From 2734beaae30c9662b3cfc00e40b8c3059ffc9bad Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Thu, 8 Oct 2026 21:11:17 -0700 Subject: [PATCH 01/54] Try every font mirror when one fails file(DOWNLOAD) with EXPECTED_HASH turns a failed download into a CMake error ("cannot compute hash on failed download"), so a timeout on the first CTAN mirror ended the configure without trying the others; the v0.1.0 tag's MinGW build failed that way. The hash is now checked after the download, a mismatch moves on to the next mirror, and a font that no mirror provides is a warning again, as the file's comment says. Co-Authored-By: Claude Opus 5.5 (1M context) --- cmake/Fonts.cmake | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/cmake/Fonts.cmake b/cmake/Fonts.cmake index de64eea..8c7cac2 100644 --- a/cmake/Fonts.cmake +++ b/cmake/Fonts.cmake @@ -18,16 +18,22 @@ function(openmm2_fetch_archive name sha256) endif() set(archive "${OPENMM2_FONT_CACHE}/${name}.download") foreach(url IN LISTS ARGN) - file(DOWNLOAD "${url}" "${archive}" EXPECTED_HASH SHA256=${sha256} STATUS status TIMEOUT 120 - INACTIVITY_TIMEOUT 30) + # The hash is checked after the download: with EXPECTED_HASH a failed + # download is a CMake error ("cannot compute hash on failed download"), + # which ends the configure before the other mirrors are tried. + file(DOWNLOAD "${url}" "${archive}" STATUS status TIMEOUT 120 INACTIVITY_TIMEOUT 30) list(GET status 0 code) if(code EQUAL 0) - file(REMOVE_RECURSE "${dir}") - file(ARCHIVE_EXTRACT INPUT "${archive}" DESTINATION "${dir}") - file(REMOVE "${archive}") - file(TOUCH "${dir}/.complete") - set(${name}_DIR "${dir}" PARENT_SCOPE) - return() + file(SHA256 "${archive}" actual) + if(actual STREQUAL sha256) + file(REMOVE_RECURSE "${dir}") + file(ARCHIVE_EXTRACT INPUT "${archive}" DESTINATION "${dir}") + file(REMOVE "${archive}") + file(TOUCH "${dir}/.complete") + set(${name}_DIR "${dir}" PARENT_SCOPE) + return() + endif() + set(status "SHA256 ${actual}, expected ${sha256}") endif() message(STATUS "Font download failed from ${url}: ${status}") endforeach() From 13634e92b6a1879cf0c0697c0ee808d9e63b44da Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Thu, 8 Oct 2026 22:11:02 -0700 Subject: [PATCH 02/54] Clean up names, chat and city names that arrive from the network MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The host cleaned up what joiners sent, but its cleanup dropped every trailing non-ASCII character ("José" became "Jos") and could cut a name inside a UTF-8 sequence when it added " (2)". Clients cleaned up nothing the host sent: names, chat, the session name, kick and reject reasons reached the UI with control characters and invalid UTF-8, the player list grew to 255 entries (each one a car in the race), and the city name went unchecked into file names and, as the last race, into the profile file, where a newline added arbitrary lines. sanitizeText keeps well-formed UTF-8 without control characters and cuts on a character boundary. Car and city names must be plain base names ([A-Za-z0-9_-], up to 32 characters): the host gives a joiner with any other car the default car and keeps the old one on a request, a client replaces such a car with the default and ignores such a city. A client keeps at most kMaxPlayers players and refuses a Welcome that does not list it. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/net/Protocol.cpp | 79 ++++++++ src/net/Protocol.h | 16 ++ src/net/Session.cpp | 104 ++++++---- tests/net/test_hostile_input.cpp | 337 +++++++++++++++++++++++++++++++ 4 files changed, 495 insertions(+), 41 deletions(-) create mode 100644 tests/net/test_hostile_input.cpp diff --git a/src/net/Protocol.cpp b/src/net/Protocol.cpp index 5d9ab03..1cb7ef4 100644 --- a/src/net/Protocol.cpp +++ b/src/net/Protocol.cpp @@ -1,6 +1,85 @@ #include "net/Protocol.h" +#include "core/StringUtil.h" + namespace mm2::net { +namespace { + +// Length of the well-formed UTF-8 sequence at `pos` (RFC 3629: no overlong +// forms, no surrogates, nothing above U+10FFFF) and its code point; 0 when the +// bytes there are not one. +std::size_t utf8Sequence(std::string_view s, std::size_t pos, char32_t& cp) { + const auto at = [&](std::size_t i) { return static_cast(static_cast(s[i])); }; + const unsigned b0 = at(pos); + if (b0 < 0x80) { + cp = b0; + return 1; + } + std::size_t len = 0; + unsigned lo = 0x80, hi = 0xBF; // allowed range of the second byte + if (b0 >= 0xC2 && b0 <= 0xDF) { + len = 2; + cp = b0 & 0x1F; + } else if (b0 >= 0xE0 && b0 <= 0xEF) { + len = 3; + cp = b0 & 0x0F; + lo = b0 == 0xE0 ? 0xA0 : 0x80; + hi = b0 == 0xED ? 0x9F : 0xBF; + } else if (b0 >= 0xF0 && b0 <= 0xF4) { + len = 4; + cp = b0 & 0x07; + lo = b0 == 0xF0 ? 0x90 : 0x80; + hi = b0 == 0xF4 ? 0x8F : 0xBF; + } else { + return 0; + } + if (len > s.size() - pos) + return 0; + for (std::size_t i = 1; i < len; ++i) { + const unsigned b = at(pos + i); + if (b < (i == 1 ? lo : 0x80u) || b > (i == 1 ? hi : 0xBFu)) + return 0; + cp = (cp << 6) | (b & 0x3F); + } + return len; +} + +} // namespace + +std::string sanitizeText(std::string_view text, std::size_t maxBytes) { + std::string clean; + clean.reserve(text.size()); + for (std::size_t pos = 0; pos < text.size();) { + char32_t cp = 0; + const std::size_t len = utf8Sequence(text, pos, cp); + if (len == 0) { + ++pos; // not UTF-8: dropped + continue; + } + if (cp == U'\t' || cp == U'\n' || cp == U'\r') + clean.push_back(' '); + else if (cp >= 0x20 && !(cp >= 0x7F && cp <= 0x9F)) + clean.append(text.substr(pos, len)); + pos += len; + } + std::string_view out = str::trim(clean); + if (out.size() > maxBytes) { + // `clean` is valid UTF-8: back up to the start of a character. + std::size_t cut = maxBytes; + while (cut > 0 && (static_cast(out[cut]) & 0xC0) == 0x80) + --cut; + out = str::trim(out.substr(0, cut)); + } + return std::string(out); +} + +bool isValidAssetName(std::string_view name) { + if (name.empty() || name.size() > kMaxShortStringLength) + return false; + return std::ranges::all_of(name, [](char c) { + return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_' || c == '-'; + }); +} const char* describe(DisconnectReason reason) { switch (reason) { diff --git a/src/net/Protocol.h b/src/net/Protocol.h index 127497e..258805f 100644 --- a/src/net/Protocol.h +++ b/src/net/Protocol.h @@ -17,6 +17,7 @@ #include #include #include +#include #include #include @@ -46,6 +47,21 @@ inline constexpr std::size_t kMaxReasonLength = 200; inline constexpr std::size_t kMaxExtraSettings = 32; inline constexpr std::size_t kMaxExtraValueLength = 64; inline constexpr std::size_t kMaxEventPayload = 1024; +inline constexpr const char* kDefaultCar = "vpbug"; // a player's car when none (or no valid one) was given + +// --- Received text ----------------------------------------------------------------- +// +// Everything below arrives from other machines and is shown in the UI, logged, +// or (the city) stored in the profile, so it is cleaned up on receipt. + +// Valid UTF-8 without control characters (C0, DEL, C1), trimmed, at most +// `maxBytes` bytes cut on a character boundary. Invalid bytes are dropped. +std::string sanitizeText(std::string_view text, std::size_t maxBytes); + +// Car and city names are base names of game files (tune/.info, +// city/.psdl): 1 to kMaxShortStringLength characters from [A-Za-z0-9_-], +// so a name from the network can never name a path. +bool isValidAssetName(std::string_view name); enum class MsgType : std::uint8_t { Challenge = 1, diff --git a/src/net/Session.cpp b/src/net/Session.cpp index de9d394..8abd9e0 100644 --- a/src/net/Session.cpp +++ b/src/net/Session.cpp @@ -19,21 +19,44 @@ DisconnectReason reasonFromData(std::uint32_t data) { std::uint32_t toData(DisconnectReason r) { return static_cast(r); } -std::string sanitize(std::string_view text, std::size_t maxLength) { - std::string out; - for (char c : str::trim(text)) { - if (static_cast(c) < 0x20 || c == 0x7F) - continue; - out.push_back(c); +std::string sanitize(std::string_view text, std::size_t maxLength) { return sanitizeText(text, maxLength); } + +// A car name from the network, or `fallback` when it is not a plain base name. +std::string carName(std::string_view car, std::string_view fallback) { + return isValidAssetName(car) ? std::string(car) : std::string(fallback); +} + +// What the host says about a player, made safe to show and to load. +void cleanPlayer(PlayerInfo& p) { + p.name = sanitize(p.name, kMaxNameLength); + p.car = carName(p.car, kDefaultCar); +} + +// The host's settings. A city that is not a plain base name becomes empty, +// which no city loads: the name would otherwise reach file names and, as the +// last race, the profile file. +void cleanSettings(SessionSettings& s) { + s.name = sanitize(s.name, kMaxNameLength * 2); + if (!s.city.empty() && !isValidAssetName(s.city)) { + log::warn("net: ignoring the host's city: not a valid name"); + s.city.clear(); } - if (out.size() > maxLength) - out.resize(maxLength); - // Don't leave a truncated UTF-8 sequence at the end. - while (!out.empty() && (static_cast(out.back()) & 0xC0) == 0x80) - out.pop_back(); - if (!out.empty() && (static_cast(out.back()) & 0x80)) - out.pop_back(); - return out; +} + +// Adds or replaces a player the host told about. False (and nothing changes) +// for the id no player has, or a new player beyond the protocol's limit: +// every listed player gets a car in the race. +bool upsertPlayer(std::vector& players, const PlayerInfo& p) { + if (p.id == kInvalidPlayerId) + return false; + if (const auto it = std::ranges::find(players, p.id, &PlayerInfo::id); it != players.end()) { + *it = p; + return true; + } + if (players.size() >= kMaxPlayers) + return false; + players.push_back(p); + return true; } std::array randomNonce() { @@ -93,9 +116,7 @@ bool Session::host(const HostParams& params, std::string* error) { self.name = sanitize(params.player.name, kMaxNameLength); if (self.name.empty()) self.name = "Host"; - self.car = sanitize(params.player.car, kMaxShortStringLength); - if (self.car.empty()) - self.car = "vpbug"; + self.car = carName(params.player.car, kDefaultCar); self.color = params.player.color; self.team = params.player.team; self.host = true; @@ -244,7 +265,7 @@ std::string Session::uniqueName(std::string name) const { return name; for (int i = 2;; ++i) { std::string suffix = std::format(" ({})", i); - std::string base = name.substr(0, kMaxNameLength - suffix.size()); + std::string base = sanitize(name, kMaxNameLength - suffix.size()); // cut on a character boundary if (!taken(base + suffix)) return base + suffix; } @@ -503,9 +524,7 @@ void Session::hostHandle(Remote& r, MsgType type, std::span dat PlayerInfo* p = findPlayer(id); if (!decodeMessage(data, req) || !p) return; - const std::string car = sanitize(req.car, kMaxShortStringLength); - if (!car.empty()) - p->car = car; + p->car = carName(req.car, p->car); // a name that is not a base name keeps the car p->color = req.color; p->team = req.team; p->ready = req.ready; @@ -562,9 +581,7 @@ void Session::hostAcceptHello(Remote& r, HelloMsg& hello) { PlayerInfo p; p.id = id; p.name = uniqueName(sanitize(hello.name, kMaxNameLength)); - p.car = sanitize(hello.car, kMaxShortStringLength); - if (p.car.empty()) - p.car = "vpbug"; + p.car = carName(hello.car, kDefaultCar); p.color = hello.color; p.team = hello.team; p.ping = static_cast(std::min(m_transport->stats(r.peer).rttMs, 65535)); @@ -639,6 +656,13 @@ void Session::clientHandle(MsgType type, std::span data) { WelcomeMsg w; if (!decodeMessage(data, w) || m_state != State::Joining) return; + cleanSettings(w.settings); + std::erase_if(w.players, [](const PlayerInfo& p) { return p.id == kInvalidPlayerId; }); + for (auto& p : w.players) + cleanPlayer(p); + // The host always lists the joiner (hostAcceptHello). + if (std::ranges::find(w.players, w.yourId, &PlayerInfo::id) == w.players.end()) + return close(DisconnectReason::ProtocolError, "the host sent an invalid welcome", true); const std::uint64_t now = monotonicMs(); m_localId = w.yourId; m_settings = std::move(w.settings); @@ -662,7 +686,7 @@ void Session::clientHandle(MsgType type, std::span data) { case MsgType::Reject: { RejectMsg rej; if (decodeMessage(data, rej)) - close(rej.reason, rej.message, true); + close(rej.reason, sanitize(rej.message, kMaxReasonLength), true); return; } default: break; @@ -676,11 +700,9 @@ void Session::clientHandle(MsgType type, std::span data) { PlayerJoinedMsg m; if (!decodeMessage(data, m)) return; - if (PlayerInfo* p = findPlayer(m.player.id)) - *p = m.player; - else - m_players.push_back(m.player); - emit(ev::PlayerJoined{m.player}); + cleanPlayer(m.player); + if (upsertPlayer(m_players, m.player)) + emit(ev::PlayerJoined{m.player}); return; } case MsgType::PlayerLeft: { @@ -700,16 +722,17 @@ void Session::clientHandle(MsgType type, std::span data) { PlayerUpdateMsg m; if (!decodeMessage(data, m)) return; - if (PlayerInfo* p = findPlayer(m.player.id)) - *p = m.player; - else - m_players.push_back(m.player); - emit(ev::PlayerUpdated{m.player}); + cleanPlayer(m.player); + if (upsertPlayer(m_players, m.player)) + emit(ev::PlayerUpdated{m.player}); return; } case MsgType::Chat: { ChatMsg m; - if (decodeMessage(data, m)) + if (!decodeMessage(data, m)) + return; + m.text = sanitize(m.text, kMaxChatLength); + if (!m.text.empty()) emit(ev::Chat{m.from, std::move(m.text)}); return; } @@ -717,6 +740,7 @@ void Session::clientHandle(MsgType type, std::span data) { SettingsMsg m; if (!decodeMessage(data, m)) return; + cleanSettings(m.settings); m_settings = m.settings; emit(ev::SettingsChanged{std::move(m.settings)}); return; @@ -745,8 +769,8 @@ void Session::clientHandle(MsgType type, std::span data) { case MsgType::Kick: { KickMsg m; decodeMessage(data, m); - close(DisconnectReason::Kicked, m.reason.empty() ? describe(DisconnectReason::Kicked) - : std::string("kicked: ") + m.reason, + const std::string reason = sanitize(m.reason, kMaxReasonLength); + close(DisconnectReason::Kicked, reason.empty() ? describe(DisconnectReason::Kicked) : "kicked: " + reason, false); return; } @@ -797,9 +821,7 @@ void Session::setLocalPlayer(const std::string& car, std::uint8_t color, std::ui if (!self || m_state != State::Active) return; if (m_role == Role::Host) { - const std::string c = sanitize(car, kMaxShortStringLength); - if (!c.empty()) - self->car = c; + self->car = carName(car, self->car); self->color = color; self->team = team; sendToPlayers(Channel::Control, PlayerUpdateMsg{*self}); diff --git a/tests/net/test_hostile_input.cpp b/tests/net/test_hostile_input.cpp new file mode 100644 index 0000000..bec2a5d --- /dev/null +++ b/tests/net/test_hostile_input.cpp @@ -0,0 +1,337 @@ +// Hostile and malformed network input: a fake host or client built on a raw +// Transport sends what an honest OpenMM2 never would, and the real Session, +// LAN scanner and beacon must stay consistent (docs/review/multiplayer-input.md). +#include "net/Discovery.h" +#include "net/Session.h" + +#include + +#include +#include +#include +#include +#include + +using namespace mm2; +using namespace mm2::net; + +namespace { + +SessionConfig fastConfig() { + SessionConfig c; + c.connectTimeoutMs = 3000; + c.joinTimeoutMs = 3000; + c.snapshotRateHz = 50; + c.interpolationDelayMs = 50; + return c; +} + +bool waitFor(const std::function& step, int timeoutMs = 3000) { + const auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(timeoutMs); + while (std::chrono::steady_clock::now() < deadline) { + if (step()) + return true; + std::this_thread::sleep_for(std::chrono::milliseconds(2)); + } + return false; +} + +// A Session and the events it reported. +struct Peer { + std::unique_ptr session = std::make_unique(fastConfig()); + std::vector events; + + void pump() { + session->update(); + for (auto& e : session->takeEvents()) + events.push_back(std::move(e)); + } + template + int count() const { + int n = 0; + for (const auto& e : events) + n += std::holds_alternative(e); + return n; + } + template + const E* find() const { + for (const auto& e : events) + if (const auto* p = std::get_if(&e)) + return p; + return nullptr; + } +}; + +// One end of a connection driven by hand: sends any bytes it likes. +struct RawEnd { + Transport transport; + PeerId peer = kInvalidPeer; + std::vector events; + + void pump() { transport.service(events, 0); } + template + void send(M msg, Channel channel = Channel::Control) { + transport.send(peer, channel, encodeMessage(std::move(msg))); + } + void sendRaw(std::span bytes, Channel channel = Channel::Control) { + transport.send(peer, channel, bytes); + } + // The first received message of type M (removed from the queue). + template + std::optional take() { + for (auto it = events.begin(); it != events.end(); ++it) { + M m; + if (it->type == TransportEvent::Type::Received && decodeMessage(it->payload, m)) { + events.erase(it); + return m; + } + } + return std::nullopt; + } + bool connected() const { + return std::ranges::any_of(events, [](const TransportEvent& e) { return e.type == TransportEvent::Type::Connected; }); + } +}; + +HostParams hostParams() { + HostParams h; + h.bind = Address::loopback(0); + h.advertiseOnLan = false; + h.player.name = "Host"; + h.player.car = "vpcop"; + return h; +} + +// A raw client that completed the handshake with `host`; returns its player id. +std::uint8_t rawJoin(Peer& host, RawEnd& raw, HelloMsg hello = {}) { + EXPECT_TRUE(raw.transport.startClient()); + raw.peer = raw.transport.connect(Address::loopback(host.session->port())); + std::optional challenge; + EXPECT_TRUE(waitFor([&] { + host.pump(); + raw.pump(); + challenge = raw.take(); + return challenge.has_value(); + })); + if (hello.name.empty()) + hello.name = "Raw"; + if (hello.car.empty()) + hello.car = "vpbug"; + raw.send(hello); + std::optional welcome; + EXPECT_TRUE(waitFor([&] { + host.pump(); + raw.pump(); + welcome = raw.take(); + return welcome.has_value(); + })); + return welcome ? welcome->yourId : kInvalidPlayerId; +} + +// A raw host listening on loopback that `client` joined; `welcome` is sent +// in answer to the client's Hello. +void rawHost(RawEnd& raw, Peer& client, WelcomeMsg welcome) { + ASSERT_TRUE(raw.transport.listen(Address::loopback(0))); + JoinParams jp; + jp.host = Address::loopback(raw.transport.port()); + jp.player.name = "Victim"; + ASSERT_TRUE(client.session->join(jp)); + ASSERT_TRUE(waitFor([&] { + client.pump(); + raw.pump(); + for (const auto& e : raw.events) + if (e.type == TransportEvent::Type::Connected) + raw.peer = e.peer; + return raw.peer != kInvalidPeer; + })); + raw.send(ChallengeMsg{}); + ASSERT_TRUE(waitFor([&] { + client.pump(); + raw.pump(); + return raw.take().has_value(); + })); + raw.send(std::move(welcome)); + ASSERT_TRUE(waitFor([&] { + client.pump(); + raw.pump(); + return client.session->state() == Session::State::Active || client.session->state() == Session::State::Closed; + })); +} + +PlayerInfo playerInfo(std::uint8_t id, std::string name, std::string car = "vpbug") { + PlayerInfo p; + p.id = id; + p.name = std::move(name); + p.car = std::move(car); + return p; +} + +WelcomeMsg welcomeFor(std::uint8_t you) { + WelcomeMsg w; + w.yourId = you; + w.players = {playerInfo(kHostPlayerId, "Host"), playerInfo(you, "Victim")}; + w.players[0].host = true; + return w; +} + +bool hasControl(std::string_view s) { + return std::ranges::any_of(s, [](char c) { return static_cast(c) < 0x20 || c == 0x7F; }); +} + +} // namespace + +// --- Names and text ------------------------------------------------------------------- + +TEST(HostileInput, SanitizeTextKeepsWellFormedUtf8Only) { + EXPECT_EQ(sanitizeText("José", 24), "José"); + EXPECT_EQ(sanitizeText("price: 5€", 24), "price: 5€"); + EXPECT_EQ(sanitizeText(" hi\tthere\r\n ", 24), "hi there"); + EXPECT_EQ(sanitizeText("a\x1b[31mb\x7f\x01", 24), "a[31mb"); + EXPECT_EQ(sanitizeText("x\xC2\x85y", 24), "xy"); // C1 control (NEL) + EXPECT_EQ(sanitizeText("x\xC3", 24), "x"); // truncated sequence + EXPECT_EQ(sanitizeText("x\xC0\xAFy", 24), "xy"); // overlong '/' + EXPECT_EQ(sanitizeText("x\xED\xA0\x80y", 24), "xy"); // UTF-16 surrogate + EXPECT_EQ(sanitizeText("x\xF4\x90\x80\x80y", 24), "xy"); // above U+10FFFF + EXPECT_EQ(sanitizeText("\xFF\xFE", 24), ""); + // Cut on a character boundary, never inside "é" (2 bytes). + EXPECT_EQ(sanitizeText("ééé", 5), "éé"); + EXPECT_EQ(sanitizeText("abc", 0), ""); + EXPECT_EQ(sanitizeText("ab cd", 3), "ab"); +} + +TEST(HostileInput, AssetNamesArePlainBaseNames) { + for (const char* ok : {"vpbug", "vpmustang99", "sf", "london", "My_Car-2"}) + EXPECT_TRUE(isValidAssetName(ok)) << ok; + for (const char* bad : {"", "../x", "a/b", "a\\b", "a.b", "x\n", "c:x", " vpbug", "vp bug"}) + EXPECT_FALSE(isValidAssetName(bad)) << bad; + EXPECT_TRUE(isValidAssetName(std::string(kMaxShortStringLength, 'a'))); + EXPECT_FALSE(isValidAssetName(std::string(kMaxShortStringLength + 1, 'a'))); +} + +// A name's last character survives the host's cleanup even when it is not +// ASCII ("José", not "Jos"). +TEST(HostileInput, NonAsciiNamesAndChatSurviveTheHost) { + Peer host; + ASSERT_TRUE(host.session->host(hostParams())); + Peer client; + JoinParams jp; + jp.host = Address::loopback(host.session->port()); + jp.player.name = "José"; + ASSERT_TRUE(client.session->join(jp)); + ASSERT_TRUE(waitFor([&] { + host.pump(); + client.pump(); + return client.find() != nullptr; + })); + ASSERT_NE(host.session->player(client.session->localId()), nullptr); + EXPECT_EQ(host.session->player(client.session->localId())->name, "José"); + + client.session->sendChat("à bientôt"); + ASSERT_TRUE(waitFor([&] { + host.pump(); + client.pump(); + return host.find() != nullptr; + })); + EXPECT_EQ(host.find()->text, "à bientôt"); +} + +// Everything text-like that a (modified) host sends is cleaned up by the +// client before the game sees it: names, chat, the session name, a kick +// reason. A city or car name that is not a plain base name never reaches +// the game (a newline in the city would end up in the profile file). +TEST(HostileInput, ClientCleansUpWhatTheHostSends) { + RawEnd raw; + Peer client; + WelcomeMsg w = welcomeFor(1); + w.settings.name = "Evil\nsession\x1b[31m"; + w.settings.city = "x\n[Races]\nhack=1"; + w.players[0].name = "Bad\nHost\x07"; + w.players[0].car = "../../tune/vpbug"; + rawHost(raw, client, w); + ASSERT_EQ(client.session->state(), Session::State::Active); + + const auto& s = client.session->settings(); + EXPECT_FALSE(hasControl(s.name)) << s.name; + EXPECT_FALSE(hasControl(s.city)) << s.city; + EXPECT_EQ(s.city.find('/'), std::string::npos); + const PlayerInfo* h = client.session->player(kHostPlayerId); + ASSERT_NE(h, nullptr); + EXPECT_FALSE(hasControl(h->name)) << h->name; + EXPECT_EQ(h->car.find('/'), std::string::npos) << h->car; + + raw.send(ChatMsg{kHostPlayerId, "hi\n\x1b[2Jthere\x7f"}); + PlayerInfo update = playerInfo(5, "New\r\nline", "vpbug\\..\\x"); + raw.send(PlayerJoinedMsg{update}); + SessionSettings later = w.settings; + later.city = "../city/london"; + raw.send(SettingsMsg{later}); + ASSERT_TRUE(waitFor([&] { + client.pump(); + raw.pump(); + return client.find() && client.session->player(5) && client.find(); + })); + EXPECT_EQ(client.find()->text.find('\n'), std::string::npos); + EXPECT_FALSE(hasControl(client.find()->text)); + EXPECT_FALSE(hasControl(client.session->player(5)->name)); + EXPECT_EQ(client.session->player(5)->car.find('\\'), std::string::npos); + EXPECT_EQ(client.session->settings().city.find('/'), std::string::npos); + + raw.send(KickMsg{"bye\n\x1b[0m"}); + ASSERT_TRUE(waitFor([&] { + client.pump(); + raw.pump(); + return client.find() != nullptr; + })); + EXPECT_FALSE(hasControl(client.find()->message)); +} + +// A host cannot make a client track more players than the protocol allows +// (each one would get a car in the race). +TEST(HostileInput, ClientKeepsAtMostMaxPlayers) { + RawEnd raw; + Peer client; + rawHost(raw, client, welcomeFor(1)); + ASSERT_EQ(client.session->state(), Session::State::Active); + for (int id = 2; id < 255; ++id) + raw.send(PlayerJoinedMsg{playerInfo(static_cast(id), std::format("P{}", id))}); + raw.send(PlayerJoinedMsg{playerInfo(kInvalidPlayerId, "Nobody")}); + raw.send(ChatMsg{kHostPlayerId, "done"}); + ASSERT_TRUE(waitFor([&] { + client.pump(); + raw.pump(); + return client.find() != nullptr; + })); + EXPECT_LE(client.session->players().size(), kMaxPlayers); + EXPECT_EQ(client.session->player(kInvalidPlayerId), nullptr); +} + +// The host keeps a player's car a plain base name: anything else in Hello +// gives the default car, and a later request with one keeps the old car. +TEST(HostileInput, HostRejectsCarNamesThatAreNotBaseNames) { + Peer host; + ASSERT_TRUE(host.session->host(hostParams())); + RawEnd raw; + HelloMsg hello; + hello.name = "Mallory"; + hello.car = "../../etc/passwd"; + const std::uint8_t id = rawJoin(host, raw, hello); + ASSERT_NE(id, kInvalidPlayerId); + const PlayerInfo* p = host.session->player(id); + ASSERT_NE(p, nullptr); + EXPECT_EQ(p->car, "vpbug"); + + raw.send(PlayerRequestMsg{"vpcoop", 1, 0, false}); + ASSERT_TRUE(waitFor([&] { + host.pump(); + raw.pump(); + return host.session->player(id)->car == "vpcoop"; + })); + raw.send(PlayerRequestMsg{"tune\\vpbug", 2, 0, true}); + ASSERT_TRUE(waitFor([&] { + host.pump(); + raw.pump(); + return host.session->player(id)->ready; + })); + EXPECT_EQ(host.session->player(id)->car, "vpcoop"); + EXPECT_EQ(host.session->player(id)->color, 2); +} From cbd7079e2a1b1a1bc9e99954e8b543cee4bd9041 Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Thu, 8 Oct 2026 22:12:38 -0700 Subject: [PATCH 03/54] Limit what one joiner can make the host relay to everyone The host relayed every chat line, PlayerRequest and game event a joiner sent to every other player on reliable channels, with no limit: one client could make the host send N-1 times its own flood and grow ENet's unbounded outgoing queues. Each joiner now has a budget per kind (chat 2 lines/s with a burst of 8, player updates 10/s with 20, game events 30/s with 60), far above what the game sends. Chat and events beyond it are dropped. A PlayerRequest is always applied and its update relayed once the budget allows, so the last car, colour, team and ready state still reaches everyone. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/net/Session.cpp | 47 +++++++++++++++++++++++++++-- src/net/Session.h | 10 ++++++ tests/net/test_hostile_input.cpp | 52 ++++++++++++++++++++++++++++++++ 3 files changed, 107 insertions(+), 2 deletions(-) diff --git a/src/net/Session.cpp b/src/net/Session.cpp index 8abd9e0..6fec028 100644 --- a/src/net/Session.cpp +++ b/src/net/Session.cpp @@ -59,6 +59,14 @@ bool upsertPlayer(std::vector& players, const PlayerInfo& p) { return true; } +// What a joiner may make the host relay to everyone else, in messages per +// second and burst. Far above what the game sends (chat typed by hand, a car, +// colour, team or ready change, a few race events); a flood beyond it would +// otherwise go out once per player on reliable channels. +constexpr double kChatRate = 2.0, kChatBurst = 8.0; +constexpr double kUpdateRate = 10.0, kUpdateBurst = 20.0; +constexpr double kEventRate = 30.0, kEventBurst = 60.0; + std::array randomNonce() { std::random_device rd; std::array n{}; @@ -81,6 +89,15 @@ std::array passwordProof(const std::array& nonce, return h.finish(); } +bool Session::RateLimit::take(std::uint64_t now, double perSecond, double burst) { + tokens = tokens < 0.0 ? burst : std::min(burst, tokens + static_cast(now - last) * perSecond / 1000.0); + last = now; + if (tokens < 1.0) + return false; + tokens -= 1.0; + return true; +} + Session::Session(SessionConfig config) : m_config(std::move(config)) { if (m_config.snapshotRateHz == 0) m_config.snapshotRateHz = 1; @@ -315,6 +332,7 @@ void Session::update() { m_transport->disconnect(p, toData(DisconnectReason::JoinTimeout)); m_remotes.erase(p); } + hostRelayUpdates(); if (now - m_lastPingBroadcast >= m_config.pingBroadcastIntervalMs) { m_lastPingBroadcast = now; @@ -528,8 +546,10 @@ void Session::hostHandle(Remote& r, MsgType type, std::span dat p->color = req.color; p->team = req.team; p->ready = req.ready; - sendToPlayers(Channel::Control, PlayerUpdateMsg{*p}); - emit(ev::PlayerUpdated{*p}); + // Every request carries the player's whole state, so one relayed + // later (hostRelayUpdates) still ends at the latest one. + r.updatePending = true; + hostRelayUpdates(); return; } case MsgType::Chat: { @@ -540,6 +560,10 @@ void Session::hostHandle(Remote& r, MsgType type, std::span dat chat.text = sanitize(chat.text, kMaxChatLength); if (chat.text.empty()) return; + if (!r.chat.take(monotonicMs(), kChatRate, kChatBurst)) { + log::debug("net: dropped chat from player {}: too many lines", id); + return; + } sendToPlayers(Channel::Control, chat); emit(ev::Chat{id, chat.text}); return; @@ -562,6 +586,10 @@ void Session::hostHandle(Remote& r, MsgType type, std::span dat GameEventMsg msg; if (!decodeMessage(data, msg)) return; + if (!r.events.take(monotonicMs(), kEventRate, kEventBurst)) { + log::debug("net: dropped game event {} from player {}: too many events", msg.type, id); + return; + } msg.from = id; hostRelayEvent(id, std::move(msg)); return; @@ -601,6 +629,21 @@ void Session::hostAcceptHello(Remote& r, HelloMsg& hello) { emit(ev::PlayerJoined{p}); } +// Relays the joiners' applied PlayerRequests while their budget allows; a +// joiner over it is relayed later, with whatever its latest state is then. +void Session::hostRelayUpdates() { + const std::uint64_t now = monotonicMs(); + for (auto& [peer, r] : m_remotes) { + if (!r.updatePending || !r.updates.take(now, kUpdateRate, kUpdateBurst)) + continue; + r.updatePending = false; + if (const PlayerInfo* p = findPlayer(r.playerId)) { + sendToPlayers(Channel::Control, PlayerUpdateMsg{*p}); + emit(ev::PlayerUpdated{*p}); + } + } +} + void Session::hostRemovePlayer(std::uint8_t id, DisconnectReason reason) { const auto it = std::ranges::find(m_players, id, &PlayerInfo::id); if (it == m_players.end()) diff --git a/src/net/Session.h b/src/net/Session.h index a496596..db71390 100644 --- a/src/net/Session.h +++ b/src/net/Session.h @@ -180,11 +180,20 @@ class Session { PeerStats peerStats(std::uint8_t playerId) const; private: + // Token bucket: how many messages of a kind a joiner may make the host + // relay to everyone (each one goes out once per other player). + struct RateLimit { + double tokens = -1.0; // full on first use + std::uint64_t last = 0; + bool take(std::uint64_t now, double perSecond, double burst); + }; struct Remote { PeerId peer = kInvalidPeer; std::uint8_t playerId = kInvalidPlayerId; // assigned after Hello std::array nonce{}; std::uint64_t connectedAt = 0; + RateLimit chat, updates, events; + bool updatePending = false; // a PlayerRequest applied but not yet relayed }; void resetState(); @@ -194,6 +203,7 @@ class Session { void hostAcceptHello(Remote& r, HelloMsg& hello); void hostRemovePlayer(std::uint8_t id, DisconnectReason reason); void hostRelayEvent(std::uint8_t from, GameEventMsg msg); + void hostRelayUpdates(); void hostSendWorldState(); void hostAdvertise(); void close(DisconnectReason reason, std::string message, bool failedJoin); diff --git a/tests/net/test_hostile_input.cpp b/tests/net/test_hostile_input.cpp index bec2a5d..3110cfd 100644 --- a/tests/net/test_hostile_input.cpp +++ b/tests/net/test_hostile_input.cpp @@ -335,3 +335,55 @@ TEST(HostileInput, HostRejectsCarNamesThatAreNotBaseNames) { EXPECT_EQ(host.session->player(id)->car, "vpcoop"); EXPECT_EQ(host.session->player(id)->color, 2); } + +// --- Floods ----------------------------------------------------------------------------- + +// A client cannot make the host relay an unbounded stream of chat lines, +// requests and events to everyone else. +TEST(HostileInput, HostRateLimitsWhatItRelays) { + Peer host; + ASSERT_TRUE(host.session->host(hostParams())); + Peer watcher; + JoinParams jp; + jp.host = Address::loopback(host.session->port()); + jp.player.name = "Watcher"; + ASSERT_TRUE(watcher.session->join(jp)); + ASSERT_TRUE(waitFor([&] { + host.pump(); + watcher.pump(); + return watcher.find() != nullptr; + })); + RawEnd raw; + const std::uint8_t id = rawJoin(host, raw); + ASSERT_NE(id, kInvalidPlayerId); + const int updatesBefore = watcher.count(); + + for (int i = 0; i < 300; ++i) { + raw.send(ChatMsg{0, std::format("spam {}", i)}); + raw.send(PlayerRequestMsg{"vpbug", static_cast(i % 8), 0, false}); + GameEventMsg ev; + ev.type = static_cast(GameEventType::Custom); + raw.send(ev, Channel::Events); + } + waitFor([&] { + host.pump(); + raw.pump(); + watcher.pump(); + return false; + }, 600); + EXPECT_LT(watcher.count(), 50); + EXPECT_LT(watcher.count() - updatesBefore, 100); + EXPECT_LT(watcher.count(), 150); + EXPECT_GT(watcher.count(), 0); // a normal pace still gets through + EXPECT_GT(watcher.count(), 0); + // The player's last request (colour 299 % 8) still reaches everyone. + EXPECT_EQ(host.session->player(id)->color, 3); + ASSERT_TRUE(waitFor([&] { + host.pump(); + raw.pump(); + watcher.pump(); + const PlayerInfo* p = watcher.session->player(id); + return p && p->color == 3; + })); +} + From 3029e41864e62da060a59fc1be9792680822c69b Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Thu, 8 Oct 2026 22:13:52 -0700 Subject: [PATCH 04/54] Cap the size of messages ENet accepts from a peer ENet's defaults allow a 32 MiB packet and 32 MiB of undelivered data per peer, and it allocates a fragmented packet's full size when the first fragment arrives. Any connected peer, before it even sends Hello, could make the host allocate tens of megabytes with a couple of datagrams, and with 16 peer slots about a gigabyte. TransportConfig now limits a packet to 64 KiB and a peer's waiting data to 1 MiB on both ends. OpenMM2's largest message, a Welcome with 16 players and 32 extra settings, is about 5 KiB. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/net/Transport.cpp | 11 ++++++--- src/net/Transport.h | 8 +++++++ tests/net/test_hostile_input.cpp | 38 ++++++++++++++++++++++++++++++++ 3 files changed, 54 insertions(+), 3 deletions(-) diff --git a/src/net/Transport.cpp b/src/net/Transport.cpp index 1ba29f6..819e852 100644 --- a/src/net/Transport.cpp +++ b/src/net/Transport.cpp @@ -61,8 +61,7 @@ bool Transport::listen(const Address& bind, std::string* error) { setError(error, std::format("cannot listen on UDP {} (port in use?)", bind.toString())); return false; } - if (m_config.compress) - enet_host_compress_with_range_coder(m_host); + configureHost(); log::info("net: listening on UDP port {}", port()); return true; } @@ -81,9 +80,15 @@ bool Transport::startClient(std::string* error) { setError(error, "cannot create a UDP socket"); return false; } + configureHost(); + return true; +} + +void Transport::configureHost() { if (m_config.compress) enet_host_compress_with_range_coder(m_host); - return true; + m_host->maximumPacketSize = m_config.maxPacketSize; + m_host->maximumWaitingData = m_config.maxWaitingData; } PeerId Transport::registerPeer(ENetPeer* peer) { diff --git a/src/net/Transport.h b/src/net/Transport.h index 989054f..768e7d7 100644 --- a/src/net/Transport.h +++ b/src/net/Transport.h @@ -48,6 +48,13 @@ struct TransportConfig { std::uint32_t timeoutMinMs = 5000; std::uint32_t timeoutMaxMs = 15000; bool compress = true; // ENet range coder + // Largest message a peer may send, and how much of a peer's received but + // not yet serviced data may wait. ENet's defaults (32 MiB each) let any + // connected peer make this machine allocate a whole packet's size with its + // first fragment. OpenMM2's largest message (a Welcome with 16 players and + // 32 extra settings) is about 5 KiB. + std::size_t maxPacketSize = 64 * 1024; + std::size_t maxWaitingData = 1024 * 1024; }; class Transport { @@ -94,6 +101,7 @@ class Transport { private: _ENetPeer* find(PeerId id) const; PeerId registerPeer(_ENetPeer* peer); + void configureHost(); void configurePeer(_ENetPeer* peer); void accountTraffic(); diff --git a/tests/net/test_hostile_input.cpp b/tests/net/test_hostile_input.cpp index 3110cfd..5e47bc0 100644 --- a/tests/net/test_hostile_input.cpp +++ b/tests/net/test_hostile_input.cpp @@ -387,3 +387,41 @@ TEST(HostileInput, HostRateLimitsWhatItRelays) { })); } +// A peer cannot make the host buffer a huge message (ENet's default limit +// is 32 MiB per packet, allocated when the first fragment arrives). +TEST(HostileInput, OversizedPacketsAreNotDelivered) { + Transport host; + ASSERT_TRUE(host.listen(Address::loopback(0))); + TransportConfig big; + big.maxPacketSize = 4 * 1024 * 1024; + Transport client(big); + ASSERT_TRUE(client.startClient()); + const PeerId toHost = client.connect(Address::loopback(host.port())); + std::vector hostEvents, clientEvents; + ASSERT_TRUE(waitFor([&] { + host.service(hostEvents); + client.service(clientEvents); + return std::ranges::any_of(clientEvents, + [](const auto& e) { return e.type == TransportEvent::Type::Connected; }); + })); + const std::vector small(16, std::byte{1}); + const std::vector huge(256 * 1024, std::byte{2}); + ASSERT_TRUE(client.send(toHost, Channel::Control, small)); + ASSERT_TRUE(client.send(toHost, Channel::Events, huge)); + std::size_t largest = 0; + bool gotSmall = false; + waitFor([&] { + host.service(hostEvents); + client.service(clientEvents); + for (const auto& e : hostEvents) { + if (e.type != TransportEvent::Type::Received) + continue; + largest = std::max(largest, e.payload.size()); + gotSmall = gotSmall || e.payload.size() == small.size(); + } + hostEvents.clear(); + return false; + }, 800); + EXPECT_TRUE(gotSmall); + EXPECT_LT(largest, huge.size()); +} From e15946ed6ce78514cb98af6c8366f2763034049f Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Thu, 8 Oct 2026 22:14:21 -0700 Subject: [PATCH 05/54] Carry the host's ambient traffic to the clients of a network cruise MM2's network cruise has no traffic and no police (mmGameMulti::Init zeroes their densities). OpenMM2 adds them as an extra: the host runs them and sends each client the cars near it. This is the wire and the replication logic; the race wiring follows. - net::AmbientStateMsg on a new unreliable, sequenced Ambient channel: per car the slot id, a generation that changes when the slot is reused, the model (catalog index), paint job, position relative to the message's origin (3 cm), orientation, the speed of a car on its rail or the full velocities of a knocked car or a police car, and flags (brake, horn, indicators, off its rail, wreck, siren, pursuit); police add their target, damage, rpm, throttle and gear. A rail car takes 118 bits. - SessionSettings::sharedTraffic (the host's lobby option, on by default) and RaceConfig::netTraffic; protocol version 2. - game::TrafficHost picks each client's cars: the police chasing it, then the nearest within 200 m (kept until 230 m), within one unfragmented datagram. game::TrafficClient follows them with the remote players' interpolation buffer: every message is complete, so a car missing from a newer one has left, an older one never brings a car back, and a new generation is a new car rather than a blend across the city. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/game/RaceConfig.h | 5 + src/game/net/NetGame.cpp | 21 +++ src/game/net/NetGame.h | 13 ++ src/game/net/TrafficSync.cpp | 297 +++++++++++++++++++++++++++++ src/game/net/TrafficSync.h | 205 ++++++++++++++++++++ src/net/AmbientState.cpp | 23 +++ src/net/AmbientState.h | 174 +++++++++++++++++ src/net/CMakeLists.txt | 1 + src/net/Protocol.h | 15 +- src/net/Session.cpp | 21 +++ src/net/Session.h | 11 ++ src/net/Transport.cpp | 3 +- tests/game/test_traffic_sync.cpp | 291 ++++++++++++++++++++++++++++ tests/net/test_ambient_state.cpp | 312 +++++++++++++++++++++++++++++++ 14 files changed, 1388 insertions(+), 4 deletions(-) create mode 100644 src/game/net/TrafficSync.cpp create mode 100644 src/game/net/TrafficSync.h create mode 100644 src/net/AmbientState.cpp create mode 100644 src/net/AmbientState.h create mode 100644 tests/game/test_traffic_sync.cpp create mode 100644 tests/net/test_ambient_state.cpp diff --git a/src/game/RaceConfig.h b/src/game/RaceConfig.h index 12743ad..73f0504 100644 --- a/src/game/RaceConfig.h +++ b/src/game/RaceConfig.h @@ -52,6 +52,11 @@ struct RaceConfig { // Multiplayer (filled from the lobby; unused in single player). bool multiplayer = false; + // Multiplayer cruise: the host's ambient traffic and police (at the + // traffic and cop densities above) are shared with every player, an + // OpenMM2 extra; false gives MM2's network cruise, which has neither + // (mmGameMulti::Init) and keeps each machine's own pedestrians. + bool netTraffic = true; CopsAndRobbersMode copsAndRobbers = CopsAndRobbersMode::FreeForAll; float timeLimitMinutes = 0.0f; int pointLimit = 0; diff --git a/src/game/net/NetGame.cpp b/src/game/net/NetGame.cpp index ed96619..fb3dce7 100644 --- a/src/game/net/NetGame.cpp +++ b/src/game/net/NetGame.cpp @@ -104,6 +104,7 @@ net::SessionSettings toSessionSettings(const RaceConfig& c, const std::string& n s.trafficDensity = percent(c.trafficDensity); s.pedDensity = percent(c.pedestrianDensity); s.cops = c.copDensity > 0.0f; + s.sharedTraffic = c.netTraffic; s.maxPlayers = static_cast(std::clamp(maxPlayers, 2, static_cast(net::kMaxPlayers))); // Options without a protocol field. setExtra(s, "weather", std::to_string(static_cast(c.weather))); @@ -136,6 +137,7 @@ RaceConfig fromSessionSettings(const net::SessionSettings& s) { c.trafficDensity = s.trafficDensity / 100.0f; c.pedestrianDensity = s.pedDensity / 100.0f; c.copDensity = std::clamp(extraInt(s, "copDensity", s.cops ? 50 : 0), 0, 100) / 100.0f; + c.netTraffic = s.sharedTraffic; if (const std::string* d = extra(s, "difficulty")) c.difficulty = *d == "pro" ? Difficulty::Professional : Difficulty::Amateur; c.opponents = std::clamp(extraInt(s, "opponents", 0), 0, 7); @@ -651,6 +653,25 @@ void NetGame::sendDamage(float damage, std::uint8_t source) { std::vector NetGame::takeGameEvents() { return std::exchange(m_gameEvents, {}); } +// --- Shared ambient traffic -------------------------------------------------------------- + +bool NetGame::sharedTraffic() const { + const auto& s = settings(); + return m_impl->session && s.mode == net::GameMode::Cruise && s.sharedTraffic; +} + +std::size_t NetGame::sendAmbientState(std::uint8_t playerId, const net::AmbientStateMsg& msg) { + return m_impl->session ? m_impl->session->sendAmbientState(playerId, msg) : 0; +} + +std::vector NetGame::takeAmbientStates() { + return m_impl->session ? m_impl->session->takeAmbientStates() : std::vector{}; +} + +net::PeerStats NetGame::peerStats(std::uint8_t playerId) const { + return m_impl->session ? m_impl->session->peerStats(playerId) : net::PeerStats{}; +} + // --- Helpers ----------------------------------------------------------------------------- // NetArena::AddGameChatLine. diff --git a/src/game/net/NetGame.h b/src/game/net/NetGame.h index 946894a..76586c2 100644 --- a/src/game/net/NetGame.h +++ b/src/game/net/NetGame.h @@ -10,9 +10,11 @@ #include "core/Math.h" #include "game/RaceConfig.h" +#include "net/AmbientState.h" #include "net/Discovery.h" #include "net/Protocol.h" #include "net/Snapshot.h" +#include "net/Transport.h" #include #include @@ -206,6 +208,17 @@ class NetGame { // Every other player in the session, sampled for this frame. std::vector remoteCars() const; + // --- Shared ambient traffic (multiplayer cruise, OpenMM2 extra) ---------------- + // Whether this session's cruise shares the host's traffic and police + // (the host's lobby option, carried by the session settings). + bool sharedTraffic() const; + // Host: sends one player its cars (unreliable); returns the bytes sent. + std::size_t sendAmbientState(std::uint8_t playerId, const net::AmbientStateMsg& msg); + // Client: the host's messages received since the last call. + std::vector takeAmbientStates(); + // Connection statistics to a player (host) or to the host (client). + net::PeerStats peerStats(std::uint8_t playerId) const; + // Game events (reliable, ordered). Race time is ms since the race start. void sendCheckpoint(int index, std::uint32_t raceTimeMs); void sendLap(int lap, std::uint32_t lapTimeMs); diff --git a/src/game/net/TrafficSync.cpp b/src/game/net/TrafficSync.cpp new file mode 100644 index 0000000..8f83ce2 --- /dev/null +++ b/src/game/net/TrafficSync.cpp @@ -0,0 +1,297 @@ +// Shared ambient traffic of a multiplayer cruise (OpenMM2 extra): the +// catalog, the host's choice of cars per client and the client's +// interpolation. See TrafficSync.h and docs/multiplayer.md. +#include "game/net/TrafficSync.h" + +#include "core/StringUtil.h" + +#include +#include + +namespace mm2::game { +namespace { + +constexpr double kAiStepMs = 1000.0 / 30.0; // ai::kAiStepSeconds + +bool finite(const Vec3& v) { return std::isfinite(v.x) && std::isfinite(v.y) && std::isfinite(v.z); } + +// Signed difference of two session times (they may wrap). +std::int64_t later(std::uint32_t a, std::uint32_t b) { + return static_cast(static_cast(a - b)); +} + +net::AmbientEntity toEntity(const SharedCar& c) { + net::AmbientEntity e; + e.id = static_cast(c.id); + e.generation = static_cast(static_cast(c.generation) % net::kAmbientGenerations); + e.kind = c.kind; + e.model = static_cast(c.model); + e.paint = static_cast(std::clamp(c.paint, 0, static_cast(net::kMaxAmbientPaint))); + e.position = c.transform.m3; + e.orientation = Quat::fromMatrix(c.transform); + e.speed = c.speed; + e.velocity = c.velocity; + e.angularVelocity = c.angularVelocity; + e.flags = c.flags; + e.target = c.target; + e.damage = c.damage; + e.rpm = c.rpm; + e.throttle = c.throttle; + e.gear = static_cast(std::clamp(c.gear, -1, 8)); + return e; +} + +} // namespace + +// --- TrafficCatalog ---------------------------------------------------------------------- + +void TrafficCatalog::add(std::string_view model) { + if (model.empty() || find(model) >= 0 || m_models.size() >= net::kMaxAmbientModels) + return; + m_models.push_back(str::lower(model)); +} + +int TrafficCatalog::find(std::string_view model) const { + for (std::size_t i = 0; i < m_models.size(); ++i) + if (str::iequals(m_models[i], model)) + return static_cast(i); + return -1; +} + +const std::string* TrafficCatalog::name(int index) const { + if (index < 0 || static_cast(index) >= m_models.size()) + return nullptr; + return &m_models[static_cast(index)]; +} + +std::uint16_t TrafficCatalog::checksum() const { + std::uint32_t h = 2166136261u; + for (const auto& m : m_models) { + for (char c : m) { + h ^= static_cast(c); + h *= 16777619u; + } + h ^= 0xFFu; // separator + h *= 16777619u; + } + return static_cast((h >> 16) ^ (h & 0xFFFFu)); +} + +// --- TrafficHost ------------------------------------------------------------------------- + +net::AmbientStateMsg TrafficHost::build(const TrafficViewer& viewer, std::span cars, + std::uint32_t time, std::uint32_t lightSteps, std::uint16_t catalog) { + net::AmbientStateMsg msg; + msg.time = time; + msg.lightSteps = lightSteps; + msg.catalog = catalog; + msg.setOrigin(viewer.position); + const Vec3 origin = msg.originVec(); + std::unordered_set& set = m_sets[viewer.player]; + + struct Candidate { + const SharedCar* car; + float distance2; + bool chasing; + }; + std::vector candidates; + const float enter2 = m_options.enterRadius * m_options.enterRadius; + const float leave2 = m_options.leaveRadius * m_options.leaveRadius; + // Kept inside the encodable offsets with a margin for the rounding. + const float reach = net::kAmbientOffsetRange - 2.0f, reachUp = net::kAmbientHeightRange - 2.0f; + for (const SharedCar& c : cars) { + if (c.id < 0 || c.id >= static_cast(net::kMaxAmbientIds) || c.model < 0 || + c.model >= static_cast(net::kMaxAmbientModels) || !finite(c.transform.m3)) + continue; + const Vec3 d = c.transform.m3 - origin; + if (std::abs(d.x) > reach || std::abs(d.z) > reach || std::abs(d.y) > reachUp) + continue; + const float dx = c.transform.m3.x - viewer.position.x, dz = c.transform.m3.z - viewer.position.z; + const float d2 = dx * dx + dz * dz; + const bool chasing = c.kind == net::AmbientKind::Police && c.target == viewer.player; + if (chasing || d2 < enter2 || (d2 < leave2 && set.contains(c.id))) + candidates.push_back({&c, d2, chasing}); + } + std::ranges::stable_sort(candidates, [](const Candidate& a, const Candidate& b) { + if (a.chasing != b.chasing) + return a.chasing; + return a.distance2 < b.distance2; + }); + + // The header: type, time, light steps, catalog, origin, the count. + constexpr std::size_t kHeaderBits = 8 + 32 + 32 + 16 + 48 + 8; + const std::size_t total = m_options.maxBytes * 8; + const std::size_t budget = total > kHeaderBits ? total - kHeaderBits : 0; + std::size_t used = 0; + std::unordered_set sent; + for (const Candidate& c : candidates) { + if (msg.entities.size() >= net::kMaxAmbientPerMessage) + break; + net::AmbientEntity e = toEntity(*c.car); + const std::size_t bits = net::ambientEntityBits(e); + if (used + bits > budget) + break; + used += bits; + if (!sent.insert(c.car->id).second) + continue; // the same id twice: the first one counts + msg.entities.push_back(std::move(e)); + } + set = std::move(sent); + return msg; +} + +// --- TrafficClient ----------------------------------------------------------------------- + +TrafficClient::TrafficClient(std::size_t catalogSize, std::uint16_t catalogChecksum, const Options& options) + : m_options(options), m_catalogSize(catalogSize), m_catalog(catalogChecksum) {} + +void TrafficClient::clear() { + m_entries.clear(); + m_cars.clear(); + m_any = false; + m_latest = 0; +} + +void TrafficClient::restart(Entry& entry, const net::AmbientEntity& e, std::uint32_t time) { + entry.generation = e.generation; + entry.kind = e.kind; + entry.model = e.model; + entry.paint = e.paint; + entry.buffer.clear(); + entry.firstTime = time; + entry.lastTime = time; + entry.goneAt.reset(); + entry.horn = false; + entry.shown = false; +} + +void TrafficClient::receive(const net::AmbientStateMsg& msg) { + ++m_stats.messages; + if (msg.catalog != m_catalog) + m_mismatch = true; + const bool newest = !m_any || later(msg.time, m_latest) >= 0; + if (!newest) + ++m_stats.outdated; + std::unordered_set listed; + for (const net::AmbientEntity& e : msg.entities) { + ++m_stats.entities; + const int id = e.id; + if (id >= static_cast(net::kMaxAmbientIds) || e.model >= m_catalogSize || + e.paint > net::kMaxAmbientPaint || e.kind > net::AmbientKind::Last || !finite(e.position) || + !finite(e.velocity) || !finite(e.angularVelocity) || !std::isfinite(e.speed) || + !listed.insert(id).second) { + ++m_stats.refused; + continue; + } + auto it = m_entries.find(id); + const bool sameCar = it != m_entries.end() && it->second.generation == e.generation && + it->second.kind == e.kind && it->second.model == e.model && + it->second.paint == e.paint; + if (!newest && !sameCar) + continue; // an old message must not bring a car back or swap it + if (it == m_entries.end()) + it = m_entries.emplace(id, Entry{}).first; + Entry& entry = it->second; + if (!sameCar) { + restart(entry, e, msg.time); + } else if (newest && entry.buffer.latest()) { + // Put somewhere else without a new generation (a police car back + // at its post): shown there at once. + const net::VehicleSnapshot& last = *entry.buffer.latest(); + const auto ms = std::max(0, later(msg.time, last.time)); + const float dt = static_cast(ms) * 0.001f; + const Vec3 expected = last.position + last.linearVelocity * dt; + // (30 m/s of velocity change on top of the allowance.) + if (expected.dist(e.position) > m_options.teleportDistance + 30.0f * dt) { + ++m_stats.teleports; + restart(entry, e, msg.time); + } + } + net::VehicleSnapshot s; + s.time = msg.time; + s.position = e.position; + s.orientation = e.orientation; + s.linearVelocity = e.velocity; + s.angularVelocity = e.angularVelocity; + s.controls.throttle = e.throttle; + s.controls.gear = e.gear; + s.damage = e.damage; + s.flags = e.flags; + entry.buffer.push(s); + if (later(msg.time, entry.lastTime) >= 0) { + entry.lastTime = msg.time; + entry.target = e.target; + entry.rpm = e.rpm; + } + if (later(msg.time, entry.firstTime) < 0) + entry.firstTime = msg.time; + if (newest) + entry.goneAt.reset(); + } + if (!newest) + return; + // Complete for this client: whatever it knows and this newer message + // leaves out has gone (out of range, or back in the pool) by now. + for (auto& [id, entry] : m_entries) + if (!listed.contains(id) && !entry.goneAt && later(msg.time, entry.lastTime) > 0) + entry.goneAt = msg.time; + m_any = true; + m_latest = msg.time; + m_lightTime = msg.time; + m_lightSteps = msg.lightSteps; +} + +void TrafficClient::update(double renderTime) { + m_cars.clear(); + for (auto it = m_entries.begin(); it != m_entries.end();) { + Entry& entry = it->second; + const bool gone = entry.goneAt && renderTime >= static_cast(*entry.goneAt); + const bool stale = renderTime - static_cast(entry.lastTime) > m_options.staleMs; + if (gone || stale || entry.buffer.size() == 0) { + it = m_entries.erase(it); + continue; + } + const int id = it->first; + ++it; + if (renderTime < static_cast(entry.firstTime)) + continue; // not on the host's screen yet at this time + net::VehicleSnapshot s; + const auto result = entry.buffer.sample(renderTime, s, m_options.maxExtrapolationMs); + if (result == net::SnapshotBuffer::Result::Empty) + continue; + Car c; + c.id = id; + c.kind = entry.kind; + c.generation = entry.generation; + c.model = entry.model; + c.paint = entry.paint; + c.transform = s.orientation.normalized().toMatrix(s.position); + c.velocity = s.linearVelocity; + c.angularVelocity = s.angularVelocity; + c.speed = -c.transform.m2.dot(c.velocity); + c.flags = s.flags; + c.target = entry.target; + c.damage = s.damage; + c.rpm = entry.rpm; + c.throttle = s.controls.throttle; + c.gear = s.controls.gear; + c.extrapolated = result != net::SnapshotBuffer::Result::Interpolated; + const bool horn = (s.flags & net::kAmbientHorn) != 0; + c.hornStarted = horn && !entry.horn; + entry.horn = horn; + c.fresh = !entry.shown; + entry.shown = true; + m_cars.push_back(c); + entry.buffer.prune(renderTime); + } +} + +std::optional TrafficClient::lightSteps(double renderTime) const { + if (!m_any) + return std::nullopt; + const double steps = static_cast(m_lightSteps) + + std::floor((renderTime - static_cast(m_lightTime)) / kAiStepMs); + return static_cast(std::max(0.0, steps)); +} + +} // namespace mm2::game diff --git a/src/game/net/TrafficSync.h b/src/game/net/TrafficSync.h new file mode 100644 index 0000000..2642f5a --- /dev/null +++ b/src/game/net/TrafficSync.h @@ -0,0 +1,205 @@ +#pragma once + +// The shared ambient traffic and police of a multiplayer cruise, an OpenMM2 +// extra (docs/multiplayer.md, "Shared traffic"; docs/parity/openmm2-only.md). +// MM2's network cruise has no traffic and no police (mmGameMulti::Init); with +// the host's lobby option on, the host runs ai::World's traffic and the +// police for everyone and the clients show what it sends. +// +// TrafficCatalog the models a shared car can be, by index (both machines +// build it from the same data; a checksum tells them apart) +// TrafficHost host side: which cars each client gets (the ones near its +// car, the police chasing it first) within a packet budget +// TrafficClient client side: the received cars, interpolated in session +// time like the remote players (net::SnapshotBuffer) +// +// Nothing here touches the network or the simulation: the race screen feeds +// the host's cars in and sends the messages (game::NetGame), and feeds the +// client's messages in and draws, hears and collides with what comes out. + +#include "core/Math.h" +#include "net/AmbientState.h" +#include "net/Snapshot.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace mm2::game { + +class TrafficCatalog { +public: + // Adds a model (case-insensitive; duplicates and models beyond + // net::kMaxAmbientModels are ignored). + void add(std::string_view model); + // Index of a model, -1 when it is not listed. + int find(std::string_view model) const; + // Model at `index`, null out of range. + const std::string* name(int index) const; + std::size_t size() const { return m_models.size(); } + // FNV-1a over the lower-case names, folded to 16 bits. + std::uint16_t checksum() const; + +private: + std::vector m_models; +}; + +// One car of the host's simulation as it is shared. +struct SharedCar { + // 0 .. net::kMaxAmbientIds - 1: the traffic slot, or the police car's + // place after the pool. + int id = 0; + net::AmbientKind kind = net::AmbientKind::Traffic; + int generation = 0; // changes when the slot is reused (only the low bits travel) + int model = 0; // catalog index + int paint = 0; // paint job + Mat34 transform; // model origin + float speed = 0.0f; // along -m2 (rail cars) + Vec3 velocity; + Vec3 angularVelocity; + std::uint8_t flags = 0; // net::AmbientFlags + // Police. + std::uint8_t target = net::kAmbientNoTarget; + float damage = 0.0f; + float rpm = 0.0f; + float throttle = 0.0f; + int gear = 0; +}; + +// A client the host sends cars to: its player id and where its car is (the +// host's view of it). +struct TrafficViewer { + std::uint8_t player = net::kInvalidPlayerId; + Vec3 position; +}; + +class TrafficHost { +public: + struct Options { + // A car joins a client's set within enterRadius of its car (on the + // ground plane) and leaves it beyond leaveRadius, so a car near the + // edge does not come and go with every message. + float enterRadius = 200.0f; + float leaveRadius = 230.0f; + // The message's size, bytes (the header included): one unfragmented + // UDP datagram. + std::size_t maxBytes = 1100; + }; + + TrafficHost() = default; + explicit TrafficHost(const Options& options) : m_options(options) {} + + // The message for `viewer`: the police chasing it, then the cars nearest + // it, as many as fit. Cars with an id or model out of range are skipped. + net::AmbientStateMsg build(const TrafficViewer& viewer, std::span cars, + std::uint32_t time, std::uint32_t lightSteps, std::uint16_t catalog); + // A player left: its set is forgotten. + void forget(std::uint8_t player) { m_sets.erase(player); } + + const Options& options() const { return m_options; } + +private: + Options m_options; + std::map> m_sets; // the ids each client got last +}; + +class TrafficClient { +public: + struct Options { + // As the remote players (net::SessionConfig): shown this far in the + // past, extrapolated at most this far beyond the newest message. + double interpolationDelayMs = 100.0; + double maxExtrapolationMs = 250.0; + // A car not heard of for this long is dropped (the host stopped + // sending, or every message for a while was lost). + double staleMs = 1500.0; + // A car that moved this much further than its velocity explains was + // put somewhere else: it is shown there at once, not blended. + float teleportDistance = 25.0f; + }; + + // `catalogSize`: the client's own catalog; models beyond it are refused. + TrafficClient(std::size_t catalogSize, std::uint16_t catalogChecksum) + : TrafficClient(catalogSize, catalogChecksum, Options{}) {} + TrafficClient(std::size_t catalogSize, std::uint16_t catalogChecksum, const Options& options); + + // A message from the host (untrusted: checked as it is read). + void receive(const net::AmbientStateMsg& msg); + // The cars at `renderTime` (session ms, normally session time less the + // interpolation delay); cars that have left by then are dropped. + void update(double renderTime); + + struct Car { + int id = 0; + net::AmbientKind kind = net::AmbientKind::Traffic; + int generation = 0; + int model = 0; + int paint = 0; + Mat34 transform; // model origin + Vec3 velocity; + Vec3 angularVelocity; + float speed = 0.0f; // along -m2 + std::uint8_t flags = 0; // net::AmbientFlags + std::uint8_t target = net::kAmbientNoTarget; + float damage = 0.0f; + float rpm = 0.0f; + float throttle = 0.0f; + int gear = 0; + bool extrapolated = false; // beyond the newest message (late or lost packets) + bool hornStarted = false; // the horn sounded since the previous update + bool fresh = false; // shown for the first time (or put somewhere new) this update + }; + const std::vector& cars() const { return m_cars; } + // The host's traffic light steps at `renderTime`, once a message came. + std::optional lightSteps(double renderTime) const; + // The host's catalog differs from this client's. + bool catalogMismatch() const { return m_mismatch; } + std::size_t known() const { return m_entries.size(); } + void clear(); + + struct Stats { + std::uint64_t messages = 0; + std::uint64_t entities = 0; + std::uint64_t refused = 0; // entries with a model out of range, duplicates, bad values + std::uint64_t outdated = 0; // messages older than the newest + std::uint64_t teleports = 0; + }; + const Stats& stats() const { return m_stats; } + const Options& options() const { return m_options; } + +private: + struct Entry { + int generation = 0; + net::AmbientKind kind = net::AmbientKind::Traffic; + int model = 0; + int paint = 0; + net::SnapshotBuffer buffer{32}; + std::uint32_t firstTime = 0; // its first snapshot (hidden before it) + std::uint32_t lastTime = 0; // its newest snapshot + std::optional goneAt; // missing from a newer message: gone from this time on + std::uint8_t target = net::kAmbientNoTarget; + float rpm = 0.0f; + bool horn = false; // the horn flag as last shown + bool shown = false; // shown since its last (re)start + }; + void restart(Entry& entry, const net::AmbientEntity& e, std::uint32_t time); + + Options m_options; + std::size_t m_catalogSize = 0; + std::uint16_t m_catalog = 0; + bool m_mismatch = false; + bool m_any = false; + std::uint32_t m_latest = 0; // the newest message's time + std::uint32_t m_lightTime = 0, m_lightSteps = 0; + std::map m_entries; + std::vector m_cars; + Stats m_stats; +}; + +} // namespace mm2::game diff --git a/src/net/AmbientState.cpp b/src/net/AmbientState.cpp new file mode 100644 index 0000000..b3ce381 --- /dev/null +++ b/src/net/AmbientState.cpp @@ -0,0 +1,23 @@ +#include "net/AmbientState.h" + +#include +#include + +namespace mm2::net { + +void AmbientStateMsg::setOrigin(const Vec3& p) { + const float v[3] = {p.x, p.y, p.z}; + for (int i = 0; i < 3; ++i) { + const float r = std::isfinite(v[i]) ? std::round(v[i]) : 0.0f; + origin[i] = static_cast(std::clamp(r, -32768.0f, 32767.0f)); + } +} + +std::size_t ambientEntityBits(const AmbientEntity& e) { + WriteStream s; + AmbientEntity copy = e; + serializeAmbientEntity(s, copy, e.position); + return s.writer().bitCount(); +} + +} // namespace mm2::net diff --git a/src/net/AmbientState.h b/src/net/AmbientState.h new file mode 100644 index 0000000..932f7c8 --- /dev/null +++ b/src/net/AmbientState.h @@ -0,0 +1,174 @@ +#pragma once + +// The shared ambient traffic and police of a multiplayer cruise (OpenMM2 +// extra, see docs/multiplayer.md "Shared traffic"): the host simulates them +// and sends each client, about 20 times a second on the unreliable Ambient +// channel, the cars near that client's car. +// +// Every message is complete for its client: a car the client knows that is +// missing from a newer message has left (out of range, or back in the +// traffic's pool). Losing or reordering messages therefore never leaves a +// car behind or brings one back. A car's id is the host's slot (the traffic +// pool index, or a police car's place after the pool); its generation +// changes whenever the host reuses the slot for a new car, so the client +// never blends a recycled car from its old place to its new one. +// +// Positions travel relative to the message's origin (whole metres near the +// receiving car), which keeps them at 3 cm resolution in 44 bits. A car on +// its rail moves along its heading (ai::Traffic: velocity = -m2 x speed), +// so only its speed is sent; a car off its rail (knocked loose, a wreck) +// and every police car carry their full linear and angular velocity. + +#include "net/Protocol.h" + +#include +#include + +namespace mm2::net { + +inline constexpr std::uint32_t kMaxAmbientIds = 512; // entity ids 0..511 +inline constexpr std::uint32_t kMaxAmbientModels = 64; // catalog indices 0..63 +inline constexpr std::uint32_t kMaxAmbientPaint = 15; // paint job 0..15 +inline constexpr std::uint32_t kAmbientGenerations = 8; +inline constexpr std::size_t kMaxAmbientPerMessage = 96; +// Offsets from the origin: +-512 m across, +-256 m up and down. +inline constexpr float kAmbientOffsetRange = 512.0f; +inline constexpr float kAmbientHeightRange = 256.0f; +inline constexpr float kAmbientSpeedRange = 64.0f; // rail cars, m/s +inline constexpr float kAmbientVelocityRange = 96.0f; // off-rail cars and police, m/s +inline constexpr float kAmbientSpinRange = 32.0f; // rad/s +inline constexpr float kAmbientMaxRpm = 10000.0f; +// Police target: a player id, or none. +inline constexpr std::uint8_t kAmbientNoTarget = kInvalidPlayerId; + +enum class AmbientKind : std::uint8_t { Traffic, Police, Last = Police }; + +enum AmbientFlags : std::uint8_t { + kAmbientBrake = 1 << 0, // tail lights (decelerating or standing; a police car braking) + kAmbientHorn = 1 << 1, // honked since the last message + kAmbientSignalLeft = 1 << 2, // indicators: left, right, both = hazards + kAmbientSignalRight = 1 << 3, + kAmbientOffRail = 1 << 4, // traffic: knocked off its rail (physical, avoiding, regaining, wreck) + kAmbientWrecked = 1 << 5, // traffic: a wreck that never regains its rail; police: out of action + kAmbientSiren = 1 << 6, // police: siren on + kAmbientPursuit = 1 << 7, // police: in pursuit (aiPoliceOfficer::InPersuit) +}; + +struct AmbientEntity { + std::uint16_t id = 0; + std::uint8_t generation = 0; + AmbientKind kind = AmbientKind::Traffic; + std::uint8_t model = 0; // index into the session's model catalog + std::uint8_t paint = 0; // paint job + Vec3 position; // model origin, world space + Quat orientation; + float speed = 0.0f; // rail cars: along the car's forward axis (-m2) + Vec3 velocity; // off-rail cars and police + Vec3 angularVelocity; // off-rail cars and police + std::uint8_t flags = 0; // AmbientFlags + // Police only. + std::uint8_t target = kAmbientNoTarget; // the player it chases + float damage = 0.0f; // 0..1 + float rpm = 0.0f; // engine + float throttle = 0.0f; // 0..1 + std::int8_t gear = 0; // -1 reverse .. 8 + + // Whether the full velocities travel (police, or a car off its rail). + bool fullMotion() const { return kind == AmbientKind::Police || (flags & kAmbientOffRail) != 0; } +}; + +// Host -> one client. +struct AmbientStateMsg { + static constexpr MsgType kType = MsgType::AmbientState; + 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 + std::int16_t origin[3] = {0, 0, 0}; // metres; positions are relative to it + std::vector entities; + + Vec3 originVec() const { + return {static_cast(origin[0]), static_cast(origin[1]), static_cast(origin[2])}; + } + void setOrigin(const Vec3& p); +}; + +template +bool serializeAmbientEntity(S& s, AmbientEntity& e, const Vec3& origin) { + std::int32_t id = e.id, generation = e.generation, model = e.model, paint = e.paint; + s.ranged(id, 0, static_cast(kMaxAmbientIds) - 1); + s.ranged(generation, 0, static_cast(kAmbientGenerations) - 1); + s.enumeration(e.kind, AmbientKind::Last); + s.ranged(model, 0, static_cast(kMaxAmbientModels) - 1); + s.ranged(paint, 0, static_cast(kMaxAmbientPaint)); + e.id = static_cast(id); + e.generation = static_cast(generation); + e.model = static_cast(model); + e.paint = static_cast(paint); + Vec3 offset = e.position - origin; + s.quantized(offset.x, -kAmbientOffsetRange, kAmbientOffsetRange, 15); + s.quantized(offset.y, -kAmbientHeightRange, kAmbientHeightRange, 14); + s.quantized(offset.z, -kAmbientOffsetRange, kAmbientOffsetRange, 15); + if constexpr (S::kReading) + e.position = origin + offset; + s.quat(e.orientation); + s.u8(e.flags); + if (e.fullMotion()) { + s.vec3Quantized(e.velocity, kAmbientVelocityRange, 12); + s.vec3Quantized(e.angularVelocity, kAmbientSpinRange, 11); + if constexpr (S::kReading) + e.speed = -(e.orientation.toMatrix().m2.dot(e.velocity)); + } else { + s.quantized(e.speed, -kAmbientSpeedRange, kAmbientSpeedRange, 11); + if constexpr (S::kReading) { + // ai::Traffic's published velocity of a rail car. + e.velocity = -e.orientation.toMatrix().m2 * e.speed; + e.angularVelocity = {}; + } + } + if (e.kind == AmbientKind::Police) { + std::int32_t target = e.target == kAmbientNoTarget ? static_cast(kMaxPlayers) + : std::min(e.target, kMaxPlayers); + s.ranged(target, 0, static_cast(kMaxPlayers)); + e.target = target == static_cast(kMaxPlayers) ? kAmbientNoTarget + : static_cast(target); + s.quantized(e.damage, 0.0f, 1.0f, 6); + s.quantized(e.rpm, 0.0f, kAmbientMaxRpm, 8); + s.quantized(e.throttle, 0.0f, 1.0f, 4); + std::int32_t gear = e.gear; + s.ranged(gear, -1, 8); + e.gear = static_cast(gear); + } else if constexpr (S::kReading) { + e.target = kAmbientNoTarget; + e.damage = e.rpm = e.throttle = 0.0f; + e.gear = 0; + } + return s.ok(); +} + +template +bool serialize(S& s, AmbientStateMsg& m) { + s.u32(m.time); + s.u32(m.lightSteps); + s.u16(m.catalog); + for (auto& o : m.origin) { + auto v = static_cast(o); + s.u16(v); + o = static_cast(v); + } + auto count = static_cast(std::min(m.entities.size(), kMaxAmbientPerMessage)); + s.varU32(count); + if (count > kMaxAmbientPerMessage) + return s.fail(); + if constexpr (S::kReading) + m.entities.resize(count); + const Vec3 origin = m.originVec(); + for (std::uint32_t i = 0; i < count; ++i) + if (!serializeAmbientEntity(s, m.entities[i], origin)) + return s.fail(); + return s.ok(); +} + +// Bits one entity takes on the wire (for the host's packet budget). +std::size_t ambientEntityBits(const AmbientEntity& e); + +} // namespace mm2::net diff --git a/src/net/CMakeLists.txt b/src/net/CMakeLists.txt index e0c756c..001dd05 100644 --- a/src/net/CMakeLists.txt +++ b/src/net/CMakeLists.txt @@ -8,6 +8,7 @@ add_library(mm2_net STATIC Transport.cpp Discovery.cpp Session.cpp + AmbientState.cpp NatPmp.cpp NatPmpBackend.cpp UpnpBackend.cpp diff --git a/src/net/Protocol.h b/src/net/Protocol.h index 127497e..4bfdf40 100644 --- a/src/net/Protocol.h +++ b/src/net/Protocol.h @@ -23,7 +23,9 @@ namespace mm2::net { inline constexpr std::uint16_t kProtocolMagic = 0x4D32; // "M2" -inline constexpr std::uint16_t kProtocolVersion = 1; +// 2: the shared ambient traffic of multiplayer cruise (AmbientState, the +// Ambient channel, SessionSettings::sharedTraffic). +inline constexpr std::uint16_t kProtocolVersion = 2; inline constexpr std::uint32_t kConnectData = (std::uint32_t{kProtocolMagic} << 16) | kProtocolVersion; // ENet channels. @@ -31,8 +33,9 @@ enum class Channel : std::uint8_t { Control = 0, // reliable, ordered: handshake, lobby, chat, clock sync State = 1, // unreliable, sequenced: vehicle snapshots Events = 2, // reliable, ordered: in-game events + Ambient = 3, // unreliable, sequenced: the shared ambient traffic (host -> client) }; -inline constexpr std::size_t kChannelCount = 3; +inline constexpr std::size_t kChannelCount = 4; // Limits. The original game supported 8 players; the protocol allows more. inline constexpr std::size_t kMaxPlayers = 16; @@ -67,7 +70,8 @@ enum class MsgType : std::uint8_t { WorldState, GameEvent, PlayerPings, - Last = PlayerPings, + AmbientState, // net/AmbientState.h + Last = AmbientState, }; // Sent as ENet disconnect data and in Reject/PlayerLeft messages. @@ -108,6 +112,10 @@ struct SessionSettings { std::uint8_t maxPlayers = 8; bool hasPassword = false; // the password itself never leaves the host bool allowJoinInProgress = false; + // Multiplayer cruise: the host's ambient traffic and police are shared + // with every player (OpenMM2 extra); false: MM2's network cruise, with + // local pedestrians only. + bool sharedTraffic = true; // Game-specific options that don't warrant a protocol change. std::vector> extra; @@ -266,6 +274,7 @@ bool serialize(S& s, SessionSettings& m) { s.u8(m.maxPlayers); s.boolean(m.hasPassword); s.boolean(m.allowJoinInProgress); + s.boolean(m.sharedTraffic); auto count = static_cast(std::min(m.extra.size(), kMaxExtraSettings)); s.varU32(count); if (count > kMaxExtraSettings) diff --git a/src/net/Session.cpp b/src/net/Session.cpp index de9d394..916fd58 100644 --- a/src/net/Session.cpp +++ b/src/net/Session.cpp @@ -163,6 +163,7 @@ void Session::resetState() { m_request = {}; m_localState.reset(); m_lastSnapshotSent = 0; + m_ambientStates.clear(); } void Session::leave() { @@ -739,6 +740,7 @@ void Session::clientHandle(MsgType type, std::span data) { p.ready = false; m_request.ready = false; m_remoteStates.clear(); + m_ambientStates.clear(); emit(ev::ReturnedToLobby{}); return; } @@ -786,6 +788,15 @@ void Session::clientHandle(MsgType type, std::span data) { p->ping = ping; return; } + case MsgType::AmbientState: { + AmbientStateMsg m; + if (!decodeMessage(data, m)) + return; + if (m_ambientStates.size() >= kMaxQueuedAmbientStates) + m_ambientStates.erase(m_ambientStates.begin()); + m_ambientStates.push_back(std::move(m)); + return; + } default: return; } } @@ -928,6 +939,16 @@ void Session::sendGameEvent(std::uint16_t type, std::vector payload, sendTo(m_hostPeer, Channel::Events, std::move(msg)); } +std::size_t Session::sendAmbientState(std::uint8_t playerId, const AmbientStateMsg& msg) { + if (m_role != Role::Host || !m_transport || m_state != State::Active || playerId == m_localId) + return 0; + Remote* r = remoteForPlayer(playerId); + if (!r) + return 0; + const auto packet = encodeMessage(msg); + return m_transport->send(r->peer, Channel::Ambient, packet) ? packet.size() : 0; +} + PeerStats Session::peerStats(std::uint8_t playerId) const { if (!m_transport) return {}; diff --git a/src/net/Session.h b/src/net/Session.h index a496596..295885e 100644 --- a/src/net/Session.h +++ b/src/net/Session.h @@ -14,6 +14,7 @@ // chat, a countdown, vehicle snapshots and opaque game events. The host may // install an event filter to validate or rewrite events before relaying. +#include "net/AmbientState.h" #include "net/ClockSync.h" #include "net/Discovery.h" #include "net/Protocol.h" @@ -179,6 +180,15 @@ class Session { // Connection quality to the host (clients) or to a player (host). PeerStats peerStats(std::uint8_t playerId) const; + // --- Shared ambient traffic (multiplayer cruise) --- + // Host: sends one player its cars on the unreliable Ambient channel. + // Returns the encoded size in bytes (0 when nothing was sent). + std::size_t sendAmbientState(std::uint8_t playerId, const AmbientStateMsg& msg); + // Client: the messages received since the last call, oldest first (at + // most kMaxQueuedAmbientStates; older ones are dropped). + std::vector takeAmbientStates() { return std::exchange(m_ambientStates, {}); } + static constexpr std::size_t kMaxQueuedAmbientStates = 16; + private: struct Remote { PeerId peer = kInvalidPeer; @@ -245,6 +255,7 @@ class Session { std::map m_remoteStates; std::optional m_localState; std::uint64_t m_lastSnapshotSent = 0; + std::vector m_ambientStates; // client: received, not yet taken }; // Proof sent in Hello: SHA-256(nonce || password); all zeros when empty. diff --git a/src/net/Transport.cpp b/src/net/Transport.cpp index 1ba29f6..7a4a405 100644 --- a/src/net/Transport.cpp +++ b/src/net/Transport.cpp @@ -27,7 +27,8 @@ std::uint32_t packetFlags(Channel channel) { switch (channel) { case Channel::Control: case Channel::Events: return ENET_PACKET_FLAG_RELIABLE; - case Channel::State: return 0; // unreliable, sequenced + case Channel::State: + case Channel::Ambient: return 0; // unreliable, sequenced } return ENET_PACKET_FLAG_RELIABLE; } diff --git a/tests/game/test_traffic_sync.cpp b/tests/game/test_traffic_sync.cpp new file mode 100644 index 0000000..4baf1cf --- /dev/null +++ b/tests/game/test_traffic_sync.cpp @@ -0,0 +1,291 @@ +// The shared ambient traffic of multiplayer cruise (game/net/TrafficSync): +// which cars the host sends each client, and how the client follows them +// through spawns, despawns, loss and reordering. +#include "game/net/NetGame.h" +#include "game/net/TrafficSync.h" + +#include + +#include +#include +#include + +using namespace mm2; +using game::SharedCar; +using game::TrafficClient; +using game::TrafficHost; + +namespace { + +SharedCar railCar(int id, Vec3 pos, float speed = 10.0f, int model = 1) { + SharedCar c; + c.id = id; + c.model = model; + c.paint = 2; + c.transform = Mat34::rotationY(0.0f); + c.transform.m3 = pos; + c.speed = speed; + c.velocity = -c.transform.m2 * speed; // along -Z + return c; +} + +SharedCar cop(int id, Vec3 pos, std::uint8_t target) { + SharedCar c = railCar(id, pos, 30.0f, 3); + c.kind = net::AmbientKind::Police; + c.target = target; + c.flags = net::kAmbientSiren | net::kAmbientPursuit; + c.angularVelocity = {0, 0.5f, 0}; + return c; +} + +// Host -> wire -> client, as the race does. +net::AmbientStateMsg wire(const net::AmbientStateMsg& m) { + net::AmbientStateMsg out; + EXPECT_TRUE(net::decodeMessage(net::encodeMessage(m), out)); + return out; +} + +net::AmbientStateMsg message(std::uint32_t time, std::initializer_list cars, + std::uint32_t steps = 0) { + TrafficHost host; + std::vector list(cars); + return wire(host.build({1, {0, 0, 0}}, list, time, steps, 0x1234)); +} + +const TrafficClient::Car* find(const TrafficClient& c, int id) { + for (const auto& car : c.cars()) + if (car.id == id) + return &car; + return nullptr; +} + +} // namespace + +TEST(TrafficCatalog, IndicesAndChecksum) { + game::TrafficCatalog a, b; + for (auto* m : {"vacompact_l", "VAPICKUP_S", "vpcop"}) + a.add(m); + a.add("vacompact_l"); // duplicate + for (auto* m : {"vacompact_l", "vapickup_s", "vpcop"}) + b.add(m); + EXPECT_EQ(a.size(), 3u); + EXPECT_EQ(a.find("vapickup_s"), 1); + EXPECT_EQ(a.find("nope"), -1); + ASSERT_NE(a.name(2), nullptr); + EXPECT_EQ(*a.name(2), "vpcop"); + EXPECT_EQ(a.name(3), nullptr); + EXPECT_EQ(a.name(-1), nullptr); + EXPECT_EQ(a.checksum(), b.checksum()); + game::TrafficCatalog c; + for (auto* m : {"vapickup_s", "vacompact_l", "vpcop"}) + c.add(m); + EXPECT_NE(a.checksum(), c.checksum()); // the order matters +} + +TEST(TrafficHost, SendsTheCarsNearEachClientWithHysteresis) { + TrafficHost host; + std::vector cars = {railCar(0, {10, 0, 0}), railCar(1, {150, 0, 0}), + railCar(2, {215, 0, 0}), railCar(3, {400, 0, 0}), + railCar(4, {-50, 0, 5}), railCar(600, {1, 0, 1}), + railCar(5, {1, 0, 2}, 1.0f, 99)}; + auto ids = [](const net::AmbientStateMsg& m) { + std::vector v; + for (const auto& e : m.entities) + v.push_back(e.id); + return v; + }; + const game::TrafficViewer viewer{1, {0, 0, 0}}; + auto m = host.build(viewer, cars, 100, 7, 0xAAAA); + EXPECT_EQ(m.time, 100u); + EXPECT_EQ(m.lightSteps, 7u); + EXPECT_EQ(m.catalog, 0xAAAA); + // Nearest first; beyond 200 m out; ids and models out of range skipped. + EXPECT_EQ(ids(m), (std::vector{0, 4, 1})); + // Car 1 drifts to 220 m: still sent (it is in the set until 230 m). + cars[1].transform.m3.x = 220.0f; + // Car 2 comes to 205 m: not in the set, so not yet. + cars[2].transform.m3.x = 205.0f; + m = host.build(viewer, cars, 150, 8, 0xAAAA); + EXPECT_EQ(ids(m), (std::vector{0, 4, 1})); + cars[1].transform.m3.x = 240.0f; + cars[2].transform.m3.x = 190.0f; + m = host.build(viewer, cars, 200, 9, 0xAAAA); + EXPECT_EQ(ids(m), (std::vector{0, 4, 2})); + // Every client has its own set. + m = host.build({2, {400, 0, 0}}, cars, 200, 9, 0xAAAA); + EXPECT_EQ(ids(m), (std::vector{3, 1})); // car 2 is 210 m from it +} + +TEST(TrafficHost, ChasingPoliceComeFirstAndTheBudgetHolds) { + TrafficHost host; + std::vector cars; + for (int i = 0; i < 200; ++i) + cars.push_back(railCar(i, {static_cast(i % 20) * 3.0f, 0, static_cast(i / 20) * 3.0f})); + cars.push_back(cop(300, {450, 0, 0}, 1)); // far, chasing this client + cars.push_back(cop(301, {450, 0, 10}, 2)); // far, chasing another one + const auto m = host.build({1, {0, 0, 0}}, cars, 1, 0, 0); + ASSERT_FALSE(m.entities.empty()); + EXPECT_EQ(m.entities[0].id, 300); + EXPECT_TRUE( + std::none_of(m.entities.begin(), m.entities.end(), [](const auto& e) { return e.id == 301; })); + EXPECT_LE(net::encodeMessage(m).size(), host.options().maxBytes); + EXPECT_GT(m.entities.size(), 50u); +} + +TEST(TrafficClient, FollowsSpawnUpdateAndDespawn) { + TrafficClient client(8, 0x1234); + client.receive(message(1000, {railCar(5, {0, 0, 0})}, 30)); + client.update(950.0); // before the car's first state: hidden + EXPECT_EQ(client.cars().size(), 0u); + client.receive(message(1050, {railCar(5, {0, 0, -0.5f}), railCar(6, {20, 0, 0})}, 31)); + client.update(1025.0); + const auto* car = find(client, 5); + ASSERT_NE(car, nullptr); + EXPECT_NEAR(car->transform.m3.z, -0.25f, 0.05f); + EXPECT_FALSE(car->extrapolated); + EXPECT_TRUE(car->fresh); + EXPECT_NEAR(car->speed, 10.0f, 0.1f); + EXPECT_EQ(find(client, 6), nullptr); // not until 1050 + client.update(1050.0); + ASSERT_NE(find(client, 6), nullptr); + EXPECT_FALSE(find(client, 5)->fresh); + // Car 5 left: gone once the render time reaches that message. + client.receive(message(1100, {railCar(6, {20, 0, -0.5f})}, 32)); + client.update(1075.0); + EXPECT_NE(find(client, 5), nullptr); + client.update(1100.0); + EXPECT_EQ(find(client, 5), nullptr); + EXPECT_NE(find(client, 6), nullptr); + EXPECT_EQ(client.known(), 1u); + // The light steps run on from the newest message in 1/30 s steps. + EXPECT_EQ(client.lightSteps(1100.0), 32u); + EXPECT_EQ(client.lightSteps(1200.0), 35u); +} + +TEST(TrafficClient, LossIsBridgedAndReorderingNeverResurrects) { + TrafficClient client(8, 0x1234); + client.receive(message(0, {railCar(1, {0, 0, 0}), railCar(2, {5, 0, 0})})); + // 50 and 100 lost: car 1 extrapolates along its velocity. + client.update(120.0); + const auto* c1 = find(client, 1); + ASSERT_NE(c1, nullptr); + EXPECT_TRUE(c1->extrapolated); + EXPECT_NEAR(c1->transform.m3.z, -1.2f, 0.05f); + // Then 150 arrives, without car 2; later the lost 100 shows up late. + client.receive(message(150, {railCar(1, {0, 0, -1.5f})})); + client.receive( + message(100, {railCar(1, {0, 0, -1.0f}), railCar(2, {5, 0, -1.0f}), railCar(3, {9, 0, 0})})); + EXPECT_EQ(client.stats().outdated, 1u); + // Its state of car 1 is used ... + client.update(125.0); + EXPECT_NEAR(find(client, 1)->transform.m3.z, -1.25f, 0.05f); + EXPECT_FALSE(find(client, 1)->extrapolated); + EXPECT_NE(find(client, 2), nullptr); // (still there until 150) + EXPECT_EQ(find(client, 3), nullptr); // but it adds no car the newer message lacks + client.update(150.0); + EXPECT_EQ(find(client, 2), nullptr); // and does not bring one back + // Nothing for a long while: the car is dropped. + client.update(150.0 + client.options().staleMs + 1.0); + EXPECT_EQ(client.cars().size(), 0u); + EXPECT_EQ(client.known(), 0u); +} + +TEST(TrafficClient, ARecycledSlotIsANewCar) { + TrafficClient client(8, 0x1234); + SharedCar a = railCar(4, {0, 0, 0}); + client.receive(message(0, {a})); + a.transform.m3.z = -0.5f; + client.receive(message(50, {a})); + client.update(50.0); + // The pool slot is reused far away: same id, next generation. + SharedCar b = railCar(4, {150, 0, 100}, 0.0f, 2); + b.generation = 1; + client.receive(message(100, {b})); + client.update(75.0); // between the two: never blended across the city + EXPECT_EQ(find(client, 4), nullptr); + client.update(100.0); + const auto* car = find(client, 4); + ASSERT_NE(car, nullptr); + EXPECT_NEAR(car->transform.m3.x, 150.0f, 0.05f); + EXPECT_EQ(car->model, 2); + EXPECT_TRUE(car->fresh); + // Moved further than its speed explains without a new generation (a + // police car back at its post): shown there at once. + SharedCar c = b; + c.transform.m3 = {-150, 0, 0}; + client.receive(message(150, {c})); + client.update(125.0); + EXPECT_EQ(find(client, 4), nullptr); + client.update(150.0); + EXPECT_NEAR(find(client, 4)->transform.m3.x, -150.0f, 0.05f); + EXPECT_EQ(client.stats().teleports, 1u); +} + +TEST(TrafficClient, BadEntriesAreRefused) { + TrafficClient client(3, 0x1234); + net::AmbientStateMsg m; + m.time = 10; + m.catalog = 0x9999; // another catalog + net::AmbientEntity good; + good.id = 1; + good.model = 2; + good.position = {1, 2, 3}; + net::AmbientEntity badModel = good; + badModel.id = 2; + badModel.model = 3; // beyond the client's catalog + net::AmbientEntity duplicate = good; + duplicate.position = {100, 0, 0}; + net::AmbientEntity nan = good; + nan.id = 3; + nan.position.x = std::numeric_limits::quiet_NaN(); + net::AmbientEntity badId = good; + badId.id = 900; + m.entities = {good, badModel, duplicate, nan, badId}; + client.receive(m); + client.update(10.0); + EXPECT_TRUE(client.catalogMismatch()); + ASSERT_EQ(client.cars().size(), 1u); + EXPECT_NEAR(client.cars()[0].transform.m3.x, 1.0f, 1e-4f); + EXPECT_EQ(client.stats().refused, 4u); +} + +TEST(TrafficClient, PoliceKeepTheirChaseAndTheHornIsAnEdge) { + TrafficClient client(8, 0x1234); + SharedCar p = cop(300, {0, 0, 0}, 3); + p.rpm = 4000.0f; + p.gear = 2; + SharedCar t = railCar(9, {5, 0, 0}); + t.flags = net::kAmbientHorn; + client.receive(message(0, {p, t})); + p.transform.m3.z = -1.5f; + t.flags = 0; + client.receive(message(50, {p, t})); + client.update(10.0); + const auto* c = find(client, 300); + ASSERT_NE(c, nullptr); + EXPECT_EQ(c->kind, net::AmbientKind::Police); + EXPECT_EQ(c->target, 3); + EXPECT_NEAR(c->rpm, 4000.0f, 40.0f); + EXPECT_EQ(c->gear, 2); + EXPECT_TRUE(c->flags & net::kAmbientSiren); + EXPECT_TRUE(find(client, 9)->hornStarted); + client.update(15.0); + EXPECT_FALSE(find(client, 9)->hornStarted); // once +} + +TEST(TrafficSettings, TheLobbyOptionCrossesTheWire) { + game::RaceConfig on; + on.mode = game::GameMode::Cruise; + on.trafficDensity = 0.75f; + on.copDensity = 0.25f; + EXPECT_TRUE(on.netTraffic); // on by default + const auto s = game::toSessionSettings(on, "Cruise", 8); + EXPECT_TRUE(s.sharedTraffic); + const auto back = game::fromSessionSettings(s); + EXPECT_TRUE(back.netTraffic); + EXPECT_NEAR(back.trafficDensity, 0.75f, 0.01f); + EXPECT_NEAR(back.copDensity, 0.25f, 0.01f); + game::RaceConfig off = on; + off.netTraffic = false; + EXPECT_FALSE(game::fromSessionSettings(game::toSessionSettings(off, "Cruise", 8)).netTraffic); +} diff --git a/tests/net/test_ambient_state.cpp b/tests/net/test_ambient_state.cpp new file mode 100644 index 0000000..7a8b23e --- /dev/null +++ b/tests/net/test_ambient_state.cpp @@ -0,0 +1,312 @@ +// The shared ambient traffic message (net/AmbientState.h) and its delivery +// over a loopback session. +#include "net/AmbientState.h" +#include "net/Session.h" + +#include + +#include +#include +#include +#include +#include + +using namespace mm2; +using namespace mm2::net; + +namespace { + +AmbientEntity railCar(std::uint16_t id, const Vec3& pos, float heading, float speed) { + AmbientEntity e; + e.id = id; + e.generation = 5; + e.kind = AmbientKind::Traffic; + e.model = 12; + e.paint = 3; + e.position = pos; + e.orientation = Quat::fromAxisAngle({0, 1, 0}, heading); + e.speed = speed; + e.flags = kAmbientBrake | kAmbientSignalLeft; + return e; +} + +AmbientEntity copCar(std::uint16_t id, const Vec3& pos) { + AmbientEntity e; + e.id = id; + e.generation = 1; + e.kind = AmbientKind::Police; + e.model = 40; + e.paint = 1; + e.position = pos; + e.orientation = Quat::fromAxisAngle(Vec3{0.2f, 1, 0}.normalized(), -2.0f); + e.velocity = {20.0f, -1.5f, -31.25f}; + e.angularVelocity = {0.5f, -1.25f, 0.0f}; + e.flags = kAmbientSiren | kAmbientPursuit; + e.target = 3; + e.damage = 0.4f; + e.rpm = 5200.0f; + e.throttle = 1.0f; + e.gear = 3; + return e; +} + +AmbientStateMsg sampleMessage() { + AmbientStateMsg m; + m.time = 98765; + m.lightSteps = 4321; + m.catalog = 0xBEEF; + m.setOrigin({-1501.4f, 34.6f, 558.2f}); + m.entities.push_back(railCar(7, {-1480.31f, 33.02f, 620.77f}, 0.7f, 12.5f)); + m.entities.push_back(copCar(301, {-1600.0f, 40.0f, 500.0f})); + AmbientEntity knocked = railCar(299, {-1450.0f, 35.0f, 540.0f}, -1.0f, 0.0f); + knocked.flags = kAmbientOffRail | kAmbientWrecked; + knocked.velocity = {3.0f, 4.0f, -5.0f}; + knocked.angularVelocity = {1.0f, 2.0f, 3.0f}; + m.entities.push_back(knocked); + return m; +} + +} // namespace + +TEST(AmbientState, RoundTripKeepsEveryField) { + const AmbientStateMsg in = sampleMessage(); + const auto packet = encodeMessage(in); + EXPECT_EQ(peekMessageType(packet), MsgType::AmbientState); + AmbientStateMsg out; + ASSERT_TRUE(decodeMessage(packet, out)); + EXPECT_EQ(out.time, 98765u); + EXPECT_EQ(out.lightSteps, 4321u); + EXPECT_EQ(out.catalog, 0xBEEF); + EXPECT_EQ(out.origin[0], -1501); + EXPECT_EQ(out.origin[1], 35); + EXPECT_EQ(out.origin[2], 558); + ASSERT_EQ(out.entities.size(), 3u); + + const AmbientEntity& rail = out.entities[0]; + EXPECT_EQ(rail.id, 7); + EXPECT_EQ(rail.generation, 5); + EXPECT_EQ(rail.kind, AmbientKind::Traffic); + EXPECT_EQ(rail.model, 12); + EXPECT_EQ(rail.paint, 3); + EXPECT_LT(rail.position.dist(in.entities[0].position), 0.04f); + EXPECT_NEAR(rail.speed, 12.5f, 0.07f); + // A rail car's velocity is rebuilt along its heading (ai::Traffic's). + const Vec3 expected = -in.entities[0].orientation.toMatrix().m2 * 12.5f; + EXPECT_LT(rail.velocity.dist(expected), 0.1f); + EXPECT_EQ(rail.flags, kAmbientBrake | kAmbientSignalLeft); + EXPECT_EQ(rail.target, kAmbientNoTarget); + + const AmbientEntity& cop = out.entities[1]; + EXPECT_EQ(cop.id, 301); + EXPECT_EQ(cop.kind, AmbientKind::Police); + EXPECT_LT(cop.position.dist(in.entities[1].position), 0.04f); + EXPECT_LT(cop.velocity.dist(in.entities[1].velocity), 0.05f); + EXPECT_LT(cop.angularVelocity.dist(in.entities[1].angularVelocity), 0.05f); + const Mat34 a = cop.orientation.toMatrix(), b = in.entities[1].orientation.toMatrix(); + EXPECT_GT(a.m2.dot(b.m2), 0.9999f); + EXPECT_EQ(cop.target, 3); + EXPECT_NEAR(cop.damage, 0.4f, 0.02f); + EXPECT_NEAR(cop.rpm, 5200.0f, 25.0f); + EXPECT_NEAR(cop.throttle, 1.0f, 0.001f); + EXPECT_EQ(cop.gear, 3); + + const AmbientEntity& knocked = out.entities[2]; + EXPECT_TRUE(knocked.fullMotion()); + EXPECT_LT(knocked.velocity.dist({3.0f, 4.0f, -5.0f}), 0.05f); + EXPECT_LT(knocked.angularVelocity.dist({1.0f, 2.0f, 3.0f}), 0.05f); +} + +TEST(AmbientState, OutOfRangeValuesAreClampedNotTrusted) { + AmbientStateMsg in; + in.setOrigin({std::numeric_limits::quiet_NaN(), 1e9f, -1e9f}); + EXPECT_EQ(in.origin[0], 0); + EXPECT_EQ(in.origin[1], 32767); + EXPECT_EQ(in.origin[2], -32768); + AmbientEntity e = railCar(1, {}, 0.0f, std::numeric_limits::infinity()); + e.position = {std::numeric_limits::quiet_NaN(), 5000.0f, -5000.0f}; + in.entities.push_back(e); + AmbientEntity cop = copCar(2, {}); + cop.target = 200; // not a player + cop.velocity = {std::numeric_limits::quiet_NaN(), 1e6f, -1e6f}; + in.entities.push_back(cop); + AmbientStateMsg out; + ASSERT_TRUE(decodeMessage(encodeMessage(in), out)); + for (const auto& o : out.entities) { + EXPECT_TRUE(std::isfinite(o.position.x) && std::isfinite(o.position.y) && + std::isfinite(o.position.z)); + EXPECT_TRUE(std::isfinite(o.speed)); + EXPECT_LE(std::abs(o.position.x - static_cast(in.origin[0])), kAmbientOffsetRange); + EXPECT_TRUE(std::isfinite(o.velocity.x) && std::isfinite(o.velocity.y) && + std::isfinite(o.velocity.z)); + } + EXPECT_NEAR(out.entities[0].speed, kAmbientSpeedRange, 0.001f); + EXPECT_EQ(out.entities[1].target, kAmbientNoTarget); // no such player: chasing nobody +} + +TEST(AmbientState, MalformedMessagesAreRejected) { + const auto packet = encodeMessage(sampleMessage()); + AmbientStateMsg out; + // Truncated at every length. + for (std::size_t n = 0; n < packet.size(); ++n) + EXPECT_FALSE(decodeMessage(std::span(packet).first(n), out)) << n; + // Trailing bytes. + auto padded = packet; + padded.push_back(std::byte{0xFF}); + EXPECT_FALSE(decodeMessage(padded, out)); + // A count beyond the limit. + BitWriter w; + w.writeU8(static_cast(MsgType::AmbientState)); + w.writeU32(1); + w.writeU32(2); + w.writeU16(3); + for (int i = 0; i < 3; ++i) + w.writeU16(0); + w.writeVarU32(static_cast(kMaxAmbientPerMessage + 1)); + EXPECT_FALSE(decodeMessage(w.take(), out)); + // A police target that is no player (the 5-bit field's spare values). + auto copWithTarget = [](std::uint32_t target) { + BitWriter p; + p.writeU8(static_cast(MsgType::AmbientState)); + p.writeU32(1); + p.writeU32(2); + p.writeU16(3); + for (int i = 0; i < 3; ++i) + p.writeU16(0); + p.writeVarU32(1); + p.writeBits(4, 9); // id + p.writeBits(0, 3); // generation + p.writeBits(1, 1); // kind: police + p.writeBits(0, 6); // model + p.writeBits(0, 4); // paint + p.writeBits(0, 15 + 14 + 15); // position + p.writeBits(3, 2); // quaternion: w largest + p.writeBits(0, 30); + p.writeU8(0); // flags + p.writeBits(0, 3 * 12); // velocity + p.writeBits(0, 3 * 11); // spin + p.writeBits(target, 5); + p.writeBits(0, 6 + 8 + 4 + 4); // damage, rpm, throttle, gear + return p.take(); + }; + AmbientStateMsg cop; + ASSERT_TRUE(decodeMessage(copWithTarget(2), cop)); + EXPECT_EQ(cop.entities[0].target, 2); + ASSERT_TRUE(decodeMessage(copWithTarget(static_cast(kMaxPlayers)), cop)); + EXPECT_EQ(cop.entities[0].target, kAmbientNoTarget); + EXPECT_FALSE(decodeMessage(copWithTarget(20), cop)); + // Random bytes never crash the decoder and never yield an id or model + // out of range. + std::uint32_t seed = 12345; + for (int round = 0; round < 2000; ++round) { + std::vector junk(1 + (round % 300)); + junk[0] = static_cast(MsgType::AmbientState); + for (std::size_t i = 1; i < junk.size(); ++i) { + seed = seed * 1664525u + 1013904223u; + junk[i] = static_cast(seed >> 24); + } + AmbientStateMsg m; + if (decodeMessage(junk, m)) { + EXPECT_LE(m.entities.size(), kMaxAmbientPerMessage); + for (const auto& e : m.entities) { + EXPECT_LT(e.id, kMaxAmbientIds); + EXPECT_LT(e.model, kMaxAmbientModels); + EXPECT_LE(e.paint, kMaxAmbientPaint); + EXPECT_TRUE(std::isfinite(e.position.x)); + } + } + } +} + +TEST(AmbientState, SizesFitTheBudget) { + const AmbientEntity rail = railCar(1, {10, 0, 10}, 0.0f, 10.0f); + AmbientEntity offRail = rail; + offRail.flags |= kAmbientOffRail; + const AmbientEntity cop = copCar(2, {}); + EXPECT_EQ(ambientEntityBits(rail), 118u); + EXPECT_EQ(ambientEntityBits(offRail), 176u); + EXPECT_EQ(ambientEntityBits(cop), 203u); + // 64 rail cars stay under one MTU. + AmbientStateMsg m; + for (std::uint16_t i = 0; i < 64; ++i) + m.entities.push_back(railCar(i, {static_cast(i), 0, 0}, 0.0f, 10.0f)); + EXPECT_LT(encodeMessage(m).size(), 1000u); +} + +namespace { + +SessionConfig fastConfig() { + SessionConfig c; + c.connectTimeoutMs = 3000; + c.joinTimeoutMs = 3000; + return c; +} + +bool pumpUntil(std::initializer_list sessions, const std::function& done, + int timeoutMs = 3000) { + const auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(timeoutMs); + while (std::chrono::steady_clock::now() < deadline) { + for (Session* s : sessions) { + s->update(); + s->takeEvents(); + } + if (done()) + return true; + std::this_thread::sleep_for(std::chrono::milliseconds(2)); + } + return false; +} + +} // namespace + +TEST(AmbientState, HostSendsEachClientItsOwnState) { + Session host(fastConfig()), a(fastConfig()), b(fastConfig()); + HostParams hp; + hp.bind = Address::loopback(0); + hp.advertiseOnLan = false; + ASSERT_TRUE(host.host(hp)); + JoinParams jp; + jp.host = Address::loopback(host.port()); + jp.player.name = "A"; + ASSERT_TRUE(a.join(jp)); + jp.player.name = "B"; + ASSERT_TRUE(b.join(jp)); + ASSERT_TRUE(pumpUntil({&host, &a, &b}, [&] { + return a.state() == Session::State::Active && b.state() == Session::State::Active && + host.players().size() == 3; + })); + EXPECT_TRUE(host.settings().sharedTraffic); // on by default + EXPECT_TRUE(a.settings().sharedTraffic); + + AmbientStateMsg forA = sampleMessage(); + forA.time = 1; + AmbientStateMsg forB = sampleMessage(); + forB.time = 2; + forB.entities.resize(1); + EXPECT_GT(host.sendAmbientState(a.localId(), forA), 0u); + EXPECT_GT(host.sendAmbientState(b.localId(), forB), 0u); + EXPECT_EQ(host.sendAmbientState(host.localId(), forA), 0u); // never to itself + EXPECT_EQ(host.sendAmbientState(99, forA), 0u); // no such player + EXPECT_EQ(a.sendAmbientState(host.localId(), forA), 0u); // clients never send it + + std::vector gotA, gotB; + ASSERT_TRUE(pumpUntil({&host, &a, &b}, [&] { + for (auto& m : a.takeAmbientStates()) + gotA.push_back(std::move(m)); + for (auto& m : b.takeAmbientStates()) + gotB.push_back(std::move(m)); + return !gotA.empty() && !gotB.empty(); + })); + ASSERT_EQ(gotA.size(), 1u); + ASSERT_EQ(gotB.size(), 1u); + EXPECT_EQ(gotA[0].time, 1u); + EXPECT_EQ(gotA[0].entities.size(), 3u); + EXPECT_EQ(gotB[0].time, 2u); + EXPECT_EQ(gotB[0].entities.size(), 1u); + + // The host's setting reaches the clients. + SessionSettings s = host.settings(); + s.sharedTraffic = false; + host.updateSettings(s); + ASSERT_TRUE(pumpUntil({&host, &a, &b}, + [&] { return !a.settings().sharedTraffic && !b.settings().sharedTraffic; })); +} From ed3d80e621df6ac5e33335a5a0d257fa6d1bd6ff Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Thu, 8 Oct 2026 22:16:15 -0700 Subject: [PATCH 06/54] Keep a return to the lobby from ending the next network race The second race of a session bounced everyone back to the lobby as soon as it had loaded. When the host's race ended (a finish, the results, or Quit to Lobby), RaceScreen::leaveRace called NetGame::returnToLobby and switched to the menus; the session's ReturnedToLobby event reached NetGame only on the menus' next update and set a "return pending" flag that nothing in the menus took. The next race's first frame took it as its own, left the race, and the host, still in that race's countdown, sent everyone back again (which set the flag once more, so every later race bounced too). A joiner who had quit a race early met the same stale flag alone. NetGame now counts the races it sees start (raceNumber) and remembers which race the last return to the lobby ended (backToLobby); the race screen keeps the number of its race. A return and a new countdown arriving in one update still end the old race and start the new one. Game events still queued when a countdown starts are from an earlier race and are dropped. The frontend script now survives a race (wait:race, and the commands after the one that started it run when the menus are back), and mp:car changes the lobby car as the garage does, which is how the bounce was reproduced. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/frontend.md | 7 +- docs/multiplayer.md | 23 +++- docs/parity/frontend-ui.md | 2 +- src/app/RaceScreen.cpp | 13 +- src/app/frontend/FrontendScreen.cpp | 55 ++++++-- src/game/net/NetGame.cpp | 13 +- src/game/net/NetGame.h | 13 +- tests/game/test_netgame.cpp | 5 +- tests/game/test_netgame_lobby.cpp | 203 ++++++++++++++++++++++++++++ 9 files changed, 302 insertions(+), 32 deletions(-) create mode 100644 tests/game/test_netgame_lobby.cpp diff --git a/docs/frontend.md b/docs/frontend.md index aef2a8f..de9b369 100644 --- a/docs/frontend.md +++ b/docs/frontend.md @@ -580,8 +580,11 @@ vehicle|showcase|options|graphics|audio|control|customize|about|crashintro| crashlondon|crashsf|sessions|quit|message|delete>`, `mode:` and `city:` (both apply the race defaults), `vehicle:`, `go` (start the race), `result:`, -`nav:`, `wait:`. Consecutive -`nav:` commands need a `wait:1` between them. +`nav:`, `wait:`, `wait:race` +(until a race has been driven). Consecutive `nav:` commands need a `wait:1` +between them. The script runs once per process: after a race the menus carry +on with the commands that followed the one that started it. The multiplayer +commands are in docs/multiplayer.md. ## Not yet done diff --git a/docs/multiplayer.md b/docs/multiplayer.md index 4ae5642..648afa0 100644 --- a/docs/multiplayer.md +++ b/docs/multiplayer.md @@ -355,6 +355,13 @@ every machine and switches to `makeRaceScreen(ctx, netGame->raceConfig())` `raceStartTime()`. A `RaceResult` with `config.multiplayer` brings the player back to the lobby instead of the results screen. +Every countdown counts a race (`NetGame::raceNumber()`, from 1); the race +screen keeps the number of its race. A return to the lobby ends the race it +arrives in (`backToLobby(number)`), even when the player is in the menus by +then (the host's own return, which reaches the menus as an event after the +race screen has gone, or a joiner who quit the race early). Game events still +queued when a countdown starts are from an earlier race and are dropped. + ### Per-frame contract for RaceScreen When `config.multiplayer && ctx.netGame`: @@ -384,7 +391,7 @@ When `config.multiplayer && ctx.netGame`: (`event.as()` etc.) to rank players and show messages. 6. **End:** the host calls `ctx.netGame->returnToLobby()` when the race is over (everyone finished, or the time/point limit); every machine sees - `takeReturnToLobby()` and returns with `makeFrontendScreen(ctx, result)`. + `backToLobby()` and returns with `makeFrontendScreen(ctx, result)`. A player who quits early just returns to the frontend (it shows the lobby; the others keep racing). If `ctx.netGame->inSession()` turns false (host quit), return to the frontend; `takeNotice()` has the message. @@ -403,10 +410,18 @@ off the lobby says which UDP port must be opened by hand. `OPENMM2_FRONTEND_SCRIPT` has multiplayer commands: `mp:host[:]`, `mp:join:
[|]`, `mp:chat:`, `mp:ready`, `mp:start`, `mp:team:<0|1>`, -`mp:mode:`, and the pages -`hostoptions`, `address`, `hostsettings`, `eject`. Example: one process hosts +`mp:mode:`, +`mp:car:[:]` (what SELECT VEHICLE, a pick in the garage and +its PREV do), and the pages `hostoptions`, `address`, `hostsettings`, +`eject`. The script survives a race: after it the menus carry on with the +commands after the one that started it, and `wait:race` waits until a race +has been driven. Example: one process hosts (`profile:A;page:sessions;mp:host;wait:900`), another joins -(`profile:B;page:sessions;mp:join:127.0.0.1;wait:100;mp:ready`). +(`profile:B;page:sessions;mp:join:127.0.0.1;wait:100;mp:ready`); a second +race after changing cars in the lobby: +`profile:A;mp:host;wait:1500;mp:start;wait:race;wait:300;mp:car:vpcop;wait:300;mp:start` +with `OPENMM2_POPUP_SCRIPT="wait:500;open:quit;wait:5;nav:accept"` (Quit to +Lobby) and `profile:B;mp:join:127.0.0.1;wait:100;mp:ready;wait:race;wait:100;mp:car:vpmustang99;wait:50;mp:ready`. `test_game` (`tests/game/test_netgame.cpp`) runs a host and a client `NetGame` in one process: settings round trip, join by address, LAN diff --git a/docs/parity/frontend-ui.md b/docs/parity/frontend-ui.md index fa408f2..c14f379 100644 --- a/docs/parity/frontend-ui.md +++ b/docs/parity/frontend-ui.md @@ -95,7 +95,7 @@ the race loop to session (see "For other areas" at the end). | `timeOfDayName`, `weatherName` | — | openmm2 | English names for the lobby panel. | | `modeDisplayName` | strings 585-589, 78 | verified | | | `formatTime` | `GetLocTime` | verified | "%d:%02d:%02d" of t + 0.005 (double), " --- " for no time. | -| `Script::*`, `scriptOnce`, `injectKey` | — | openmm2 | `OPENMM2_FRONTEND_SCRIPT` automation; still works (checked with the race menu). | +| `Script::*`, `processScript`, `injectKey` | — | openmm2 | `OPENMM2_FRONTEND_SCRIPT` automation; still works (checked with the race menu). | | `FrontendScreen::FrontendScreen` (first start) | `mmInterface::InitPlayerInfo` | fixed | DriverX got the first car of the list; MM2's driver without a last car gets cruise in vpbug (`PlayerSetState`), the profile's default. | | `FrontendScreen::FrontendScreen` (after a race) | `mmInterface::ShowMain` | fixed | returns to the Crash Course page for every race started there, showing the event just driven; else the race menu. | | Results as a frontend page | `PUResults` popup over the paused race | deviation | shown after the race instead of in it (documented). | diff --git a/src/app/RaceScreen.cpp b/src/app/RaceScreen.cpp index 5c40cf3..d79bc0d 100644 --- a/src/app/RaceScreen.cpp +++ b/src/app/RaceScreen.cpp @@ -102,6 +102,10 @@ class RaceScreen final : public Screen { RaceScreen(Context& ctx, const game::RaceConfig& config) : m_ui(ctx.device(), ctx.game->vfs), m_text(ctx.device()) { m_result.config = config; + // The network race this screen runs (FrontendScreen starts it when + // the countdown arrives). + if (config.multiplayer && ctx.netGame) + m_netRace = ctx.netGame->raceNumber(); // GetLoadScreenName: _.jpg (cruise "roam" and Cops and // Robbers "multicop" without a number), else the generic one. const std::string prefix = modePrefix(config.mode); @@ -235,7 +239,9 @@ class RaceScreen final : public Screen { m_hud->updateChat(static_cast(dt)); if (multiplayer(ctx)) { ctx.netGame->update(); - if (ctx.netGame->takeReturnToLobby() || !ctx.netGame->inSession()) { + if (ctx.netGame->backToLobby(m_netRace) || !ctx.netGame->inSession()) { + log::info("race: the network race is over ({})", + ctx.netGame->inSession() ? "back to the lobby" : "the session ended"); leaveRace(ctx, m_result); return; } @@ -2479,8 +2485,10 @@ class RaceScreen final : public Screen { storeViewSettings(); if (multiplayer(ctx) && ctx.netGame->isHost()) { const auto phase = ctx.netGame->phase(); - if (phase == game::NetGame::Phase::Countdown || phase == game::NetGame::Phase::Racing) + if (phase == game::NetGame::Phase::Countdown || phase == game::NetGame::Phase::Racing) { + log::info("race: the host leaves the race: everyone back to the lobby"); ctx.netGame->returnToLobby(); + } } ctx.nextScreen = makeFrontendScreen(ctx, result); } @@ -3991,6 +3999,7 @@ class RaceScreen final : public Screen { std::set m_netFinished; // the other players that finished (or did not) std::map m_netPlayers; // the players last frame (who left) bool m_netPlayersKnown = false; + std::uint32_t m_netRace = 0; // NetGame::raceNumber() of this race bool multiplayer(Context& ctx) const { return m_result.config.multiplayer && ctx.netGame; } std::unique_ptr m_aiRenderer; std::unique_ptr m_trafficBodies; diff --git a/src/app/frontend/FrontendScreen.cpp b/src/app/frontend/FrontendScreen.cpp index e7f0eb8..48bc7e3 100644 --- a/src/app/frontend/FrontendScreen.cpp +++ b/src/app/frontend/FrontendScreen.cpp @@ -674,10 +674,12 @@ std::string formatTime(float seconds) { // OPENMM2_FRONTEND_SCRIPT drives the menus for automated screenshots, e.g. // "profile:Test;page:races;wait:5;nav:down;nav:right" // Commands: profile: (create/select), page:, nav:, wait:, mode:, -// city:, vehicle:, time:, -// result: (opens the results screen), -// mp: (multiplayer). +// right|accept|back|tab>, wait:, wait:race (until a race has been +// driven), mode:, city:, +// vehicle:, time:, result: +// (opens the results screen), +// mp: +// (multiplayer). class Script { public: explicit Script(const char* text) { @@ -686,10 +688,14 @@ class Script { if (!str::trim(part).empty()) m_commands.emplace_back(str::trim(part)); } - bool active() const { return m_next < m_commands.size() || m_wait > 0; } + bool active() const { return m_next < m_commands.size() || m_wait > 0 || m_waitRace; } + // The menus came back after a race (ends a wait:race). + void raceDriven() { m_waitRace = false; } // Runs commands for this frame; may inject navigation into `nav`. void step(Frontend& fe) { + if (m_waitRace) + return; if (m_wait > 0) { --m_wait; return; @@ -699,6 +705,11 @@ class Script { const auto colon = c.find(':'); const std::string cmd = c.substr(0, colon); const std::string arg = colon == std::string::npos ? "" : c.substr(colon + 1); + if (cmd == "wait" && arg == "race") { + // wait:race: until a race has started and the menus are back. + m_waitRace = true; + return; + } if (cmd == "wait") { m_wait = static_cast(str::parseInt(arg).value_or(1)); return; @@ -716,6 +727,7 @@ class Script { std::vector m_commands; std::size_t m_next = 0; int m_wait = 0; + bool m_waitRace = false; }; void Script::run(Frontend& fe, const std::string& cmd, const std::string& arg) { @@ -750,7 +762,8 @@ void Script::run(Frontend& fe, const std::string& cmd, const std::string& arg) { fe.startRace(); } else if (cmd == "mp") { // Multiplayer automation: mp:host[:], mp:join:
[|], mp:chat:, - // mp:ready, mp:start, mp:team:<0|1>, mp:mode:. + // mp:ready, mp:start, mp:team:<0|1>, mp:mode:, + // mp:car:[:]. const auto sub = arg.substr(0, arg.find(':')); const auto rest = arg.find(':') == std::string::npos ? std::string() : arg.substr(arg.find(':') + 1); if (sub == "host") { @@ -766,6 +779,17 @@ void Script::run(Frontend& fe, const std::string& cmd, const std::string& arg) { fe.ctx.netGame->setReady(true); } else if (fe.ctx.netGame && sub == "start") { fe.ctx.netGame->startRace(); + } else if (fe.ctx.netGame && sub == "car") { + // mp:car:[:]: what the lobby's SELECT VEHICLE (a + // joiner's ready is cleared), a pick in the garage and its PREV + // (Frontend::applyLobbyCar) do. + const auto paint = rest.find(':'); + if (!fe.ctx.netGame->isHost()) + fe.ctx.netGame->setReady(false); + fe.config.vehicle = rest.substr(0, paint); + fe.config.vehicleColor = + paint == std::string::npos ? 0 : static_cast(str::parseInt(rest.substr(paint + 1)).value_or(0)); + fe.applyLobbyCar(); } else if (fe.ctx.netGame && sub == "team") { game::NetCar car = fe.ctx.netGame->localCar(); car.team = static_cast(str::parseInt(rest).value_or(0)); @@ -870,19 +894,18 @@ namespace { using frontend::Frontend; // The automation script runs once per process: when a scripted race ends, -// the menus come back on the results screen instead of replaying it. -const char* scriptOnce() { - static bool used = false; - if (used) - return nullptr; - used = true; - return std::getenv("OPENMM2_FRONTEND_SCRIPT"); +// the menus come back on the results screen (or the multiplayer lobby) and +// the script carries on with the commands after the one that started the +// race, instead of replaying it. +frontend::Script& processScript() { + static frontend::Script script(std::getenv("OPENMM2_FRONTEND_SCRIPT")); + return script; } class FrontendScreen final : public Screen { public: FrontendScreen(Context& ctx, const game::RaceResult* result) - : m_fe(ctx), m_script(scriptOnce()) { + : m_fe(ctx), m_script(processScript()) { ctx.input.startTextInput(ctx.window()); // mmInterface::PlayUIMusic. if (auto* music = ctx.music()) { @@ -901,6 +924,8 @@ class FrontendScreen final : public Screen { p->save(); } } + if (result) + m_script.raceDriven(); const std::string last = m_fe.store.lastUsed(); if (!last.empty()) m_fe.selectProfile(last); @@ -1003,7 +1028,7 @@ class FrontendScreen final : public Screen { } Frontend m_fe; - frontend::Script m_script; + frontend::Script& m_script; }; } // namespace diff --git a/src/game/net/NetGame.cpp b/src/game/net/NetGame.cpp index ed96619..0a98939 100644 --- a/src/game/net/NetGame.cpp +++ b/src/game/net/NetGame.cpp @@ -247,7 +247,8 @@ void NetGame::leave() { m_impl->session.reset(); } m_impl->joining = false; - m_raceStartPending = m_returnPending = m_raceStarted = false; + m_raceStartPending = m_raceStarted = false; + m_raceNumber = m_lobbyAfterRace = 0; m_gameEvents.clear(); } @@ -293,12 +294,18 @@ void NetGame::handleEvents() { } else if constexpr (std::is_same_v) { m_chat.push_back({ev.from, playerName(ev.from), ev.text, false}); } else if constexpr (std::is_same_v) { + ++m_raceNumber; + log::info("netgame: race {} starts at session time {}", m_raceNumber, ev.startTime); m_raceStartPending = true; m_raceStarted = false; + // Whatever is still queued belongs to an earlier race + // (late events, or a cruise that reads none). + m_gameEvents.clear(); } else if constexpr (std::is_same_v) { m_raceStarted = true; } else if constexpr (std::is_same_v) { - m_returnPending = true; + log::info("netgame: back to the lobby after race {}", m_raceNumber); + m_lobbyAfterRace = m_raceNumber; m_raceStartPending = false; m_raceStarted = false; addSystemLine("**Session returning to Lobby**"); // string 73 @@ -556,8 +563,6 @@ std::string NetGame::portMappingStatus() const { bool NetGame::takeRaceStart() { return std::exchange(m_raceStartPending, false); } -bool NetGame::takeReturnToLobby() { return std::exchange(m_returnPending, false); } - std::uint32_t NetGame::sessionTime() const { return m_impl->session ? m_impl->session->time() : 0; } std::uint32_t NetGame::raceStartTime() const { return m_impl->session ? m_impl->session->countdownEnd() : 0; } diff --git a/src/game/net/NetGame.h b/src/game/net/NetGame.h index 946894a..44807e6 100644 --- a/src/game/net/NetGame.h +++ b/src/game/net/NetGame.h @@ -191,8 +191,14 @@ class NetGame { // --- Race ------------------------------------------------------------------------- // True once when a countdown starts (the frontend switches to the race). bool takeRaceStart(); - // True once when the host returned everyone to the lobby. - bool takeReturnToLobby(); + // The races this NetGame has seen start, counted from 1 (0 before the + // first). The race screen keeps the number of the race it runs. + std::uint32_t raceNumber() const { return m_raceNumber; } + // Whether the host has taken everyone back to the lobby since race + // `number` started. A return that arrives while the player is in the + // menus (the host's own return, or one after the player quit the race + // early) belongs to that race only, never to the next one. + bool backToLobby(std::uint32_t number) const { return number != 0 && m_lobbyAfterRace >= number; } // Session clock (ms; the host's clock, estimated on clients). std::uint32_t sessionTime() const; std::uint32_t raceStartTime() const; @@ -232,7 +238,8 @@ class NetGame { std::deque m_notices; std::vector m_gameEvents; bool m_raceStartPending = false; - bool m_returnPending = false; + std::uint32_t m_raceNumber = 0; // countdowns seen + std::uint32_t m_lobbyAfterRace = 0; // the race the last return to the lobby ended bool m_raceStarted = false; bool m_closed = false; net::DisconnectReason m_joinFailure = net::DisconnectReason::None; diff --git a/tests/game/test_netgame.cpp b/tests/game/test_netgame.cpp index f723287..e3f89d3 100644 --- a/tests/game/test_netgame.cpp +++ b/tests/game/test_netgame.cpp @@ -190,8 +190,11 @@ TEST(NetGame, HostJoinChatReadyCountdownAndState) { EXPECT_EQ(got[1].from, client.localId()); // Back to the lobby. + const std::uint32_t race = client.raceNumber(); + EXPECT_EQ(race, 1u); + EXPECT_FALSE(client.backToLobby(race)); host.returnToLobby(); - ASSERT_TRUE(pump({&host, &client}, [&] { return client.takeReturnToLobby(); })); + ASSERT_TRUE(pump({&host, &client}, [&] { return client.backToLobby(race); })); EXPECT_EQ(client.phase(), NetGame::Phase::Lobby); // Host quits: the client is told. diff --git a/tests/game/test_netgame_lobby.cpp b/tests/game/test_netgame_lobby.cpp new file mode 100644 index 0000000..8954437 --- /dev/null +++ b/tests/game/test_netgame_lobby.cpp @@ -0,0 +1,203 @@ +// The lobby and race flow of game::NetGame between a host and a client in +// one process (docs/review/multiplayer-lobby.md). +#include "game/net/NetGame.h" + +#include + +#include +#include +#include +#include + +using namespace mm2; +using game::NetGame; + +namespace { + +// Distinct ports per test process so parallel ctest runs do not collide. +std::uint16_t basePort() { + static const std::uint16_t port = static_cast( + 33000 + (std::chrono::steady_clock::now().time_since_epoch().count() / 1000) % 4000 * 2); + return port; +} + +game::NetOptions options(const std::string& name) { + game::NetOptions o; + o.playerName = name; + o.port = basePort(); + o.portMapping = false; // never touch a real router from tests + o.discoveryPort = static_cast(basePort() + 1); + return o; +} + +bool pump(std::initializer_list games, const std::function& done, int timeoutMs = 4000) { + const auto end = std::chrono::steady_clock::now() + std::chrono::milliseconds(timeoutMs); + while (std::chrono::steady_clock::now() < end) { + for (NetGame* g : games) + g->update(); + if (done()) + return true; + std::this_thread::sleep_for(std::chrono::milliseconds(2)); + } + return false; +} + +// Pumps for a while without a condition (lets messages in flight arrive). +void settle(std::initializer_list games, int ms = 150) { + pump(games, [] { return false; }, ms); +} + +game::RaceConfig cruise() { + game::RaceConfig c; + c.mode = game::GameMode::Cruise; + c.city = "sf"; + return c; +} + +game::NetHostOptions hostOptions() { + game::NetHostOptions h; + h.sessionName = "Lobby test"; + h.advertiseOnLan = false; + return h; +} + +// A host and a joined, ready client in the lobby. +struct Lobby { + NetGame host{options("Hosty")}; + NetGame client{options("Clienty")}; + + void open() { + std::string err; + ASSERT_TRUE(host.host(cruise(), hostOptions(), {"vpbug", 0, 0}, &err)) << err; + ASSERT_TRUE(client.join(std::format("127.0.0.1:{}", basePort()), "", {"vpcab", 0, 1}, &err)) << err; + ASSERT_TRUE(pump({&host, &client}, [&] { + return client.phase() == NetGame::Phase::Lobby && host.players().size() == 2; + })); + readyUp(); + } + void readyUp() { + client.setReady(true); + ASSERT_TRUE(pump({&host, &client}, [&] { return host.everyoneReady(false); })); + } + // GO DRIVE: both machines see the race start once. + void start() { + host.startRace(200); + bool h = false, c = false; + ASSERT_TRUE(pump({&host, &client}, [&] { + h = h || host.takeRaceStart(); + c = c || client.takeRaceStart(); + return h && c; + })); + ASSERT_TRUE(pump({&host, &client}, [&] { return host.raceStarted() && client.raceStarted(); })); + } +}; + +} // namespace + +// The maintainer's report: the second race of a session bounced everyone +// back to the lobby as soon as it had loaded. The host's own return to the +// lobby (RaceScreen::leaveRace) arrives as an event once the menus are up, +// where nothing took it, and the next race's first frame took it as its own. +TEST(NetGameLobby, ReturnToLobbyEndsOnlyItsOwnRace) { + Lobby l; + ASSERT_NO_FATAL_FAILURE(l.open()); + EXPECT_EQ(l.host.raceNumber(), 0u); + EXPECT_FALSE(l.host.backToLobby(l.host.raceNumber())); // no race yet + + ASSERT_NO_FATAL_FAILURE(l.start()); + const std::uint32_t first = l.host.raceNumber(); + EXPECT_EQ(first, 1u); + EXPECT_EQ(l.client.raceNumber(), 1u); + EXPECT_FALSE(l.host.backToLobby(first)); + EXPECT_FALSE(l.client.backToLobby(first)); + + // The host ends the race (a finish, or Quit to Lobby); the host's race + // screen leaves without updating again, the menus update from now on. + l.host.returnToLobby(); + ASSERT_TRUE( + pump({&l.host, &l.client}, [&] { return l.host.backToLobby(first) && l.client.backToLobby(first); })); + EXPECT_EQ(l.client.phase(), NetGame::Phase::Lobby); + + // In the lobby: both change cars, the host changes the settings, the + // client gets ready again (the return cleared it). + EXPECT_FALSE(l.host.everyoneReady(false)); + l.host.setLocalCar({"vpcop", 0, 0}); + l.client.setLocalCar({"vpmustang99", 1, 1}); + auto cfg = cruise(); + cfg.timeOfDay = game::TimeOfDay::Night; + l.host.setRaceConfig(cfg); + settle({&l.host, &l.client}); + ASSERT_NO_FATAL_FAILURE(l.readyUp()); + + ASSERT_NO_FATAL_FAILURE(l.start()); + const std::uint32_t second = l.host.raceNumber(); + EXPECT_EQ(second, 2u); + // The new race is not over on either machine... + settle({&l.host, &l.client}); + EXPECT_FALSE(l.host.backToLobby(second)); + EXPECT_FALSE(l.client.backToLobby(l.client.raceNumber())); + EXPECT_EQ(l.host.phase(), NetGame::Phase::Racing); + EXPECT_EQ(l.client.phase(), NetGame::Phase::Racing); + // ...while the first one still is. + EXPECT_TRUE(l.host.backToLobby(first)); + EXPECT_EQ(l.client.raceConfig().vehicle, "vpmustang99"); + EXPECT_EQ(l.client.raceConfig().timeOfDay, game::TimeOfDay::Night); +} + +// A client that quit the race early is in the menus when the host ends it: +// that return must not end the client's next race either. +TEST(NetGameLobby, ReturnSeenInTheMenusDoesNotEndTheNextRace) { + Lobby l; + ASSERT_NO_FATAL_FAILURE(l.open()); + ASSERT_NO_FATAL_FAILURE(l.start()); + // The client's race screen is gone (Quit to Race Menu); the host races on + // and ends it later. + l.host.returnToLobby(); + ASSERT_TRUE(pump({&l.host, &l.client}, [&] { return l.client.phase() == NetGame::Phase::Lobby; })); + ASSERT_NO_FATAL_FAILURE(l.readyUp()); + ASSERT_NO_FATAL_FAILURE(l.start()); + settle({&l.host, &l.client}); + EXPECT_FALSE(l.client.backToLobby(l.client.raceNumber())); +} + +// The host returns everyone and starts again before the client's race screen +// has updated: both messages arrive in one update. The old race is over, the +// new one is pending and not over. +TEST(NetGameLobby, ReturnAndNewCountdownInOneUpdate) { + Lobby l; + ASSERT_NO_FATAL_FAILURE(l.open()); + ASSERT_NO_FATAL_FAILURE(l.start()); + const std::uint32_t first = l.client.raceNumber(); + l.host.returnToLobby(); + l.host.startRace(500); // MultiAllReady is the lobby's check, not the session's + // Only the host is serviced until both messages have left it. + settle({&l.host}, 100); + ASSERT_TRUE(pump({&l.client}, [&] { return l.client.raceNumber() == first + 1; })); + EXPECT_TRUE(l.client.backToLobby(first)); // the race screen of the first race leaves + EXPECT_TRUE(l.client.takeRaceStart()); // and the menus start the second + EXPECT_FALSE(l.client.backToLobby(first + 1)); +} + +// Game events left over from a race (a finish sent while the host was +// already going back to the lobby) do not reach the next race. +TEST(NetGameLobby, StaleGameEventsAreDroppedAtTheNextCountdown) { + Lobby l; + ASSERT_NO_FATAL_FAILURE(l.open()); + ASSERT_NO_FATAL_FAILURE(l.start()); + l.host.returnToLobby(); + l.client.sendFinish(61000, 1); // sent before the client heard of the return + ASSERT_TRUE(pump({&l.host, &l.client}, [&] { return l.client.phase() == NetGame::Phase::Lobby; })); + settle({&l.host, &l.client}); + ASSERT_NO_FATAL_FAILURE(l.readyUp()); + ASSERT_NO_FATAL_FAILURE(l.start()); + EXPECT_TRUE(l.host.takeGameEvents().empty()); + // The new race's events come through. + l.client.sendCheckpoint(1, 5000); + std::vector got; + ASSERT_TRUE(pump({&l.host, &l.client}, [&] { + for (auto& e : l.host.takeGameEvents()) + got.push_back(std::move(e)); + return !got.empty(); + })); + EXPECT_EQ(got.front().type, net::GameEventType::CheckpointReached); +} From 31cd6ded105357c22d804005d948f80da4fa020f Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Thu, 8 Oct 2026 22:16:32 -0700 Subject: [PATCH 07/54] Answer LAN queries only from the LAN, at a bounded rate The beacon answered every well-formed query from any source with an advert about 17 times its size, up to 64 per frame: a spoofed source turned a host with a reachable discovery port into a reflector, and a spoofed broadcast source made one query reach a whole subnet. The scanner kept every advert it heard, keyed by source and game port, so a flood of spoofed adverts grew the session list without bound, and their text reached the session list as sent. The beacon now answers loopback, private addresses and the subnets of this machine's interfaces (so VPN adapters on public ranges still work), never broadcast, multicast or zero addresses, and at most 20 replies a second with a burst of 40. The scanner lists at most 64 sessions, skips adverts with game port 0 and cleans up their text like the session's. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/net/Discovery.cpp | 179 ++++++++++++++++++++++--------- src/net/Discovery.h | 23 ++++ tests/net/test_hostile_input.cpp | 104 ++++++++++++++++++ 3 files changed, 257 insertions(+), 49 deletions(-) diff --git a/src/net/Discovery.cpp b/src/net/Discovery.cpp index 20dee01..3efd73e 100644 --- a/src/net/Discovery.cpp +++ b/src/net/Discovery.cpp @@ -77,6 +77,88 @@ bool serializeAdvert(S& s, LanAdvert& a) { std::uint64_t sessionKey(const Address& a) { return (std::uint64_t{a.ip} << 16) | a.port; } +// Replies a beacon sends per second, and its burst. A scanner asks about once +// a second on each of its broadcast addresses, so this serves a LAN party with +// room to spare and bounds what a flood of queries gets back. +constexpr double kBeaconReplyRate = 20.0; +constexpr double kBeaconReplyBurst = 40.0; + +// An advert's text is shown in the session list: cleaned up like everything +// else that arrives from the network. +void cleanAdvert(LanAdvert& a) { + a.sessionName = sanitizeText(a.sessionName, kMaxNameLength * 2); + a.hostName = sanitizeText(a.hostName, kMaxNameLength); + if (!isValidAssetName(a.city)) + a.city.clear(); + a.build = sanitizeText(a.build, kMaxShortStringLength); +} + +struct Interface { + std::uint32_t ip = 0; + std::uint32_t mask = 0; + std::uint32_t broadcast = 0; + bool canBroadcast = false; +}; + +// Up, non-loopback IPv4 interfaces. +std::vector interfaces() { + std::vector out; +#ifdef _WIN32 + ULONG size = 16 * 1024; + std::vector buffer(size); + auto* adapters = reinterpret_cast(buffer.data()); + ULONG rc = GetAdaptersAddresses(AF_INET, GAA_FLAG_SKIP_ANYCAST | GAA_FLAG_SKIP_MULTICAST | GAA_FLAG_SKIP_DNS_SERVER, + nullptr, adapters, &size); + if (rc == ERROR_BUFFER_OVERFLOW) { + buffer.resize(size); + adapters = reinterpret_cast(buffer.data()); + rc = GetAdaptersAddresses(AF_INET, GAA_FLAG_SKIP_ANYCAST | GAA_FLAG_SKIP_MULTICAST | GAA_FLAG_SKIP_DNS_SERVER, + nullptr, adapters, &size); + } + if (rc == NO_ERROR) { + for (auto* a = adapters; a; a = a->Next) { + if (a->OperStatus != IfOperStatusUp || a->IfType == IF_TYPE_SOFTWARE_LOOPBACK) + continue; + for (auto* u = a->FirstUnicastAddress; u; u = u->Next) { + if (u->Address.lpSockaddr->sa_family != AF_INET) + continue; + const auto* sin = reinterpret_cast(u->Address.lpSockaddr); + const unsigned prefix = u->OnLinkPrefixLength; + if (prefix == 0 || prefix > 32) + continue; + Interface f; + f.ip = ntohl(sin->sin_addr.s_addr); + f.mask = prefix == 32 ? 0xFFFFFFFFu : 0xFFFFFFFFu << (32 - prefix); + f.broadcast = f.ip | ~f.mask; + f.canBroadcast = prefix < 32; + out.push_back(f); + } + } + } +#else + ifaddrs* list = nullptr; + if (getifaddrs(&list) == 0) { + for (ifaddrs* i = list; i; i = i->ifa_next) { + if (!i->ifa_addr || i->ifa_addr->sa_family != AF_INET) + continue; + if (!(i->ifa_flags & IFF_UP) || (i->ifa_flags & IFF_LOOPBACK)) + continue; + Interface f; + f.ip = ntohl(reinterpret_cast(i->ifa_addr)->sin_addr.s_addr); + if (i->ifa_netmask) + f.mask = ntohl(reinterpret_cast(i->ifa_netmask)->sin_addr.s_addr); + if ((i->ifa_flags & IFF_BROADCAST) && i->ifa_broadaddr) { + f.broadcast = ntohl(reinterpret_cast(i->ifa_broadaddr)->sin_addr.s_addr); + f.canBroadcast = true; + } + out.push_back(f); + } + freeifaddrs(list); + } +#endif + return out; +} + } // namespace // --- Wire format -------------------------------------------------------------- @@ -135,57 +217,40 @@ bool decodeLanAdvert(std::span packet, std::uint32_t& nonce, La std::vector broadcastAddresses() { std::vector out; -#ifdef _WIN32 - ULONG size = 16 * 1024; - std::vector buffer(size); - auto* adapters = reinterpret_cast(buffer.data()); - ULONG rc = GetAdaptersAddresses(AF_INET, GAA_FLAG_SKIP_ANYCAST | GAA_FLAG_SKIP_MULTICAST | GAA_FLAG_SKIP_DNS_SERVER, - nullptr, adapters, &size); - if (rc == ERROR_BUFFER_OVERFLOW) { - buffer.resize(size); - adapters = reinterpret_cast(buffer.data()); - rc = GetAdaptersAddresses(AF_INET, GAA_FLAG_SKIP_ANYCAST | GAA_FLAG_SKIP_MULTICAST | GAA_FLAG_SKIP_DNS_SERVER, - nullptr, adapters, &size); - } - if (rc == NO_ERROR) { - for (auto* a = adapters; a; a = a->Next) { - if (a->OperStatus != IfOperStatusUp || a->IfType == IF_TYPE_SOFTWARE_LOOPBACK) - continue; - for (auto* u = a->FirstUnicastAddress; u; u = u->Next) { - if (u->Address.lpSockaddr->sa_family != AF_INET) - continue; - const auto* sin = reinterpret_cast(u->Address.lpSockaddr); - const std::uint32_t ip = ntohl(sin->sin_addr.s_addr); - const unsigned prefix = u->OnLinkPrefixLength; - if (prefix == 0 || prefix >= 32) - continue; - const std::uint32_t mask = 0xFFFFFFFFu << (32 - prefix); - out.push_back(ip | ~mask); - } - } - } -#else - ifaddrs* list = nullptr; - if (getifaddrs(&list) == 0) { - for (ifaddrs* i = list; i; i = i->ifa_next) { - if (!i->ifa_addr || i->ifa_addr->sa_family != AF_INET) - continue; - if (!(i->ifa_flags & IFF_UP) || (i->ifa_flags & IFF_LOOPBACK) || !(i->ifa_flags & IFF_BROADCAST)) - continue; - if (!i->ifa_broadaddr) - continue; - const auto* sin = reinterpret_cast(i->ifa_broadaddr); - out.push_back(ntohl(sin->sin_addr.s_addr)); - } - freeifaddrs(list); - } -#endif + for (const Interface& f : interfaces()) + if (f.canBroadcast) + out.push_back(f.broadcast); out.push_back(0xFFFFFFFFu); std::ranges::sort(out); out.erase(std::unique(out.begin(), out.end()), out.end()); return out; } +std::vector localSubnets() { + std::vector out; + for (const Interface& f : interfaces()) + if (f.mask != 0) + out.push_back({f.ip, f.mask}); + return out; +} + +bool answersLanQueryFrom(const Address& from, std::span subnets) { + const std::uint32_t ip = from.ip; + // 0.0.0.0, 224/4 multicast, 240/4 reserved and 255.255.255.255. + if (ip == 0 || from.port == 0 || (ip >> 28) >= 0xE) + return false; + for (const Subnet& s : subnets) { + // A subnet's broadcast (and old-style all-zeros broadcast) address. + const std::uint32_t host = ~s.mask; + if (host > 1 && (ip & s.mask) == (s.ip & s.mask) && ((ip & host) == host || (ip & host) == 0)) + return false; + } + if (from.isPrivate()) + return true; + return std::ranges::any_of(subnets, + [&](const Subnet& s) { return s.mask != 0 && (ip & s.mask) == (s.ip & s.mask); }); +} + std::uint32_t primaryLocalAddress() { auto sock = UdpSocket::open(Address::any(0), false, false); // Connecting a UDP socket only selects a route; nothing is transmitted. @@ -278,6 +343,9 @@ bool LanBeacon::start(std::uint16_t discoveryPort, std::string* error) { if (m_targets.empty()) for (std::uint32_t ip : broadcastAddresses()) m_targets.push_back({ip, m_port}); + m_subnets = localSubnets(); + m_replyTokens = kBeaconReplyBurst; + m_replyRefill = monotonicMs(); return true; } @@ -290,6 +358,10 @@ std::uint16_t LanBeacon::port() const { return m_socket ? m_socket->localAddress void LanBeacon::update() { if (!m_socket) return; + const std::uint64_t now = monotonicMs(); + m_replyTokens = std::min(kBeaconReplyBurst, m_replyTokens + static_cast(now - m_replyRefill) * + kBeaconReplyRate / 1000.0); + m_replyRefill = now; std::array buf{}; for (int i = 0; i < 64; ++i) { Address from; @@ -297,10 +369,12 @@ void LanBeacon::update() { if (n <= 0) break; std::uint32_t nonce = 0; - if (decodeLanQuery(std::span(buf.data(), static_cast(n)), nonce)) - m_socket->sendTo(from, encodeLanAdvert(nonce, m_advert)); + if (!decodeLanQuery(std::span(buf.data(), static_cast(n)), nonce) || + !answersLanQueryFrom(from, m_subnets) || m_replyTokens < 1.0) + continue; + m_replyTokens -= 1.0; + m_socket->sendTo(from, encodeLanAdvert(nonce, m_advert)); } - const std::uint64_t now = monotonicMs(); if (m_announceIntervalMs && now >= m_nextAnnounce) { m_nextAnnounce = now + m_announceIntervalMs; const auto packet = encodeLanAdvert(0, m_advert); @@ -365,9 +439,16 @@ void LanScanner::receive(UdpSocket& socket) { LanAdvert advert; if (!decodeLanAdvert(std::span(buf.data(), static_cast(n)), nonce, advert)) continue; + cleanAdvert(advert); + if (advert.gamePort == 0) + continue; const std::uint64_t now = monotonicMs(); const Address game{from.ip, advert.gamePort}; - auto& entry = m_sessions[sessionKey(game)]; + const std::uint64_t key = sessionKey(game); + // Spoofed adverts (any source, any port) must not grow the list without bound. + if (!m_sessions.contains(key) && m_sessions.size() >= kMaxLanSessions) + continue; + auto& entry = m_sessions[key]; entry.address = game; entry.advert = std::move(advert); entry.lastSeenMs = now; diff --git a/src/net/Discovery.h b/src/net/Discovery.h index ab738d1..0d5e048 100644 --- a/src/net/Discovery.h +++ b/src/net/Discovery.h @@ -11,6 +11,7 @@ #include "net/Net.h" #include "net/Protocol.h" +#include #include #include #include @@ -55,6 +56,24 @@ bool decodeLanAdvert(std::span packet, std::uint32_t& nonce, La // 255.255.255.255. std::vector broadcastAddresses(); +// An IPv4 interface address and its netmask, host byte order. +struct Subnet { + std::uint32_t ip = 0; + std::uint32_t mask = 0; +}; +// The subnets of all up, non-loopback IPv4 interfaces (VPN adapters included). +std::vector localSubnets(); + +// Whether a beacon answers a query that claims to come from `from`: loopback, +// a private address (Address::isPrivate) or one on `subnets`, so a spoofed +// query from the Internet cannot make it send adverts elsewhere, and never a +// broadcast, multicast or zero address, where one reply would reach many +// machines or none. +bool answersLanQueryFrom(const Address& from, std::span subnets); + +// Most sessions a LanScanner lists; adverts for further sessions are ignored. +inline constexpr std::size_t kMaxLanSessions = 64; + // The LAN address this machine uses for Internet traffic (no packets are // sent), or 0 if there is no route. std::uint32_t primaryLocalAddress(); @@ -87,6 +106,10 @@ class LanBeacon { std::uint32_t m_announceIntervalMs = 2000; std::uint64_t m_nextAnnounce = 0; std::vector
m_targets; + std::vector m_subnets; + // Replies left in the budget (see kBeaconReplyRate) and when it was filled. + double m_replyTokens = 0.0; + std::uint64_t m_replyRefill = 0; }; class LanScanner { diff --git a/tests/net/test_hostile_input.cpp b/tests/net/test_hostile_input.cpp index 5e47bc0..7ae2a2f 100644 --- a/tests/net/test_hostile_input.cpp +++ b/tests/net/test_hostile_input.cpp @@ -425,3 +425,107 @@ TEST(HostileInput, OversizedPacketsAreNotDelivered) { EXPECT_TRUE(gotSmall); EXPECT_LT(largest, huge.size()); } + +// --- LAN discovery --------------------------------------------------------------------- + +// Who a beacon answers: this machine and the LAN (private ranges, or a subnet +// of one of its interfaces such as a VPN's), never a public source, nor a +// broadcast, multicast or zero address. +TEST(HostileInput, BeaconAnswersOnlyLanSources) { + const std::vector subnets = {{0xC0A8010Au, 0xFFFFFF00u}, // 192.168.1.10/24 + {0x19010203u, 0xFF000000u}}; // 25.1.2.3/8 (a VPN on a public range) + auto allowed = [&](std::uint32_t ip, std::uint16_t port = 50000) { + return answersLanQueryFrom(Address{ip, port}, subnets); + }; + EXPECT_TRUE(allowed(0x7F000001u)); // 127.0.0.1 + EXPECT_TRUE(allowed(0xC0A80114u)); // 192.168.1.20 + EXPECT_TRUE(allowed(0x0A000005u)); // 10.0.0.5, private elsewhere + EXPECT_TRUE(allowed(0x64400001u)); // 100.64.0.1 (CGNAT, e.g. Tailscale) + EXPECT_TRUE(allowed(0x19FFFF01u)); // 25.255.255.1 on the VPN subnet + EXPECT_FALSE(allowed(0x08080808u)); // 8.8.8.8 + EXPECT_FALSE(allowed(0x1A000001u)); // 26.0.0.1 + EXPECT_FALSE(allowed(0xC0A801FFu)); // 192.168.1.255: the subnet's broadcast + EXPECT_FALSE(allowed(0xC0A80100u)); // 192.168.1.0 + EXPECT_FALSE(allowed(0x19FFFFFFu)); // the VPN's broadcast + EXPECT_FALSE(allowed(0xFFFFFFFFu)); + EXPECT_FALSE(allowed(0xE0000001u)); // 224.0.0.1 + EXPECT_FALSE(allowed(0)); + EXPECT_FALSE(allowed(0xC0A80114u, 0)); // no source port to answer + // This machine's own interfaces count as a LAN. + const auto local = localSubnets(); + for (const Subnet& s : local) { + if (~s.mask > 1) { + EXPECT_TRUE(answersLanQueryFrom(Address{s.ip, 2301}, local)); + } + } +} + +// The beacon answers a query at a bounded rate (it would otherwise reflect +// ~17 times the traffic it receives to whatever source a query claims). +TEST(HostileInput, BeaconRepliesAreRateLimited) { + LanBeacon beacon; + ASSERT_TRUE(beacon.start(0)); + beacon.setAnnounceTargets({}); + beacon.setAnnounceInterval(0); + LanAdvert advert; + advert.sessionName = std::string(48, 'x'); + beacon.setAdvert(advert); + auto sock = UdpSocket::open(Address::loopback(0), false, false); + ASSERT_TRUE(sock); + const auto query = encodeLanQuery(1); + int replies = 0; + std::array buf{}; + for (int i = 0; i < 400; ++i) { + sock->sendTo(Address::loopback(beacon.port()), query); + if (i % 50 == 49) { + beacon.update(); + Address from; + while (sock->receiveFrom(from, buf) > 0) + ++replies; + } + } + waitFor([&] { + beacon.update(); + Address from; + while (sock->receiveFrom(from, buf) > 0) + ++replies; + return false; + }, 200); + EXPECT_GT(replies, 0); + EXPECT_LT(replies, 100); +} + +// A flood of adverts (spoofed sources and ports) cannot grow the session +// list without bound, and their text is cleaned up. +TEST(HostileInput, ScannerBoundsAndCleansAdverts) { + LanScanner scanner; + // Listening on the discovery port lets the test deliver "announcements". + auto probe = UdpSocket::open(Address::loopback(0), false, false); + ASSERT_TRUE(probe); + const std::uint16_t port = probe->localAddress().port; + probe.reset(); + ASSERT_TRUE(scanner.start(port, /*listenForAnnouncements=*/true)); + scanner.setBroadcast(false); + auto sock = UdpSocket::open(Address::loopback(0), false, false); + ASSERT_TRUE(sock); + for (int i = 0; i < 2000; ++i) { + LanAdvert a; + a.sessionName = std::format("Fake\n{}\x1b", i); + a.hostName = "h\r"; + a.city = "../x"; + a.gamePort = static_cast(1000 + i); + sock->sendTo(Address::loopback(port), encodeLanAdvert(0, a)); + if (i % 100 == 99) + scanner.update(); + } + std::this_thread::sleep_for(std::chrono::milliseconds(20)); + scanner.update(); + const auto sessions = scanner.sessions(); + EXPECT_GT(sessions.size(), 0u); + EXPECT_LE(sessions.size(), 64u); + for (const auto& s : sessions) { + EXPECT_FALSE(hasControl(s.advert.sessionName)); + EXPECT_FALSE(hasControl(s.advert.hostName)); + EXPECT_EQ(s.advert.city.find('/'), std::string::npos); + } +} From 255e792cef3a656f3e25bd447b12d3bf0fcd925d Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Thu, 8 Oct 2026 22:18:58 -0700 Subject: [PATCH 08/54] Check the port mapping record and router replies before using them The crash-safe state file's ports were cast to 16 bits unchecked, so a damaged file wrapped them around, and internal port 0 passed: the next start's NAT-PMP or PCP clean-up then asked the router to delete internal port 0, which RFC 6886 and RFC 6887 define as every mapping of this machine, other programs' included. The PCP nonce's hex accepted signs. The external address a UPnP device reported went to the lobby's status line as given, and any device on the LAN can answer as the router. Records with a port outside 1-65535 are ignored, the PCP/NAT-PMP backend refuses to delete internal port 0, the nonce takes only hex digits, and an external address that does not parse is dropped. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/net/NatPmpBackend.cpp | 22 +++++++++++++++---- src/net/PortMapper.cpp | 11 ++++++++-- src/net/PortMapper.h | 2 +- src/net/UpnpBackend.cpp | 5 ++++- tests/net/test_hostile_input.cpp | 36 ++++++++++++++++++++++++++++++++ 5 files changed, 68 insertions(+), 8 deletions(-) diff --git a/src/net/NatPmpBackend.cpp b/src/net/NatPmpBackend.cpp index 7432d84..ea06b60 100644 --- a/src/net/NatPmpBackend.cpp +++ b/src/net/NatPmpBackend.cpp @@ -1,6 +1,5 @@ // PCP / NAT-PMP port mapping backend and default-gateway lookup. #include "core/Log.h" -#include "core/StringUtil.h" #include "net/Discovery.h" #include "net/NatPmp.h" #include "net/PortMapper.h" @@ -79,11 +78,20 @@ std::string toHex(std::span bytes) { bool fromHex(std::string_view hex, std::span out) { if (hex.size() != out.size() * 2) return false; + auto digit = [](char c) -> int { + if (c >= '0' && c <= '9') + return c - '0'; + if (c >= 'a' && c <= 'f') + return c - 'a' + 10; + if (c >= 'A' && c <= 'F') + return c - 'A' + 10; + return -1; + }; for (std::size_t i = 0; i < out.size(); ++i) { - const auto v = str::parseInt(std::string("0x") + std::string(hex.substr(i * 2, 2))); - if (!v) + const int hi = digit(hex[i * 2]), lo = digit(hex[i * 2 + 1]); + if (hi < 0 || lo < 0) return false; - out[i] = static_cast(*v); + out[i] = static_cast(hi * 16 + lo); } return true; } @@ -192,6 +200,12 @@ class NatPmpBackend final : public PortMappingBackend { } bool unmap(const MappingRecord& record, std::string& error) override { + // RFC 6886 3.4 / RFC 6887 11.1: a deletion for internal port 0 removes + // every mapping of this machine, including other programs'. + if (record.internalPort == 0) { + error = "no internal port"; + return false; + } if (record.method == MappingMethod::Pcp) { natpmp::Nonce nonce{}; if (!fromHex(record.token, nonce)) { diff --git a/src/net/PortMapper.cpp b/src/net/PortMapper.cpp index 107a6e6..22e0b36 100644 --- a/src/net/PortMapper.cpp +++ b/src/net/PortMapper.cpp @@ -79,8 +79,15 @@ std::optional loadMappingRecord(const std::filesystem::path& file r.method = methodFromKey(ini.getString(kSection, "Method")); r.gatewayId = ini.getString(kSection, "Gateway"); r.internalIp = ini.getString(kSection, "InternalIp"); - r.internalPort = static_cast(ini.getInt(kSection, "InternalPort", 0)); - r.externalPort = static_cast(ini.getInt(kSection, "ExternalPort", 0)); + // A damaged file must not wrap a port around, nor name internal port 0: + // to PCP and NAT-PMP a deletion for internal port 0 removes every mapping + // this machine has. + const long long internalPort = ini.getInt(kSection, "InternalPort", 0); + const long long externalPort = ini.getInt(kSection, "ExternalPort", 0); + if (internalPort < 1 || internalPort > 65535 || externalPort < 1 || externalPort > 65535) + return std::nullopt; + r.internalPort = static_cast(internalPort); + r.externalPort = static_cast(externalPort); r.token = ini.getString(kSection, "Token"); r.description = ini.getString(kSection, "Description"); if (!r.valid()) diff --git a/src/net/PortMapper.h b/src/net/PortMapper.h index ad49bb1..0d9f7e7 100644 --- a/src/net/PortMapper.h +++ b/src/net/PortMapper.h @@ -96,7 +96,7 @@ struct MappingRecord { std::string token; // backend-specific (PCP mapping nonce, hex) std::string description; - bool valid() const { return method != MappingMethod::None && externalPort != 0; } + bool valid() const { return method != MappingMethod::None && internalPort != 0 && externalPort != 0; } }; class PortMappingBackend { diff --git a/src/net/UpnpBackend.cpp b/src/net/UpnpBackend.cpp index 749a1f0..783c741 100644 --- a/src/net/UpnpBackend.cpp +++ b/src/net/UpnpBackend.cpp @@ -97,8 +97,11 @@ class UpnpBackend final : public PortMappingBackend { m_externalIp = ext; else if (wan[0]) m_externalIp = wan; - out.externalIp = m_externalIp; + // Any device on the LAN can answer as the router: show only an address. const auto extAddr = Address::parse(m_externalIp); + if (!extAddr) + m_externalIp.clear(); + out.externalIp = m_externalIp; out.doubleNat = r == UPNP_PRIVATEIP_IGD || (extAddr && extAddr->isPrivate()); return true; } diff --git a/tests/net/test_hostile_input.cpp b/tests/net/test_hostile_input.cpp index 7ae2a2f..f939844 100644 --- a/tests/net/test_hostile_input.cpp +++ b/tests/net/test_hostile_input.cpp @@ -2,13 +2,16 @@ // Transport sends what an honest OpenMM2 never would, and the real Session, // LAN scanner and beacon must stay consistent (docs/review/multiplayer-input.md). #include "net/Discovery.h" +#include "net/PortMapper.h" #include "net/Session.h" #include #include #include +#include #include +#include #include #include @@ -529,3 +532,36 @@ TEST(HostileInput, ScannerBoundsAndCleansAdverts) { EXPECT_EQ(s.advert.city.find('/'), std::string::npos); } } + +// --- Port forwarding state file ---------------------------------------------------------- + +// The crash-safe record is read back with its ports checked: a damaged file +// neither wraps a port around nor names internal port 0, for which a PCP or +// NAT-PMP deletion would remove every mapping of this machine. +TEST(HostileInput, DamagedPortMappingRecordIsIgnored) { + const auto file = std::filesystem::temp_directory_path() / + std::format("openmm2-hostile-portmap-{}.ini", + std::chrono::steady_clock::now().time_since_epoch().count()); + auto write = [&](std::string_view internalPort, std::string_view externalPort) { + std::ofstream out(file, std::ios::trunc); + out << "[PortMapping]\nMethod=natpmp\nGateway=192.168.1.1\nInternalIp=192.168.1.20\n" + << "InternalPort=" << internalPort << "\nExternalPort=" << externalPort << "\nToken=\n"; + }; + write("2300", "2300"); + ASSERT_TRUE(loadMappingRecord(file)); + const std::vector> damaged = { + {"0", "2300"}, {"-1", "2300"}, {"70000", "2300"}, {"2300", "65536"}, {"2300", "0"}, {"x", "2300"}}; + for (const auto& [in, ext] : damaged) { + write(in, ext); + EXPECT_FALSE(loadMappingRecord(file)) << in << " " << ext; + } + std::filesystem::remove(file); + + // The backend refuses such a deletion itself as well. + MappingRecord all; + all.method = MappingMethod::NatPmp; + all.externalPort = 2300; + std::string error; + EXPECT_FALSE(makeNatPmpBackend()->unmap(all, error)); + EXPECT_EQ(error, "no internal port"); +} From 836488de8219d22b56785049c07faff4f1fe7603 Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Thu, 8 Oct 2026 22:20:34 -0700 Subject: [PATCH 09/54] Let the ambient traffic see every player, as aiMap's player list does MM2's traffic is written for a list of players (aiMap +0x170, filled by aiMap::AddPlayer): aiMap::Update populates the roads round each player's room with that player's bit in each road's mask (aiPath::AddAmbPlayer / RemAmbPlayer), aiGoalRandomDrive::Update and aiGoalRegainRail::Update walk the list for the first player in a car's way and keep its index (aiVehicleSpline +0xe6) for aiGoalAvoidPlayer, and the pose solver refits a car when any player is within 100 m. OpenMM2 had folded that into one player. Traffic::step now takes the list (one player in slot 0 is the single-player step, unchanged), with 16 slots where aiMap has four, for the shared traffic of a network cruise. ai::World::setOtherPlayers adds the network players to the traffic (the pedestrians keep the local player only). World counts its light steps, and advanceLightsTo lets a network client's light sets follow the host's: they are deterministic from a reset. Traffic publishes a spawn count per pool slot so the network can tell a recycled slot from the car before it, and the police mark hits by the first player's car (the local one) as before. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/ai/Police.cpp | 9 +- src/ai/Traffic.cpp | 182 ++++++++++++++++++++------- src/ai/Traffic.h | 62 +++++++-- src/ai/World.cpp | 45 ++++++- src/ai/World.h | 26 ++++ tests/ai/test_shared_traffic.cpp | 207 +++++++++++++++++++++++++++++++ 6 files changed, 469 insertions(+), 62 deletions(-) create mode 100644 tests/ai/test_shared_traffic.cpp diff --git a/src/ai/Police.cpp b/src/ai/Police.cpp index 2814c51..6211a15 100644 --- a/src/ai/Police.cpp +++ b/src/ai/Police.cpp @@ -416,9 +416,14 @@ void PoliceCar::update(float dt, std::span cars, PoliceForce& bool active) { // aiPoliceOfficer::Update. (void)los; - for (const TrackedCar& c : cars) - if (c.isPlayer) + // The local player's car (the first player; the others of a network + // cruise's shared traffic follow it): what it hits is marked. + for (const TrackedCar& c : cars) { + if (c.isPlayer) { m_playerBody = c.body; + break; + } + } // dgPhysManager::CollideInstances marks what the player's car hits // (lvlInstance flag 0x8000, cleared each frame). const bool touching = m_touchingPlayer || m_car.body.hitByPlayer; diff --git a/src/ai/Traffic.cpp b/src/ai/Traffic.cpp index 11dfd26..25e644b 100644 --- a/src/ai/Traffic.cpp +++ b/src/ai/Traffic.cpp @@ -500,7 +500,6 @@ void Traffic::resetReactTicks(int car) { // --- Population (aiMap::AdjustAmbients, aiPath::ClearAmbients) -------------- void Traffic::activate(int path) { - m_pathActive[static_cast(path)] = 1; m_activePaths.insert(m_activePaths.begin(), path); } @@ -575,6 +574,7 @@ bool Traffic::placeCar(int slot, int path, int dir, int lane, float dist) { if (o.dist2(at) < sq(kAmbientOpponentClearance)) return true; m_pool.pop_back(); + ++c.spawns; // aiVehicleAmbient::Reset: aiVehicleSpline::Reset (speed, tyre // rotation, reaction ticks, the obstacle map state), then each goal's // Init (aiGoalRandomDrive::Init: acceleration and target speed 0, no @@ -614,7 +614,8 @@ bool Traffic::placeCar(int slot, int path, int dir, int lane, float dist) { return true; } -void Traffic::adjustAmbients(int oldRoom, int newRoom) { +void Traffic::adjustAmbients(int oldRoom, int newRoom, int slot) { + const auto bit = static_cast(1u << slot); const city::AiMap* map = m_net.source(); if (!map) return; @@ -638,9 +639,16 @@ void Traffic::adjustAmbients(int oldRoom, int newRoom) { if (std::ranges::find(to, p) == to.end() && p < m_net.paths().size()) removed.push_back(p); } - for (int p : removed) - if (m_pathActive[static_cast(p)]) + // aiPath::RemAmbPlayer: the player's bit off; a road no player keeps is + // emptied (aiPath::ClearAmbients). + for (int p : removed) { + std::uint16_t& mask = m_pathActive[static_cast(p)]; + if (mask == 0) + continue; + mask = static_cast(mask & ~bit); + if (mask == 0) clearPath(p); + } // The total usable lane length of the new roads (centre length - 5 m per // lane, roads with an [Exceptions] entry aside) sets the spacing: about @@ -660,8 +668,13 @@ void Traffic::adjustAmbients(int oldRoom, int newRoom) { float carry = 0.0f, leftover = 0.0f; for (int p : added) { - if (m_pathActive[static_cast(p)]) - continue; // already populated + // aiPath::AddAmbPlayer: the player's bit on; a road another player + // already keeps is populated already. + std::uint16_t& mask = m_pathActive[static_cast(p)]; + const std::uint16_t before = mask; + mask = static_cast(mask | bit); + if (before != 0) + continue; activate(p); const PathInfo& info = m_net.paths()[static_cast(p)]; for (int dir : {-1, 1}) { @@ -759,10 +772,11 @@ void Traffic::reset() { c.reactDist = kIntersectionReactDist; m_pool.push_back(static_cast(i)); } - // The next step populates the roads round the player's room + // The next step populates the roads round the players' rooms // (aiMap::Reset's AdjustAmbients from room 0). m_started = false; - m_room = 0; + m_slotRooms.fill(0); + m_slotPresent.fill(false); m_avoidEvents.clear(); publish(); } @@ -926,7 +940,7 @@ void Traffic::resetRandomDrive(Car& c) { c.curReactTicks = c.totReactTicks; // Reset ends by posing the car on its curve (the pose solver Update // calls), whatever its speed. - solvePose(c, m_player); + solvePose(c); } bool Traffic::stopSignOkayToGo(int node, int car) { @@ -1557,7 +1571,14 @@ void Traffic::changeLanes(int idx) { } // The car's matrix (the solver aiGoalRandomDrive calls after moving it). -void Traffic::solvePose(Car& c, const PlayerCar& player) { +bool Traffic::anyPlayerWithin(const Vec3& p, float radius2) const { + for (const PlayerCar& player : m_players) + if (player.valid && Vec2{player.transform.m3.x - p.x, player.transform.m3.z - p.z}.mag2() < radius2) + return true; + return false; +} + +void Traffic::solvePose(Car& c) { const float t = c.segLen > 0.0f ? c.segDist / c.segLen : 0.0f; Vec3 F; Vec3 P = curvePoint(c, t, &F); @@ -1576,8 +1597,8 @@ void Traffic::solvePose(Car& c, const PlayerCar& player) { c.fitted = false; return; } - const bool playerNear = - player.valid && Vec2{player.transform.m3.x - P.x, player.transform.m3.z - P.z}.mag2() < 10000.0f; + // Any player within 100 m (the pose solver walks aiMap's player list). + const bool playerNear = anyPlayerWithin(P, 10000.0f); const int S = static_cast(src.center.size()); if (!playerNear && c.goal != AmbientGoal::RegainRail && c.rail == Rail::Lane && c.section < S - 1 && !src.xAxis.empty()) { @@ -1628,7 +1649,7 @@ void Traffic::solvePose(Car& c, const PlayerCar& player) { } // aiGoalRandomDrive::Update. -void Traffic::updateRandomDrive(int idx, float dt, const PlayerCar& player) { +void Traffic::updateRandomDrive(int idx, float dt) { Car& c = m_cars[static_cast(idx)]; if (c.goalTicks == 0) resetRandomDrive(c); @@ -1642,22 +1663,33 @@ void Traffic::updateRandomDrive(int idx, float dt, const PlayerCar& player) { c.laneChangeOk = false; } if (c.speed <= 0.0f) { - // A stopped car is only refitted once a player comes within 100 m. - if (!c.fitted && player.valid && - Vec2{player.transform.m3.x - c.transform.m3.x, player.transform.m3.z - c.transform.m3.z}.mag2() < - 10000.0f) { - c.fitted = true; - solvePose(c, player); + // A stopped car is only refitted once a player comes within 100 m + // (for each such player in the list, as coded). + if (!c.fitted) { + for (const PlayerCar& player : m_players) { + if (player.valid && Vec2{player.transform.m3.x - c.transform.m3.x, + player.transform.m3.z - c.transform.m3.z} + .mag2() < 10000.0f) { + c.fitted = true; + solvePose(c); + } + } } } else if (solveRailType(idx)) { - solvePose(c, player); - if (player.valid && - Vec2{c.transform.m3.x - player.transform.m3.x, c.transform.m3.z - player.transform.m3.z}.mag2() < - sq(kPlayerZoneDistance) && - detectPlayerCollision(c, player) && !ambientBlockingPlayer(idx, player)) { - c.goal = AmbientGoal::AvoidPlayer; - c.goalTicks = 0; - return; + solvePose(c); + // The first player of the list within 25 m, in its path and not + // behind the car ahead: the car avoids that player. + for (std::size_t k = 0; k < m_players.size(); ++k) { + const PlayerCar& player = m_players[k]; + if (player.valid && + Vec2{c.transform.m3.x - player.transform.m3.x, c.transform.m3.z - player.transform.m3.z}.mag2() < + sq(kPlayerZoneDistance) && + detectPlayerCollision(c, player) && !ambientBlockingPlayer(idx, player)) { + c.avoidSlot = m_playerSlots[k]; + c.goal = AmbientGoal::AvoidPlayer; + c.goalTicks = 0; + return; + } } } else { return; // back in the pool @@ -1746,8 +1778,10 @@ void Traffic::fitOffRail(Car& c) { } // aiGoalAvoidPlayer: off its rail, swerving round the player. -void Traffic::updateAvoidPlayer(int idx, float dt, const PlayerCar& p) { +void Traffic::updateAvoidPlayer(int idx, float dt) { Car& c = m_cars[static_cast(idx)]; + // aiMap::Player(+0xe6): the player it avoids. + const PlayerCar& p = m_slotCars[static_cast(std::clamp(c.avoidSlot, 0, kMaxTrafficPlayers - 1))]; const PathInfo& info = m_net.paths()[static_cast(c.path)]; const float cap = info.speedLimit + c.exceedLimit; const float R = p.radius; @@ -2137,7 +2171,7 @@ void Traffic::resetRegainRail(int idx) { } // aiGoalRegainRail::Update. -void Traffic::updateRegainRail(int idx, float dt, const PlayerCar& p) { +void Traffic::updateRegainRail(int idx, float dt) { Car& c = m_cars[static_cast(idx)]; if (c.goalTicks == 0) { resetRegainRail(idx); @@ -2145,10 +2179,14 @@ void Traffic::updateRegainRail(int idx, float dt, const PlayerCar& p) { return; } ++c.goalTicks; - if (p.valid && detectPlayerCollision(c, p)) { - c.goal = AmbientGoal::AvoidPlayer; - c.goalTicks = 0; - return; + // Any player of the list in its path: it avoids that one. + for (std::size_t k = 0; k < m_players.size(); ++k) { + if (m_players[k].valid && detectPlayerCollision(c, m_players[k])) { + c.avoidSlot = m_playerSlots[k]; + c.goal = AmbientGoal::AvoidPlayer; + c.goalTicks = 0; + return; + } } if (c.speed > c.target) c.speed = c.target; @@ -2156,7 +2194,7 @@ void Traffic::updateRegainRail(int idx, float dt, const PlayerCar& p) { c.speed = dt * c.accelFactor + c.speed; c.segDist = c.speed * dt + c.segDist; c.roadDist = c.segDist + c.regainBase; - solvePose(c, p); + solvePose(c); if (c.segDist > c.regainLength) { c.goal = AmbientGoal::RandomDrive; c.goalTicks = 0; @@ -2263,21 +2301,72 @@ void Traffic::step(float dt, const Vec3& pos, const Vec3& vel, int playerRoom) { } bool Traffic::populate(int playerRoom) { + TrafficPlayer local; + local.room = playerRoom; + return populate(std::span(&local, 1)); +} + +bool Traffic::populate(std::span players) { if (m_started) return false; m_started = true; - m_room = playerRoom; - adjustAmbients(0, playerRoom); + for (const TrafficPlayer& p : players) { + if (p.slot < 0 || p.slot >= kMaxTrafficPlayers || m_slotPresent[static_cast(p.slot)]) + continue; + m_slotPresent[static_cast(p.slot)] = true; + m_slotRooms[static_cast(p.slot)] = p.room; + adjustAmbients(0, p.room, p.slot); + } m_populateAll = false; return true; } void Traffic::step(float dt, const PlayerCar& player, int playerRoom) { - m_player = player; - // Population: aiMap::Reset, then whenever the player enters a new room. - if (!populate(playerRoom) && playerRoom != 0 && playerRoom != m_room) { - adjustAmbients(m_room, playerRoom); - m_room = playerRoom; + TrafficPlayer local; + local.car = player; + local.room = playerRoom; + step(dt, std::span(&local, 1)); +} + +void Traffic::step(float dt, std::span players) { + m_players.clear(); + m_playerSlots.clear(); + std::array listed{}; + for (const TrafficPlayer& p : players) { + if (p.slot < 0 || p.slot >= kMaxTrafficPlayers || listed[static_cast(p.slot)]) + continue; + listed[static_cast(p.slot)] = true; + m_players.push_back(p.car); + m_playerSlots.push_back(p.slot); + m_slotCars[static_cast(p.slot)] = p.car; + } + // Population: aiMap::Reset, then whenever a player enters a new room + // (aiMap::Update). A player gone from the list gives up its roads from + // the room they were populated for (aiMap::RemovePlayer asks the room + // of its car's last place; inferred equivalent), one new to it starts + // from room 0 (aiMap::AddPlayer). + if (!populate(players)) { + for (int slot = 0; slot < kMaxTrafficPlayers; ++slot) { + const auto s = static_cast(slot); + if (m_slotPresent[s] && !listed[s]) { + adjustAmbients(m_slotRooms[s], 0, slot); + m_slotRooms[s] = 0; + m_slotPresent[s] = false; + } + } + for (const TrafficPlayer& p : players) { + if (p.slot < 0 || p.slot >= kMaxTrafficPlayers) + continue; + const auto s = static_cast(p.slot); + if (!m_slotPresent[s]) { + m_slotPresent[s] = true; + m_slotRooms[s] = 0; + } + if (p.room != 0 && p.room != m_slotRooms[s]) { + adjustAmbients(m_slotRooms[s], p.room, p.slot); + m_slotRooms[s] = p.room; + } + } } // Lights that turned green restart their queues (aiTrafficLightSet:: // Update -> aiPath::ResetVehicleReactTicks). @@ -2318,7 +2407,7 @@ void Traffic::step(float dt, const PlayerCar& player, int playerRoom) { if (m_cars[static_cast(car)].lane == static_cast(l) && !seen[static_cast(car)]) { seen[static_cast(car)] = 1; - updateCar(car, dt, player); + updateCar(car, dt); } car = behind; } @@ -2330,13 +2419,13 @@ void Traffic::step(float dt, const PlayerCar& player, int playerRoom) { // aiVehicleAmbient::Update: the running goal (its Reset first), then // aiVehicleSpline::Update. -void Traffic::updateCar(int idx, float dt, const PlayerCar& player) { +void Traffic::updateCar(int idx, float dt) { Car& c = m_cars[static_cast(idx)]; if (!c.active) return; switch (c.goal) { case AmbientGoal::RandomDrive: - updateRandomDrive(idx, dt, player); + updateRandomDrive(idx, dt); break; case AmbientGoal::Collision: if (c.goalTicks == 0) @@ -2348,10 +2437,10 @@ void Traffic::updateCar(int idx, float dt, const PlayerCar& player) { c.moverFlags = 0x08; break; case AmbientGoal::RegainRail: - updateRegainRail(idx, dt, player); + updateRegainRail(idx, dt); break; case AmbientGoal::AvoidPlayer: - updateAvoidPlayer(idx, dt, player); + updateAvoidPlayer(idx, dt); break; case AmbientGoal::Parked: break; @@ -2537,6 +2626,7 @@ void Traffic::publish() { a.physical = c.physical; a.wreck = c.wreck; a.moverFlags = c.moverFlags; + a.spawns = c.spawns; m_public.push_back(std::move(a)); } } diff --git a/src/ai/Traffic.h b/src/ai/Traffic.h index 2d5603b..cb64634 100644 --- a/src/ai/Traffic.h +++ b/src/ai/Traffic.h @@ -90,6 +90,23 @@ struct AmbientCar { // regaining its rail (aiGoalAvoidPlayer / aiGoalRegainRail::Update), // 0x08 for a wreck (aiGoalCollision::Update); 0 not declared. unsigned moverFlags = 0; + // OpenMM2: how many times the pool slot was put on a road (a network + // cruise's shared traffic tells a recycled slot from the car before it). + int spawns = 0; +}; + +// The players the traffic sees: MM2's aiMap player list (aiMap::AddPlayer, +// aiMap::RemovePlayer, aiVehiclePlayer). A single-player game has one, the +// local player in slot 0. aiMap keeps four slots, one bit each in a road's +// populated mask (aiPath::AddAmbPlayer / RemAmbPlayer); OpenMM2 keeps 16 +// for the shared traffic of its network cruise (an OpenMM2 extra, see +// docs/parity/openmm2-only.md), whose other players take the slots of their +// network ids. +inline constexpr int kMaxTrafficPlayers = 16; +struct TrafficPlayer { + int slot = 0; // aiVehiclePlayer +8: its place in the list (0..kMaxTrafficPlayers - 1) + PlayerCar car; + int room = 0; // the PSDL room it is in (0: outside every room) }; struct TrafficSettings { @@ -123,6 +140,14 @@ class Traffic { // One update (aiMap::Update's ambient part). `playerRoom` is the PSDL room // the player is in (0: outside every room, nothing changes). void step(float dt, const PlayerCar& player, int playerRoom); + // The same for every player in `players` (MM2's list order; slots must + // differ): the roads round each are populated, a player new to the list + // populates from room 0 (aiMap::AddPlayer) and one gone from it gives + // its roads up (aiMap::RemovePlayer), and the cars avoid and let pass + // any of them (aiGoalRandomDrive::Update, aiGoalRegainRail::Update, + // aiGoalAvoidPlayer for the player it avoids). One player in slot 0 is + // the single-player step. + void step(float dt, std::span players); // aiMap::Reset's ambient part (mmGame::Init after the AI map loads, and // mmGame::Reset when a race restarts): the own random stream back to its // seed (ResetRandomSeed; a shared one is its owner's to set), every road @@ -137,10 +162,15 @@ class Traffic { // calls it before the pedestrians' population, as aiMap::Reset orders // them, and before either updates). False when already populated. bool populate(int playerRoom); + // aiMap::Reset's AdjustAmbients for every player (`players` as step()). + bool populate(std::span players); // Convenience for tools: a player of default size at `pos` moving at `vel`. void step(float dt, const Vec3& pos, const Vec3& vel, int playerRoom); const std::vector& cars() const { return m_public; } + // The vehicle types of the pool (aiMap::Init's ambient types, in the AI + // map's order). + const std::vector& types() const { return m_types; } // Ids of the cars that started avoiding the player since the last call // (aiGoalAvoidPlayer::Reset, where MM2 plays the avoidance horn and, if // it sounds, the driver's reaction). @@ -263,6 +293,7 @@ class Traffic { float frontBumper = 2.0f, backBumper = 2.0f, leftSide = 1.0f, rightSide = 1.0f; bool active = false; bool wreck = false; + int spawns = 0; // placeCar count (AmbientCar::spawns) int regainAttempts = 1; // +0xea // aiVehicleSpline::UpdateObstacleMap's state (+0xdc id, +0xde type, // +0xe0 room hint, +0xe2 section, +0xe4 side). @@ -293,7 +324,9 @@ class Traffic { int goalTicks = 0; // the running goal's counter; Reset runs at 0 bool laneChangeOk = false; // aiGoalRandomDrive +0x10 bool atStopSign = false; // aiGoalRandomDrive +0x12 - // aiGoalAvoidPlayer. + // aiGoalAvoidPlayer: the player it avoids (aiVehicleSpline +0xe6, a + // slot of the player list), its heading and offsets. + int avoidSlot = 0; float heading = 0.0f, passOffset = 0.0f; bool centred = false; // aiGoalRegainRail. @@ -362,7 +395,8 @@ class Traffic { void resetReactTicks(int car); // Population. - void adjustAmbients(int oldRoom, int newRoom); + // aiMap::AdjustAmbients for player `slot` (its bit in the roads' masks). + void adjustAmbients(int oldRoom, int newRoom, int slot); void activate(int path); void clearPath(int path); bool placeCar(int slot, int path, int dir, int lane, float dist); @@ -372,7 +406,7 @@ class Traffic { // aiGoalRandomDrive. bool chooseNext(Car& c); void resetRandomDrive(Car& c); - void updateRandomDrive(int idx, float dt, const PlayerCar& player); + void updateRandomDrive(int idx, float dt); float speedLimit(const Car& c) const; float distanceToIntersection(const Car& c) const; float distanceToVehicle(const Car& a, const Car& b) const; @@ -385,7 +419,7 @@ class Traffic { bool solveRailType(int idx); void solveLane(Car& c); void changeLanes(int idx); - void solvePose(Car& c, const PlayerCar& player); + void solvePose(Car& c); // Stop signs (aiIntersection). bool stopSignOkayToGo(int node, int car); @@ -396,12 +430,12 @@ class Traffic { bool detectPlayerZoneCollision(const Car& c, const PlayerCar& p) const; bool playerInFront(const Car& c, const PlayerCar& p) const; bool ambientBlockingPlayer(int idx, const PlayerCar& p) const; - void updateAvoidPlayer(int idx, float dt, const PlayerCar& p); + void updateAvoidPlayer(int idx, float dt); void resetRegainRail(int idx); - void updateRegainRail(int idx, float dt, const PlayerCar& p); + void updateRegainRail(int idx, float dt); void fitOffRail(Car& c); - void updateCar(int idx, float dt, const PlayerCar& player); + void updateCar(int idx, float dt); void publish(); const RoadNetwork& m_net; @@ -426,7 +460,7 @@ class Traffic { void updateObstacleMap(int idx); std::vector* obstacleList(int path, int side, int bucket); std::vector> m_queues; // per network lane - std::vector m_pathActive; // aiPath AddAmbPlayer mask + std::vector m_pathActive; // aiPath AddAmbPlayer mask: a bit per player slot std::vector m_activePaths; // aiMap +0x17c, most recent first std::vector> m_stopWaiting; // per intersection (aiIntersection +0x8) std::vector m_alwaysStop; // per path (aiPath +0x162) @@ -434,10 +468,18 @@ class Traffic { std::vector m_opponents; ImpactHandler m_onImpact; GroundProbe m_probe; - int m_room = 0; bool m_started = false; bool m_populateAll = false; - PlayerCar m_player; + // The player list of this step (aiMap +0x170) with their slots and, per + // slot, the last car seen there (a car avoiding a player who left keeps + // avoiding where it was, inferred), the room its roads were populated + // for and whether it is in the list. + std::vector m_players; + std::vector m_playerSlots; + std::array m_slotCars{}; + std::array m_slotRooms{}; + std::array m_slotPresent{}; + bool anyPlayerWithin(const Vec3& p, float radius2) const; }; } // namespace mm2::ai diff --git a/src/ai/World.cpp b/src/ai/World.cpp index 77f735c..933b6ad 100644 --- a/src/ai/World.cpp +++ b/src/ai/World.cpp @@ -253,15 +253,36 @@ void World::step(const PlayerCar& player) { m_playerRoom = room; // After a reset: aiMap::Reset's population, the traffic's then the // pedestrians', before the updates draw from the same stream. - m_traffic->populate(room); - m_peds->populate(room); - m_traffic->step(kAiStepSeconds, player, room); + if (m_others.empty()) { + m_traffic->populate(room); + m_peds->populate(room); + m_traffic->step(kAiStepSeconds, player, room); + } else { + // The traffic sees every player (the local one first), the + // pedestrians only the local one. + std::vector players; + players.reserve(m_others.size() + 1); + players.push_back({0, player, room}); + for (const OtherPlayer& o : m_others) { + if (o.slot <= 0 || o.slot >= kMaxTrafficPlayers) + continue; + int& hint = m_otherRooms[static_cast(o.slot)]; + const int r = roomAt(o.car.transform.m3, hint); + if (r != 0) + hint = r; + players.push_back({o.slot, o.car, r}); + } + m_traffic->populate(players); + m_peds->populate(room); + m_traffic->step(kAiStepSeconds, players); + } m_peds->step(kAiStepSeconds, player, room); if (m_lightsDeferred) { ++m_pendingLightSteps; return; } m_lights.update(kAiStepSeconds); + ++m_lightSteps; updateSignals(); } @@ -277,7 +298,9 @@ void World::reset() { m_map->resetPlayers(); // aiVehiclePlayer::Reset m_accumulator = 0.0f; m_pendingLightSteps = 0; + m_lightSteps = 0; m_playerRoom = 0; + m_otherRooms.fill(0); updateSignals(); } @@ -296,11 +319,25 @@ void World::resetAndPopulate(const Vec3& playerResetPos) { void World::updateLights() { if (m_pendingLightSteps == 0) return; - for (; m_pendingLightSteps > 0; --m_pendingLightSteps) + for (; m_pendingLightSteps > 0; --m_pendingLightSteps) { m_lights.update(kAiStepSeconds); + ++m_lightSteps; + } updateSignals(); } +void World::advanceLightsTo(std::uint32_t steps, int maxSteps) { + m_pendingLightSteps = 0; + bool changed = false; + for (int n = 0; m_lightSteps < steps && n < maxSteps; ++n) { + m_lights.update(kAiStepSeconds); + ++m_lightSteps; + changed = true; + } + if (changed) + updateSignals(); +} + void World::update(float dt, const PlayerCar& player) { // aiVehicleManager::Update (a child of aiMap, once a frame): the clock // the ambient cars' indicators blink by, the game time summed in a float. diff --git a/src/ai/World.h b/src/ai/World.h index 32c2582..68efe90 100644 --- a/src/ai/World.h +++ b/src/ai/World.h @@ -20,6 +20,7 @@ #include "city/RoomLocator.h" #include "vfs/Vfs.h" +#include #include #include #include @@ -115,6 +116,28 @@ class World { void updateLights(); void step(const Vec3& playerPos, const Vec3& playerVel) { step(PlayerCar::at(playerPos, playerVel)); } + // OpenMM2 extra, the shared traffic of a network cruise: the other + // players' cars, which the ambient traffic populates the roads round, + // avoids and lets pass as it does the local player's (MM2's aiMap keeps + // a list of players, aiMap::AddPlayer and RemovePlayer, of which a + // single-player game has one). `slot` 1..kMaxTrafficPlayers - 1 (the + // network player id). The pedestrians stay the local player's. Kept + // until replaced; an empty list is the single-player step. + struct OtherPlayer { + int slot = 1; + PlayerCar car; + }; + void setOtherPlayers(std::vector players) { m_others = std::move(players); } + + // The light sets' steps since the last reset (each kAiStepSeconds). + std::uint32_t lightSteps() const { return m_lightSteps; } + // OpenMM2 extra (a client of the shared traffic): the light sets follow + // the host's instead of the local clock. Drops the steps update() left + // pending and runs the sets on to `steps` (at most `maxSteps` now); a + // set already past it waits. The sets are deterministic from a reset, + // so equal steps give equal lights. + void advanceLightsTo(std::uint32_t steps, int maxSteps = 9000); + // Race opponents' positions: ambient cars are never placed within 50 m // of one (aiMap::AdjustAmbients). void setOpponents(std::span positions) { m_traffic->setOpponents(positions); } @@ -166,7 +189,10 @@ class World { float m_vehicleClock = 0.0f; // aiVehicleManager's summed time (never reset) bool m_lightsDeferred = false; int m_pendingLightSteps = 0; + std::uint32_t m_lightSteps = 0; // light updates since the last reset int m_playerRoom = 0; // the player's last room, the next lookup's hint + std::vector m_others; + std::array m_otherRooms{}; // their last rooms (hints) }; // Vehicle types for ambient traffic of a city when no AI map lists them: diff --git a/tests/ai/test_shared_traffic.cpp b/tests/ai/test_shared_traffic.cpp new file mode 100644 index 0000000..df30307 --- /dev/null +++ b/tests/ai/test_shared_traffic.cpp @@ -0,0 +1,207 @@ +// The ambient traffic with several players (MM2's aiMap player list), as the +// shared traffic of a network cruise uses it: the roads round every player +// are populated, a player who leaves gives up its roads, and the cars avoid +// whichever player is in their way. +#include "ai/Traffic.h" +#include "ai/World.h" +#include "city/CityData.h" + +#include "TestData.h" + +#include + +#include + +using namespace mm2; + +namespace { + +// A straight two-way road from `a` to `b`, one lane a side, as the retail +// files lay it out (see test_traffic.cpp's `road`). +city::AiPath road(int id, Vec3 a, Vec3 b, std::uint32_t nodeA, std::uint32_t nodeB) { + city::AiPath p; + p.id = static_cast(id); + p.flags = 0x8; + p.halfWidth = 7.0f; + const Vec3 d = (b - a).normalized(); + const Vec3 left{d.z, 0.0f, -d.x}; + constexpr int kSections = 4; + for (int i = 0; i < kSections; ++i) { + const float t = static_cast(i) / static_cast(kSections - 1); + p.center.push_back(lerp(a, b, t)); + p.xAxis.push_back(left); + p.yAxis.push_back(Vec3::yAxis()); + p.zAxis.push_back(-d); + p.wAxis.push_back(d); + if (i > 0) + p.centerLengths.push_back(a.dist(b) * t); + } + auto side = [&](float sign, bool reversed) { + city::AiRoadSide s; + s.numLanes = 1; + s.numSidewalks = 1; + std::vector lane, walk, curb, edge; + for (int i = 0; i < kSections; ++i) { + const int k = reversed ? kSections - 1 - i : i; + lane.push_back(p.center[static_cast(k)] + left * (sign * 2.0f)); + walk.push_back(p.center[static_cast(i)] + left * (sign * 5.5f)); + curb.push_back(p.center[static_cast(i)] + left * (sign * 4.0f)); + edge.push_back(p.center[static_cast(i)] + left * (sign * 7.0f)); + } + s.polylines = {lane, walk, curb, edge}; + return s; + }; + p.left = side(1.0f, true); + p.right = side(-1.0f, false); + p.ends[0].intersection = nodeB; + p.ends[1].intersection = nodeA; + p.ends[0].vehicleRule = 3; + p.ends[1].vehicleRule = 3; + return p; +} + +// A square block, 200 m a side, without lights. +city::AiMap squareBlock() { + city::AiMap map; + const Vec3 corner[4] = {{0, 0, 0}, {0, 0, -200}, {200, 0, -200}, {200, 0, 0}}; + for (int k = 0; k < 4; ++k) + map.paths.push_back(road(k, corner[k], corner[(k + 1) % 4], static_cast(k), + static_cast((k + 1) % 4))); + for (int k = 0; k < 4; ++k) + map.intersections.push_back({static_cast(k), 0, corner[k], + {static_cast((k + 3) % 4), static_cast(k)}}); + for (auto& node : map.intersections) + for (std::size_t k = 0; k < node.paths.size(); ++k) + for (auto& e : map.paths[node.paths[k]].ends) + if (e.intersection == node.id) + e.roadIndex = static_cast(k); + return map; +} + +ai::PlayerCar parkedAt(const Vec3& p) { + ai::PlayerCar c; + c.transform = Mat34::identity(); + c.transform.m3 = p; + return c; +} + +} // namespace + +TEST(SharedTraffic, CarsAvoidWhicheverPlayerIsInTheirWay) { + const city::AiMap map = squareBlock(); + const ai::RoadNetwork net = ai::RoadNetwork::build(map, {}); + ai::TrafficLights lights; + lights.build(net); + ai::TrafficSettings settings; + settings.density = 1.0f; + ai::VehicleData sedan; + sedan.model = "test"; + sedan.size = {2.0f, 1.5f, 4.5f}; + ai::Traffic traffic(net, lights, {sedan}, settings, 3); + traffic.populateAll(); + const ai::TrafficPlayer local{0, parkedAt({1000, 0, 1000}), 0}; + for (int i = 0; i < 60; ++i) + traffic.step(ai::kAiStepSeconds, std::span(&local, 1)); + EXPECT_TRUE(traffic.takeAvoidEvents().empty()); + ASSERT_FALSE(traffic.cars().empty()); + const ai::AmbientCar car = traffic.cars().front(); + ASSERT_GT(car.speed, 1.0f); + // Another player (slot 5) parked in the car's lane 15 m ahead of it. + ai::TrafficPlayer other{5, parkedAt(car.transform.m3 - car.transform.m2 * 15.0f), 0}; + other.car.transform.m0 = car.transform.m0; + other.car.transform.m1 = car.transform.m1; + other.car.transform.m2 = car.transform.m2; + const ai::TrafficPlayer both[2] = {local, other}; + std::vector events; + bool avoiding = false; + for (int i = 0; i < 30; ++i) { + traffic.step(ai::kAiStepSeconds, both); + for (int id : traffic.takeAvoidEvents()) + events.push_back(id); + for (const auto& c : traffic.cars()) + if (c.id == car.id && c.goal == ai::AmbientGoal::AvoidPlayer) + avoiding = true; + } + ASSERT_FALSE(events.empty()); + EXPECT_EQ(events.front(), car.id); + EXPECT_TRUE(avoiding); + // The other player leaves; the traffic carries on round the last place + // it saw it, and nothing breaks. + for (int i = 0; i < 120; ++i) + traffic.step(ai::kAiStepSeconds, std::span(&local, 1)); + EXPECT_FALSE(traffic.cars().empty()); +} + +TEST(SharedTraffic, EveryPlayersRoadsArePopulatedAndGivenUp) { + MM2_REQUIRE_GAME_DATA(); + auto city = city::loadCity(*test::gameData(), "london"); + ASSERT_TRUE(city); + ai::Settings settings; + settings.trafficDensity = 1.0f; + settings.pedestrianDensity = 0.0f; + auto world = ai::World::create(*city, *test::gameData(), settings); + ASSERT_TRUE(world); + // Two intersections far apart. + const auto& nodes = world->network().intersections(); + ASSERT_GT(nodes.size(), 10u); + std::vector placed; // intersections inside a room (whose roads the traffic fills) + for (const auto& n : nodes) + if (world->roomAt(n.centre + Vec3{0, 1, 0}) != 0) + placed.push_back(n.centre); + ASSERT_GT(placed.size(), 10u); + const Vec3 a = placed[placed.size() / 2]; + Vec3 b = a; + for (const Vec3& n : placed) + if (n.dist2(a) > b.dist2(a)) + b = n; + ASSERT_GT(a.dist(b), 1500.0f); + auto near = [&](const Vec3& p, float r) { + return std::count_if(world->cars().begin(), world->cars().end(), + [&](const ai::AmbientCar& c) { return c.transform.m3.dist(p) < r; }); + }; + world->reset(); + world->step(parkedAt(a + Vec3{0, 1, 0})); + EXPECT_GT(near(a, 400.0f), 0); + EXPECT_EQ(near(b, 400.0f), 0); + // A second player at b: the roads round it fill up as well. + world->setOtherPlayers({{3, parkedAt(b + Vec3{0, 1, 0})}}); + world->step(parkedAt(a + Vec3{0, 1, 0})); + EXPECT_GT(near(a, 400.0f), 0); + EXPECT_GT(near(b, 400.0f), 0); + // It leaves: its roads are given up, the local player's kept. + world->setOtherPlayers({}); + world->step(parkedAt(a + Vec3{0, 1, 0})); + EXPECT_GT(near(a, 400.0f), 0); + EXPECT_EQ(near(b, 400.0f), 0); +} + +TEST(SharedTraffic, ClientLightsFollowTheHostsSteps) { + MM2_REQUIRE_GAME_DATA(); + auto city = city::loadCity(*test::gameData(), "london"); + ASSERT_TRUE(city); + ai::Settings settings; + settings.trafficDensity = 0.0f; + settings.pedestrianDensity = 0.0f; + auto host = ai::World::create(*city, *test::gameData(), settings); + auto client = ai::World::create(*city, *test::gameData(), settings); + ASSERT_TRUE(host && client); + ASSERT_FALSE(host->signals().empty()); + host->reset(); + client->reset(); + client->setLightsDeferred(true); + const ai::PlayerCar player = parkedAt(host->network().intersections().front().centre); + // The host runs 10 s; the client, which loaded later, has run 3 s of its + // own clock but follows the host's step count. + for (int i = 0; i < 300; ++i) + host->step(player); + for (int i = 0; i < 90; ++i) + client->step(player); + EXPECT_EQ(host->lightSteps(), 300u); + client->advanceLightsTo(host->lightSteps()); + EXPECT_EQ(client->lightSteps(), 300u); + for (std::size_t i = 0; i < host->signals().size(); ++i) + EXPECT_EQ(host->signals()[i].state, client->signals()[i].state) << i; + // Ahead of the host's count: it waits. + client->advanceLightsTo(200); + EXPECT_EQ(client->lightSteps(), 300u); +} From b11efc72c78cc4e43f4b4152a84c1e20821fffb9 Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Thu, 8 Oct 2026 22:21:12 -0700 Subject: [PATCH 10/54] Keep the driver's own race setup after a network race Back from a network race, the menus took the race's whole configuration as the driver's: multiplayer set, no traffic, police or opponents, and the host's difficulty. Leaving the session and starting a single-player cruise then ran it with no police (19 before, 0 after, in San Francisco), with the multiplayer rules and start picks, and nothing could be recorded. The menus now keep the driver's state as the profile gives it (configFromProfile), which already holds the network event and car (saveNetEvent, as MM2's MultiStartGame -> BeDone); MM2's network games never touch the single-player traffic and police settings either. The sessions page under the lobby no longer starts the LAN browser, which then searched every second through the races. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/app/frontend/FrontendScreen.cpp | 7 +++++-- src/app/frontend/PagesMulti.cpp | 4 +++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/app/frontend/FrontendScreen.cpp b/src/app/frontend/FrontendScreen.cpp index 48bc7e3..8313764 100644 --- a/src/app/frontend/FrontendScreen.cpp +++ b/src/app/frontend/FrontendScreen.cpp @@ -938,8 +938,11 @@ class FrontendScreen final : public Screen { // Back from a multiplayer race: the lobby, over the sessions list, // and a race or Cops and Robbers game that ended shows its // results first (mmGameMulti::UpdateResults, mmMultiCR:: - // FillResults; MM2 shows them over the race). - m_fe.config = result->config; + // FillResults; MM2 shows them over the race). The menus keep the + // driver's own state (configFromProfile, with the network event + // and car saveNetEvent stored): the network race's setup + // (multiplayer, no traffic, police or opponents, the host's + // difficulty) must not become the next single-player race's. m_fe.push(frontend::makeDriverPage(m_fe)); m_fe.push(frontend::makeSessionsPage(m_fe)); m_fe.push(frontend::makeLobbyPage(m_fe)); diff --git a/src/app/frontend/PagesMulti.cpp b/src/app/frontend/PagesMulti.cpp index 367514b..faef7ce 100644 --- a/src/app/frontend/PagesMulti.cpp +++ b/src/app/frontend/PagesMulti.cpp @@ -162,7 +162,9 @@ class SessionsPage final : public Page { fe.unlockedNetCar(); NetGame& net = ensureNetGame(fe); std::string error; - if (!net.startLanScan(&error)) + // Not under the lobby after a race: the browser searches while the + // sessions are on screen. + if (!net.inSession() && !net.startLanScan(&error)) log::warn("multiplayer: LAN browser unavailable: {}", error); // Connection providers: only TCP/IP has a modern equivalent (OpenMM2's From df8b0f77904bc941e11045eba747b8d3ddb0dc27 Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Thu, 8 Oct 2026 22:21:32 -0700 Subject: [PATCH 11/54] Let a kinematic network car strike at its own speed when it says so A network car is a kinematic body placed at its received position; its collider has no inertia, so every impact saw it standing still. That is MM2's static wall, and stays the default. An instance may now report its motion (Instance::kinematicMotion, Body::kinematicMoves): its collider then carries that velocity and spin into the impact, so a traffic car a remote player hits on the host is knocked away as by a moving car, and a client's car meets the shared traffic and police as moving cars. Nothing in MM2 has such instances (OpenMM2 extra). Co-Authored-By: Claude Opus 5.5 (1M context) --- src/phys/Collider.cpp | 9 +++- src/phys/Collider.h | 4 ++ src/phys/Level.h | 8 +++ src/phys/World.cpp | 14 +++++ src/phys/World.h | 6 +++ tests/phys/test_kinematic_motion.cpp | 81 ++++++++++++++++++++++++++++ 6 files changed, 120 insertions(+), 2 deletions(-) create mode 100644 tests/phys/test_kinematic_motion.cpp diff --git a/src/phys/Collider.cpp b/src/phys/Collider.cpp index 69f7171..c28fa51 100644 --- a/src/phys/Collider.cpp +++ b/src/phys/Collider.cpp @@ -21,6 +21,7 @@ void Collider::init(const Bound* b, const Mat34* m, InertialCS* inertia) { handler = nullptr; joint = nullptr; body = nullptr; + moving = false; reset(); } @@ -87,8 +88,12 @@ Mat34 Collider::copyLastMatrix(const void* pusher) const { Vec3 Collider::localVelocity(const Vec3& position) const { // phColliderBase::GetLocalVelocity. - if (!ics) - return {}; + if (!ics) { + if (!moving) + return {}; + // OpenMM2: a kinematic instance's own motion. + return motionVelocity + motionSpin.cross(position - motionCentre); + } return ics->getVelocity(&position); } diff --git a/src/phys/Collider.h b/src/phys/Collider.h index a1e0adc..b8b15cb 100644 --- a/src/phys/Collider.h +++ b/src/phys/Collider.h @@ -87,6 +87,10 @@ class Collider { const Joint* joint = nullptr; // phColliderJointed's joint (phColliderJointed::Attach) Body* body = nullptr; // the simulated body owning the collider (dgPhysEntity), if any bool active = true; // ColliderIsActive without an ICS + // OpenMM2: a collider without an ICS that moves all the same (see + // Instance::kinematicMotion); localVelocity reads it. init() clears it. + bool moving = false; + Vec3 motionVelocity, motionSpin, motionCentre; private: const void* m_key = nullptr; diff --git a/src/phys/Level.h b/src/phys/Level.h index 927f904..5fd5d68 100644 --- a/src/phys/Level.h +++ b/src/phys/Level.h @@ -61,6 +61,14 @@ class Instance { // dgPhysEntity vtable 0x1c on a banger's entity that did not break. virtual void bangerHeld() {} + // OpenMM2 (no MM2 counterpart): an instance moved from outside the + // simulation (a network car's kinematic body, a shared traffic car on a + // network client) reports its velocity and its spin about `centre`; the + // collider that stands for it in an impact carries them, so what hits it + // or is hit by it sees it move. False: it does not move (MM2's static + // instances). + virtual bool kinematicMotion(Vec3& /*velocity*/, Vec3& /*spin*/, Vec3& /*centre*/) const { return false; } + // The ids AudImpact plays for impacts against the instance // (the instance data's collider id): 0 for the world, cars and traffic, the banger's // AudioId for bangers. diff --git a/src/phys/World.cpp b/src/phys/World.cpp index ac3ea4c..193efaa 100644 --- a/src/phys/World.cpp +++ b/src/phys/World.cpp @@ -78,6 +78,15 @@ void Body::place(const Mat34& icsMatrix) { resetCollider(); } +bool Body::kinematicMotion(Vec3& velocity, Vec3& spin, Vec3& centre) const { + if (!kinematic || !kinematicMoves) + return false; + velocity = kinematicVelocity; + spin = kinematicSpin; + centre = ics.matrix.m3; + return true; +} + void Body::syncBoundMatrix() { // vehCarSim::SetWorldMatrix (and the plain bodies' equivalent): the // bound's origin at boundOrigin in the ICS frame. @@ -728,6 +737,11 @@ bool World::collideInstances(Instance& a, Instance& b) { } if (boundB->type == BoundType::ForceSphere) return true; // phCollision::TestBoundForce: no force spheres in a race (not ported) + // OpenMM2: the motion of an instance moved from outside (network cars). + if (!colA->ics) + colA->moving = a.kinematicMotion(colA->motionVelocity, colA->motionSpin, colA->motionCentre); + if (!colB->ics) + colB->moving = b.kinematicMotion(colB->motionVelocity, colB->motionSpin, colB->motionCentre); const int n = testBoundGeneric(*boundA, *colA, *boundB, *colB, m_isectsA.data(), m_isectsB.data(), m_impacts.data(), kMaxIntersections, kMaxImpacts, relPos); diff --git a/src/phys/World.h b/src/phys/World.h index aaf5a7e..64f97ac 100644 --- a/src/phys/World.h +++ b/src/phys/World.h @@ -135,6 +135,12 @@ class Body : public Instance { // integrated, others collide with it as with an object that does not // move (its collider has no ICS). bool kinematic = false; + // OpenMM2: a kinematic body that does move: what it hits sees this + // velocity and spin (about the body's centre of mass), while it keeps + // no momentum of its own (infinite mass). + bool kinematicMoves = false; + Vec3 kinematicVelocity, kinematicSpin; + bool kinematicMotion(Vec3& velocity, Vec3& spin, Vec3& centre) const override; // Places the body's centre of mass frame and resets its motion and // collider (the matrix the next sweep starts from). diff --git a/tests/phys/test_kinematic_motion.cpp b/tests/phys/test_kinematic_motion.cpp new file mode 100644 index 0000000..6d58367 --- /dev/null +++ b/tests/phys/test_kinematic_motion.cpp @@ -0,0 +1,81 @@ +// OpenMM2's moving kinematic instances (network cars): a body placed from +// outside the simulation that reports its motion strikes like a wall moving +// at that speed; one that does not is MM2's static wall. +#include "phys/Bound.h" +#include "phys/Collider.h" +#include "phys/World.h" + +#include + +#include + +using namespace mm2; +using namespace mm2::phys; + +namespace { + +struct BoxBody { + Body body; + std::unique_ptr bound; + BoxBody(const Vec3& size, float mass, const Vec3& position) { + bound = std::make_unique(size); + bound->makeOwnMaterial(); + bound->setElasticity(0.5f); + bound->setFriction(0.0f); + body.ics.setMass(size.x, size.y, size.z, mass); + body.ics.gravity = {0, 0, 0}; + body.collisionBound = bound.get(); + Mat34 m = Mat34::identity(); + m.m3 = position; + body.place(m); + } +}; + +// A kinematic box driven into a resting one at 10 m/s along +X; the struck +// box's speed afterwards. +float struckSpeed(bool reportsMotion) { + World world; + BoxBody a({1, 1, 1}, 1000.0f, {-1.1f, 5, 0}); + BoxBody b({1, 1, 1}, 100.0f, {0, 5, 0}); + a.body.kinematic = true; + a.body.resetCollider(); + a.body.kinematicMoves = reportsMotion; + a.body.kinematicVelocity = {10, 0, 0}; + world.add(&a.body); + world.add(&b.body); + constexpr float dt = 1.0f / 60.0f; + for (int i = 0; i < 12; ++i) { + Mat34 m = a.body.ics.matrix; + m.m3.x += 10.0f * dt; + a.body.place(m); + world.step(dt); + } + return b.body.ics.linearVelocity.x; +} + +} // namespace + +TEST(KinematicMotion, AMovingKinematicBodyStrikesAtItsSpeed) { + // MM2's static wall: the struck box is only pushed out of the way. + EXPECT_LT(struckSpeed(false), 1.0f); + // Moving: it takes the kinematic body's speed and more (the bounce). + EXPECT_GT(struckSpeed(true), 9.5f); +} + +TEST(KinematicMotion, ColliderVelocityIsTheReportedMotion) { + Collider c; + BoundBox box({1, 1, 1}); + Mat34 m = Mat34::identity(); + c.initStatic(&box, &m); + EXPECT_EQ(c.localVelocity({1, 2, 3}).mag2(), 0.0f); + c.moving = true; + c.motionVelocity = {1, 0, 0}; + c.motionSpin = {0, 2, 0}; // about +Y + c.motionCentre = {0, 0, 0}; + const Vec3 v = c.localVelocity({0, 0, -1}); // spin x r = (0,2,0) x (0,0,-1) = (-2,0,0) + EXPECT_NEAR(v.x, -1.0f, 1e-6f); + EXPECT_NEAR(v.y, 0.0f, 1e-6f); + EXPECT_NEAR(v.z, 0.0f, 1e-6f); + c.initStatic(&box, &m); // a new init forgets it + EXPECT_FALSE(c.moving); +} From 622dd1ca02b436c422e61f4f148ca68171c6f184 Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Thu, 8 Oct 2026 22:22:25 -0700 Subject: [PATCH 12/54] Show another player's car this machine lacks as the default car mmNetObject::Init takes a network car through mmVehList::GetVehicleInfo, which answers a name the list does not have with the default vehicle, vpcoop (mmVehList::LoadAll). OpenMM2 tried to load the name as sent and, when this machine lacked it (an add-on car), retried the load every frame, logging a warning each time, while the player's car stayed invisible and could not be hit. netVehicle gives vpcoop for a name the catalog does not have, and a car that still fails to load is tried once per car and paint job. The traffic and pedestrian densities arrive as a percent byte, which allowed up to 255 %; fromSessionSettings now keeps them at most full. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/app/RaceScreen.cpp | 12 ++++++++--- src/game/net/NetGame.cpp | 11 +++++++++-- src/game/net/NetGame.h | 7 +++++++ tests/game/test_netgame_input.cpp | 33 +++++++++++++++++++++++++++++++ 4 files changed, 58 insertions(+), 5 deletions(-) create mode 100644 tests/game/test_netgame_input.cpp diff --git a/src/app/RaceScreen.cpp b/src/app/RaceScreen.cpp index 5c40cf3..0762d8d 100644 --- a/src/app/RaceScreen.cpp +++ b/src/app/RaceScreen.cpp @@ -3332,16 +3332,20 @@ class RaceScreen final : public Screen { continue; present.push_back(rc.id); RemoteVehicle& rv = m_remotes[rc.id]; - if (rv.base != rc.car.vehicle || rv.color != rc.car.color || !rv.renderer) { + if (rv.base != rc.car.vehicle || rv.color != rc.car.color) { if (rv.sim) rv.sim->removeFrom(*m_world); rv = {}; rv.base = rc.car.vehicle; rv.color = rc.car.color; + // mmNetObject::Init takes the car through mmVehList:: + // GetVehicleInfo: one this machine lacks is the default car. + const std::string vehicle = game::netVehicle(ctx.game->catalog, rv.base); std::string error; - rv.sim = game::SimVehicle::load(ctx.game->vfs, rv.base, &error, {}, true, towing); + rv.sim = game::SimVehicle::load(ctx.game->vfs, vehicle, &error, {}, true, towing); if (!rv.sim) { - log::warn("race: network car {}: {}", rv.base, error); + // Tried once per car and paint job, not again every frame. + log::warn("race: network car {}: {}", vehicle, error); continue; } auto& sim = rv.sim->sim(); @@ -3362,6 +3366,8 @@ class RaceScreen final : public Screen { // mmNetObject::Init -> vehCar::Init -> vehSiren::vehSiren. game::VehicleRenderer::setLightGlowScales(0.2f, 0.6f); } + if (!rv.sim) + continue; auto& sim = rv.sim->sim(); // The snapshot is the model matrix; the body is at the centre of // mass (vehCarSim::SetWorldMatrix's offset). diff --git a/src/game/net/NetGame.cpp b/src/game/net/NetGame.cpp index ed96619..ef5d6f2 100644 --- a/src/game/net/NetGame.cpp +++ b/src/game/net/NetGame.cpp @@ -133,8 +133,9 @@ RaceConfig fromSessionSettings(const net::SessionSettings& s) { case net::Weather::Snow: c.weather = Weather::Snow; break; } c.weather = static_cast(std::clamp(extraInt(s, "weather", static_cast(c.weather)), 0, 4)); - c.trafficDensity = s.trafficDensity / 100.0f; - c.pedestrianDensity = s.pedDensity / 100.0f; + // Percent on the wire; the protocol's byte would allow 255. + c.trafficDensity = std::min(s.trafficDensity, 100) / 100.0f; + c.pedestrianDensity = std::min(s.pedDensity, 100) / 100.0f; c.copDensity = std::clamp(extraInt(s, "copDensity", s.cops ? 50 : 0), 0, 100) / 100.0f; if (const std::string* d = extra(s, "difficulty")) c.difficulty = *d == "pro" ? Difficulty::Professional : Difficulty::Amateur; @@ -410,6 +411,12 @@ const net::SessionSettings& NetGame::settings() const { int freeForAllTeam(const VehicleInfo* car) { return car && (car->flags & VehicleInfo::kFlagCop) ? 0 : 1; } +std::string netVehicle(const Catalog& catalog, const std::string& name) { + if (catalog.vehicle(name) || !catalog.vehicle(kDefaultVehicle)) + return name; + return kDefaultVehicle; +} + RaceConfig NetGame::raceConfig() const { RaceConfig c = fromSessionSettings(settings()); c.vehicle = m_car.vehicle; diff --git a/src/game/net/NetGame.h b/src/game/net/NetGame.h index 946894a..2054360 100644 --- a/src/game/net/NetGame.h +++ b/src/game/net/NetGame.h @@ -25,8 +25,15 @@ namespace mm2::game { +class Catalog; struct VehicleInfo; +// mmVehList::GetVehicleInfo: a car name the vehicle list does not have gives +// the default vehicle, which mmVehList::LoadAll sets to vpcoop. Another +// player's car that this machine lacks (an add-on car) is shown as that. +inline constexpr const char* kDefaultVehicle = "vpcoop"; +std::string netVehicle(const Catalog& catalog, const std::string& name); + // What the local player drives. struct NetCar { std::string vehicle = "vpbug"; // VehicleInfo::baseName diff --git a/tests/game/test_netgame_input.cpp b/tests/game/test_netgame_input.cpp new file mode 100644 index 0000000..fc8af79 --- /dev/null +++ b/tests/game/test_netgame_input.cpp @@ -0,0 +1,33 @@ +// What NetGame does with settings and car names that come from other +// machines (docs/review/multiplayer-input.md). +#include "game/Catalog.h" +#include "game/net/NetGame.h" + +#include "TestData.h" + +#include + +using namespace mm2; + +// The densities travel as percent in a byte: a host could send up to 255. +TEST(NetGameInput, DensitiesFromTheNetworkAreAtMostFull) { + net::SessionSettings s; + s.trafficDensity = 255; + s.pedDensity = 101; + const game::RaceConfig c = game::fromSessionSettings(s); + EXPECT_FLOAT_EQ(c.trafficDensity, 1.0f); + EXPECT_FLOAT_EQ(c.pedestrianDensity, 1.0f); + s.trafficDensity = 30; + EXPECT_FLOAT_EQ(game::fromSessionSettings(s).trafficDensity, 0.3f); +} + +// mmVehList::GetVehicleInfo: another player's car this machine does not have +// is shown as the default vehicle, vpcoop. +TEST(NetGameInput, UnknownRemoteCarIsTheDefaultVehicle) { + MM2_REQUIRE_GAME_DATA(); + const auto catalog = game::Catalog::load(*test::gameData()); + EXPECT_EQ(game::netVehicle(catalog, "vpbug"), "vpbug"); + EXPECT_EQ(game::netVehicle(catalog, "VPMUSTANG99"), "VPMUSTANG99"); // the list ignores case + EXPECT_EQ(game::netVehicle(catalog, "vpaddon"), "vpcoop"); + EXPECT_EQ(game::netVehicle(catalog, ""), "vpcoop"); +} From 5e227d324adc485b39a4e2624d4f2b57bdb9c112 Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Thu, 8 Oct 2026 22:22:25 -0700 Subject: [PATCH 13/54] Cut long lobby names on a character boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NetArena::AddRosterName cuts a name wider than the column to "%.6s...". OpenMM2 took six bytes, which splits a UTF-8 character (a name such as "aéééé" showed a replacement glyph). It now keeps six whole characters, as the main menu's fit() does. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/app/frontend/PagesMulti.cpp | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/app/frontend/PagesMulti.cpp b/src/app/frontend/PagesMulti.cpp index 367514b..7ea1253 100644 --- a/src/app/frontend/PagesMulti.cpp +++ b/src/app/frontend/PagesMulti.cpp @@ -773,8 +773,13 @@ class LobbyPage final : public Page { ui::drawImage(f.overlay, dot, rowX + 2 + static_cast(ready.width) + 2.0f, y); } std::string name = p.name; - if (f.text.measure(f.overlay, small, name) > 0.09f * 640.0f) - name = name.substr(0, 6) + "..."; + if (f.text.measure(f.overlay, small, name) > 0.09f * 640.0f) { + // "%.6s...": six whole UTF-8 characters, never part of one. + std::size_t end = 0; + for (int i = 0; i < 6 && end < name.size(); ++i) + ui::nextCodepoint(name, end); + name = name.substr(0, end) + "..."; + } f.text.draw(f.overlay, small, name, rowX + 26, y, color); // Cops vs. Robbers fixes the cars by team (see NetGame::raceConfig). const bool cvr = cfg.mode == GameMode::CopsAndRobbers && From 97f2fb4056a0ccc0b638a8ff0d128cbaaf515542 Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Thu, 8 Oct 2026 22:22:44 -0700 Subject: [PATCH 14/54] Drive a network race with the driver's own transmission NetGame::raceConfig() left RaceConfig::automatic at its default, so every network race was driven with the automatic gearbox whatever the driver had chosen, and the menus then saved that as the driver's choice. MM2's network session data carries no transmission (mmInterface::GetSessionData) and mmGame::Init sets vehTransmission::Automatic from the player's own state. NetCar now holds the driver's choice: the lobby takes it from the menus, the garage's PREV (applyLobbyCar) updates it, and the race and the saved event (saveNetEvent, BeDone) use it. It never travels. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/multiplayer.md | 5 ++++- src/app/frontend/FrontendScreen.cpp | 11 +++++++---- src/app/frontend/PagesMulti.cpp | 1 + src/game/net/NetGame.cpp | 1 + src/game/net/NetGame.h | 4 ++++ tests/game/test_netgame_lobby.cpp | 16 ++++++++++++++++ 6 files changed, 33 insertions(+), 5 deletions(-) diff --git a/docs/multiplayer.md b/docs/multiplayer.md index 648afa0..22a3dbd 100644 --- a/docs/multiplayer.md +++ b/docs/multiplayer.md @@ -326,7 +326,10 @@ Mustang Cruisers ... the Robber Team in Mustang GTs"): `NetGame::raceConfig()` returns `vpcop` for team 0 and `vpmustang99` for team 1. Robber Teams lets everyone choose. Free-For-All has no team lamps: the lobby sets the team from the car, 0 for a police car (flag 0x08) and 1 for any other -(`game::freeForAllTeam`, MM2's `mmMultiCR::InitMyPlayer`). +(`game::freeForAllTeam`, MM2's `mmMultiCR::InitMyPlayer`). The transmission +is each driver's own (`NetCar::automatic`, the garage's TRANSMISSION) and +never travels: MM2's session data has none, and `mmGame::Init` sets the car's +from the player's own state. ### Menus diff --git a/src/app/frontend/FrontendScreen.cpp b/src/app/frontend/FrontendScreen.cpp index 8313764..5ef7e52 100644 --- a/src/app/frontend/FrontendScreen.cpp +++ b/src/app/frontend/FrontendScreen.cpp @@ -301,10 +301,10 @@ void Frontend::unlockedNetCar() { } void Frontend::saveNetEvent() { - // BeDone for the host and every joiner: the lobby's car and paint job - // and the session's mode, race and city (copied into the state pack by - // mmInterface::GetSessionData), so the main menu shows the network event - // as LAST RACE afterwards. + // BeDone for the host and every joiner: the lobby's car, paint job and + // transmission and the session's mode, race and city (copied into the + // state pack by mmInterface::GetSessionData), so the main menu shows the + // network event as LAST RACE afterwards. if (!profile || !ctx.netGame) return; const game::RaceConfig cfg = ctx.netGame->raceConfig(); @@ -312,6 +312,7 @@ void Frontend::saveNetEvent() { game::Profile& p = *profile; p.vehicle = car.vehicle; p.vehicleColor = car.color; + p.automatic = car.automatic; p.city = cfg.city; p.mode = cfg.mode; p.raceIndex = cfg.raceIndex; @@ -326,10 +327,12 @@ void Frontend::applyLobbyCar() { game::NetCar car = ctx.netGame->localCar(); car.vehicle = config.vehicle; car.color = config.vehicleColor; + car.automatic = config.automatic; // the garage's TRANSMISSION ctx.netGame->setLocalCar(car); if (profile) { profile->vehicle = config.vehicle; profile->vehicleColor = config.vehicleColor; + profile->automatic = config.automatic; saveProfile(); } } diff --git a/src/app/frontend/PagesMulti.cpp b/src/app/frontend/PagesMulti.cpp index faef7ce..7f86f8a 100644 --- a/src/app/frontend/PagesMulti.cpp +++ b/src/app/frontend/PagesMulti.cpp @@ -64,6 +64,7 @@ game::NetCar netCar(Frontend& fe) { game::NetCar car; car.vehicle = fe.config.vehicle; car.color = fe.config.vehicleColor; + car.automatic = fe.config.automatic; if (fe.ctx.netGame) car.team = fe.ctx.netGame->localCar().team; return car; diff --git a/src/game/net/NetGame.cpp b/src/game/net/NetGame.cpp index 0a98939..311a17b 100644 --- a/src/game/net/NetGame.cpp +++ b/src/game/net/NetGame.cpp @@ -421,6 +421,7 @@ RaceConfig NetGame::raceConfig() const { RaceConfig c = fromSessionSettings(settings()); c.vehicle = m_car.vehicle; c.vehicleColor = m_car.color; + c.automatic = m_car.automatic; // "The Cop Team in Mustang Cruisers takes the gold to the bank, while the // Robber Team in Mustang GTs takes it back to the hideout" (help picture // host_cvr.jpg): Cops vs. Robbers fixes the cars by team. Robber Teams lets diff --git a/src/game/net/NetGame.h b/src/game/net/NetGame.h index 44807e6..057bd1d 100644 --- a/src/game/net/NetGame.h +++ b/src/game/net/NetGame.h @@ -32,6 +32,10 @@ struct NetCar { std::string vehicle = "vpbug"; // VehicleInfo::baseName int color = 0; // paint job int team = 0; // Cops & Robbers: 0 cops / blue, 1 robbers / red + // The driver's transmission choice. It stays on this machine: MM2's + // network session data carries no transmission, and mmGame::Init sets + // the car's from the player's own state. + bool automatic = true; }; // Cops & Robbers Free-For-All has no team buttons: the team follows the car, diff --git a/tests/game/test_netgame_lobby.cpp b/tests/game/test_netgame_lobby.cpp index 8954437..f142cd4 100644 --- a/tests/game/test_netgame_lobby.cpp +++ b/tests/game/test_netgame_lobby.cpp @@ -201,3 +201,19 @@ TEST(NetGameLobby, StaleGameEventsAreDroppedAtTheNextCountdown) { })); EXPECT_EQ(got.front().type, net::GameEventType::CheckpointReached); } + +// The driver's transmission choice reaches the network race: MM2's session +// data carries none, and mmGame::Init sets the car's from the player's own +// state. It used to be automatic for everyone. +TEST(NetGameLobby, RaceKeepsTheDriversTransmission) { + Lobby l; + ASSERT_NO_FATAL_FAILURE(l.open()); + EXPECT_TRUE(l.client.raceConfig().automatic); + game::NetCar manual = l.client.localCar(); + manual.automatic = false; + l.client.setLocalCar(manual); + EXPECT_FALSE(l.client.raceConfig().automatic); + EXPECT_TRUE(l.host.raceConfig().automatic); // each machine its own + l.host.setLocalCar({"vpcop", 0, 0, false}); + EXPECT_FALSE(l.host.raceConfig().automatic); +} From 6f774480603d9b69438c2a8656f8808fc22cd6df Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Thu, 8 Oct 2026 22:24:45 -0700 Subject: [PATCH 15/54] Give Cops vs. Robbers players their team's car on every machine In Cops vs. Robbers each machine put its own player in the team's car (vpcop for the cops, vpmustang99 for the robbers) but drew and counted the others with the car they had picked in the lobby. A robber who had picked a police car was drawn as one, and RaceScreen's Cops and Robbers took him for a cop on every machine but his own, so the gold rules disagreed. MM2's lobby sets the player's car to the team's, locks the others and sends it to the session (mmInterface, cnr_team, ChangePlayerData), so every machine sees the same car. game::raceCar now holds the rule, and NetGame applies it to every player: raceConfig() for the local car, playerCar() and remoteCars() for the others; the race's Cops and Robbers teams and the lobby roster use it. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/multiplayer.md | 9 ++++-- src/app/RaceScreen.cpp | 4 ++- src/app/frontend/PagesMulti.cpp | 6 ++-- src/game/net/NetGame.cpp | 34 +++++++++++++++++------ src/game/net/NetGame.h | 15 +++++++++- tests/game/test_netgame_lobby.cpp | 46 +++++++++++++++++++++++++++++++ 6 files changed, 96 insertions(+), 18 deletions(-) diff --git a/docs/multiplayer.md b/docs/multiplayer.md index 22a3dbd..d919bd9 100644 --- a/docs/multiplayer.md +++ b/docs/multiplayer.md @@ -322,9 +322,12 @@ mapping on a background thread). Robbers, Robber Teams), `timeLimit`, `pointLimit`, `goldMass`. `raceId` is the race index (0xFFFF for cruise and Cops & Robbers). In Cops vs. Robbers the cars are fixed by team, as the original's help text says ("The Cop Team in -Mustang Cruisers ... the Robber Team in Mustang GTs"): `NetGame::raceConfig()` -returns `vpcop` for team 0 and `vpmustang99` for team 1. Robber Teams lets -everyone choose. Free-For-All has no team lamps: the lobby sets the team +Mustang Cruisers ... the Robber Team in Mustang GTs"): `game::raceCar` gives +`vpcop` to team 0 and `vpmustang99` to team 1 (paint job 0), as MM2's lobby +sets the player's car to it and sends it to the session. Every machine +applies it to every player (`raceConfig()` for its own car, `playerCar()` and +`remoteCars()` for the others), so all of them draw the same cars and count +the same teams. Robber Teams lets everyone choose. Free-For-All has no team lamps: the lobby sets the team from the car, 0 for a police car (flag 0x08) and 1 for any other (`game::freeForAllTeam`, MM2's `mmMultiCR::InitMyPlayer`). The transmission is each driver's own (`NetCar::automatic`, the garage's TRANSMISSION) and diff --git a/src/app/RaceScreen.cpp b/src/app/RaceScreen.cpp index d79bc0d..278b9b5 100644 --- a/src/app/RaceScreen.cpp +++ b/src/app/RaceScreen.cpp @@ -2094,8 +2094,10 @@ class RaceScreen final : public Screen { }; m_cr = std::make_unique(st, *locations); m_crSelf = ctx.netGame->localId(); + // Every machine counts each player with the car it drives + // (NetGame::playerCar: Cops vs. Robbers' cars by team). for (const auto& p : ctx.netGame->players()) { - m_cr->addCar(p.id, crTeam(ctx, p.id == m_crSelf ? m_result.config.vehicle : p.car, p.team)); + m_cr->addCar(p.id, crTeam(ctx, ctx.netGame->playerCar(p.id).vehicle, p.team)); m_crPlayers.insert(p.id); } m_crMyTeam = m_cr->teamOf(m_crSelf); diff --git a/src/app/frontend/PagesMulti.cpp b/src/app/frontend/PagesMulti.cpp index 7f86f8a..6ceb7a6 100644 --- a/src/app/frontend/PagesMulti.cpp +++ b/src/app/frontend/PagesMulti.cpp @@ -779,10 +779,8 @@ class LobbyPage final : public Page { if (f.text.measure(f.overlay, small, name) > 0.09f * 640.0f) name = name.substr(0, 6) + "..."; f.text.draw(f.overlay, small, name, rowX + 26, y, color); - // Cops vs. Robbers fixes the cars by team (see NetGame::raceConfig). - const bool cvr = cfg.mode == GameMode::CopsAndRobbers && - cfg.copsAndRobbers == game::CopsAndRobbersMode::CopsVsRobbers; - const std::string car = vehicleName(fe, cvr ? (p.team == 0 ? "vpcop" : "vpmustang99") : p.car); + // Cops vs. Robbers fixes the cars by team (game::raceCar). + const std::string car = vehicleName(fe, net.playerCar(p.id).vehicle); f.text.draw(f.overlay, small, car, rowX + 26 + rowW / 3.0f, y, color); y += lh; } diff --git a/src/game/net/NetGame.cpp b/src/game/net/NetGame.cpp index 311a17b..753be12 100644 --- a/src/game/net/NetGame.cpp +++ b/src/game/net/NetGame.cpp @@ -417,22 +417,37 @@ const net::SessionSettings& NetGame::settings() const { int freeForAllTeam(const VehicleInfo* car) { return car && (car->flags & VehicleInfo::kFlagCop) ? 0 : 1; } -RaceConfig NetGame::raceConfig() const { - RaceConfig c = fromSessionSettings(settings()); - c.vehicle = m_car.vehicle; - c.vehicleColor = m_car.color; - c.automatic = m_car.automatic; +NetCar raceCar(const RaceConfig& race, NetCar lobby) { // "The Cop Team in Mustang Cruisers takes the gold to the bank, while the // Robber Team in Mustang GTs takes it back to the hideout" (help picture // host_cvr.jpg): Cops vs. Robbers fixes the cars by team. Robber Teams lets // everyone choose (host_rt.jpg). - if (c.mode == GameMode::CopsAndRobbers && c.copsAndRobbers == CopsAndRobbersMode::CopsVsRobbers) { - c.vehicle = m_car.team == 0 ? "vpcop" : "vpmustang99"; - c.vehicleColor = 0; + if (race.mode == GameMode::CopsAndRobbers && race.copsAndRobbers == CopsAndRobbersMode::CopsVsRobbers) { + lobby.vehicle = lobby.team == 0 ? "vpcop" : "vpmustang99"; + lobby.color = 0; } + return lobby; +} + +RaceConfig NetGame::raceConfig() const { + RaceConfig c = fromSessionSettings(settings()); + const NetCar car = raceCar(c, m_car); + c.vehicle = car.vehicle; + c.vehicleColor = car.color; + c.automatic = car.automatic; return c; } +NetCar NetGame::playerCar(std::uint8_t playerId) const { + const RaceConfig race = fromSessionSettings(settings()); + if (playerId == localId()) + return raceCar(race, m_car); + const net::PlayerInfo* p = player(playerId); + if (!p) + return {}; + return raceCar(race, {p->car, p->color, p->team}); +} + int NetGame::maxPlayers() const { return settings().maxPlayers; } bool NetGame::hasPassword() const { return settings().hasPassword; } @@ -596,13 +611,14 @@ std::vector NetGame::remoteCars() const { std::vector out; if (!m_impl->session) return out; + const RaceConfig race = fromSessionSettings(settings()); for (const auto& p : players()) { if (p.id == localId()) continue; NetRemoteCar car; car.id = p.id; car.name = p.name; - car.car = {p.car, p.color, p.team}; + car.car = raceCar(race, {p.car, p.color, p.team}); net::VehicleSnapshot snap; const auto r = m_impl->session->sampleRemote(p.id, snap); if (r != net::SnapshotBuffer::Result::Empty) { diff --git a/src/game/net/NetGame.h b/src/game/net/NetGame.h index 057bd1d..b01d92a 100644 --- a/src/game/net/NetGame.h +++ b/src/game/net/NetGame.h @@ -38,6 +38,14 @@ struct NetCar { bool automatic = true; }; +// The car a player drives in the race `race` describes, given the car the +// player chose in the lobby. Cops vs. Robbers gives every cop (team 0) vpcop +// and every robber vpmustang99, paint job 0: MM2's lobby sets the player's +// car to it, locks every other car and sends it to the session +// (mmInterface, under the cnr_team test, then ChangePlayerData). Every other +// game keeps the lobby's car. +NetCar raceCar(const RaceConfig& race, NetCar lobby); + // Cops & Robbers Free-For-All has no team buttons: the team follows the car, // 0 for a police car (VehicleInfo::kFlagCop) and 1 for any other // (mmMultiCR::InitMyPlayer; mmInterface::ChangePlayerData tests for vpcop). @@ -71,7 +79,7 @@ struct NetChatLine { struct NetRemoteCar { std::uint8_t id = net::kInvalidPlayerId; std::string name; - NetCar car; + NetCar car; // the car it drives (raceCar) Mat34 transform; // model space -> world (car model origin, Angel conventions) Vec3 velocity; Vec3 angularVelocity; @@ -162,7 +170,12 @@ class NetGame { const net::SessionSettings& settings() const; // The race everyone will drive, with the local player's car filled in. RaceConfig raceConfig() const; + // The car the local player chose in the lobby. NetCar localCar() const { return m_car; } + // The car a player (this one or another) drives in the race the current + // settings describe (raceCar); the transmission is known for the local + // player only. + NetCar playerCar(std::uint8_t playerId) const; int maxPlayers() const; bool hasPassword() const; int goldMass() const; // 0..kGoldMassChoices-1 diff --git a/tests/game/test_netgame_lobby.cpp b/tests/game/test_netgame_lobby.cpp index f142cd4..e0b37c2 100644 --- a/tests/game/test_netgame_lobby.cpp +++ b/tests/game/test_netgame_lobby.cpp @@ -202,6 +202,52 @@ TEST(NetGameLobby, StaleGameEventsAreDroppedAtTheNextCountdown) { EXPECT_EQ(got.front().type, net::GameEventType::CheckpointReached); } +// Cops vs. Robbers gives every cop vpcop and every robber vpmustang99 (MM2's +// lobby sets the player's car to it and sends it to the session). Every +// machine must see the same cars, and so the same teams: a robber whose lobby +// car is a police car used to be drawn as that car, and taken for a cop, by +// the others. +TEST(NetGameLobby, CopsVsRobbersCarsAreTheSameOnEveryMachine) { + Lobby l; + ASSERT_NO_FATAL_FAILURE(l.open()); + l.client.setLocalCar({"vpcop", 3, 1}); // a robber who picked a police car + auto cfg = cruise(); + cfg.mode = game::GameMode::CopsAndRobbers; + cfg.copsAndRobbers = game::CopsAndRobbersMode::CopsVsRobbers; + l.host.setRaceConfig(cfg); + ASSERT_TRUE(pump({&l.host, &l.client}, [&] { + const auto* p = l.host.player(l.client.localId()); + return p && p->car == "vpcop" && l.client.raceConfig().mode == game::GameMode::CopsAndRobbers; + })); + const std::uint8_t robber = l.client.localId(); + EXPECT_EQ(l.client.raceConfig().vehicle, "vpmustang99"); + EXPECT_EQ(l.client.raceConfig().vehicleColor, 0); + EXPECT_EQ(l.host.raceConfig().vehicle, "vpcop"); // the host is a cop (team 0) + // What the others draw and count. + EXPECT_EQ(l.host.playerCar(robber).vehicle, "vpmustang99"); + EXPECT_EQ(l.client.playerCar(robber).vehicle, "vpmustang99"); + EXPECT_EQ(l.client.playerCar(net::kHostPlayerId).vehicle, "vpcop"); + ASSERT_NO_FATAL_FAILURE(l.start()); + l.client.submitLocalState(Mat34::identity(), {}, {}, {}, 0.0f, 0); + ASSERT_TRUE(pump({&l.host, &l.client}, [&] { + const auto cars = l.host.remoteCars(); + return cars.size() == 1 && cars[0].hasState; + })); + EXPECT_EQ(l.host.remoteCars()[0].car.vehicle, "vpmustang99"); + EXPECT_EQ(l.host.remoteCars()[0].car.color, 0); + + // Robber Teams and Free-For-All keep the lobby's car. + cfg.copsAndRobbers = game::CopsAndRobbersMode::RobberTeams; + l.host.returnToLobby(); + l.host.setRaceConfig(cfg); + ASSERT_TRUE(pump({&l.host, &l.client}, [&] { + return l.client.raceConfig().copsAndRobbers == game::CopsAndRobbersMode::RobberTeams; + })); + EXPECT_EQ(l.client.raceConfig().vehicle, "vpcop"); + EXPECT_EQ(l.host.playerCar(robber).vehicle, "vpcop"); + EXPECT_EQ(l.host.playerCar(robber).color, 3); +} + // The driver's transmission choice reaches the network race: MM2's session // data carries none, and mmGame::Init sets the car's from the player's own // state. It used to be automatic for everyone. From 810f5a9161404f36f03eefeb04957e6bb3792235 Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Thu, 8 Oct 2026 22:25:52 -0700 Subject: [PATCH 16/54] Fuzz the network decoders and session handlers, refuse empty packets A fixed-seed fuzz test feeds random and mutated messages, LAN packets, router replies and event payloads to every decoder, checking that what decodes stays within the limits the writer could produce. Two more drive a live host (from a joined peer, a peer that never said Hello and raw datagrams below ENet) and a live client (from a fake host) with mutated traffic and check that the session stays consistent. It found that Transport::send passed a zero-length packet to ENet, whose range coder then reads the packet's null data and crashes the sender. Every message has at least its type byte, so send now refuses an empty one. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/net/Transport.cpp | 4 +- tests/net/test_fuzz.cpp | 525 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 528 insertions(+), 1 deletion(-) create mode 100644 tests/net/test_fuzz.cpp diff --git a/src/net/Transport.cpp b/src/net/Transport.cpp index 819e852..da0e782 100644 --- a/src/net/Transport.cpp +++ b/src/net/Transport.cpp @@ -183,7 +183,9 @@ void Transport::service(std::vector& out, std::uint32_t timeoutM bool Transport::send(PeerId peer, Channel channel, std::span data) { ENetPeer* p = find(peer); - if (!p || p->state != ENET_PEER_STATE_CONNECTED) + // Every message has at least its type byte. ENet's range coder reads a + // zero-length packet's (null) data and crashes the sender. + if (!p || p->state != ENET_PEER_STATE_CONNECTED || data.empty()) return false; ENetPacket* packet = enet_packet_create(data.data(), data.size(), packetFlags(channel)); if (!packet) diff --git a/tests/net/test_fuzz.cpp b/tests/net/test_fuzz.cpp new file mode 100644 index 0000000..a5f5897 --- /dev/null +++ b/tests/net/test_fuzz.cpp @@ -0,0 +1,525 @@ +// Fuzz-style robustness: random and mutated messages through every decoder +// and through live Session handlers, with fixed seeds so a failure always +// reproduces. Nothing may crash, read out of bounds (run test_net under +// AddressSanitizer/UBSan to check that) or produce a value outside what the +// writer could have produced. See docs/review/multiplayer-input.md. +#include "net/Discovery.h" +#include "net/NatPmp.h" +#include "net/Session.h" + +#include + +#include +#include +#include +#include +#include + +using namespace mm2; +using namespace mm2::net; + +namespace { + +using Bytes = std::vector; + +Bytes bytesOf(std::initializer_list v) { + Bytes out; + for (int b : v) + out.push_back(static_cast(b)); + return out; +} + +VehicleSnapshot sampleSnapshot(std::uint32_t time) { + VehicleSnapshot s; + s.time = time; + s.position = {123.5f, -4.0f, 900.25f}; + s.orientation = Quat::fromAxisAngle(Vec3{0.0f, 1.0f, 0.0f}, 0.7f); + s.linearVelocity = {12.0f, 0.5f, -3.0f}; + s.angularVelocity = {0.1f, 1.5f, 0.0f}; + s.controls = {0.3f, 1.0f, 0.0f, 0.0f, 3}; + s.damage = 0.25f; + s.flags = kVehicleHeadlights; + return s; +} + +PlayerInfo samplePlayer(std::uint8_t id) { + PlayerInfo p; + p.id = id; + p.name = std::format("Player {}", id); + p.car = "vpmustang99"; + p.color = 3; + p.team = 1; + p.ready = true; + p.ping = 42; + return p; +} + +SessionSettings sampleSettings() { + SessionSettings s; + s.name = "Fuzz session"; + s.city = "sf"; + s.mode = GameMode::CopsAndRobbers; + s.raceId = 2; + for (int i = 0; i < 6; ++i) + s.extra.emplace_back(std::format("key{}", i), std::format("value {}", i * 7)); + return s; +} + +// One valid encoding of every message, LAN packet, router reply and event payload. +std::vector corpus() { + std::vector c; + c.push_back(encodeMessage(ChallengeMsg{{std::byte{1}, std::byte{2}}, true})); + HelloMsg hello; + hello.build = "OpenMM2 test"; + hello.name = "Fuzzer"; + hello.car = "vpbug"; + hello.passwordProof[5] = std::byte{9}; + c.push_back(encodeMessage(hello)); + WelcomeMsg w; + w.yourId = 3; + w.settings = sampleSettings(); + for (std::uint8_t i = 0; i < 8; ++i) + w.players.push_back(samplePlayer(i)); + w.phase = SessionPhase::Countdown; + w.hostTime = 123456; + w.countdownEnd = 130000; + c.push_back(encodeMessage(w)); + c.push_back(encodeMessage(RejectMsg{DisconnectReason::ServerFull, "full"})); + c.push_back(encodeMessage(PlayerJoinedMsg{samplePlayer(4)})); + c.push_back(encodeMessage(PlayerLeftMsg{4, DisconnectReason::Timeout})); + c.push_back(encodeMessage(PlayerUpdateMsg{samplePlayer(2)})); + c.push_back(encodeMessage(PlayerRequestMsg{"vpcoop", 4, 1, true})); + c.push_back(encodeMessage(ChatMsg{1, "hello there, é"})); + c.push_back(encodeMessage(SettingsMsg{sampleSettings()})); + c.push_back(encodeMessage(CountdownMsg{99999})); + c.push_back(encodeMessage(ReturnToLobbyMsg{})); + c.push_back(encodeMessage(KickMsg{"bye"})); + c.push_back(encodeMessage(TimeRequestMsg{777})); + c.push_back(encodeMessage(TimeResponseMsg{777, 888})); + c.push_back(encodeMessage(VehicleStateMsg{sampleSnapshot(5000)})); + WorldStateMsg world; + for (std::uint8_t i = 0; i < kMaxPlayers; ++i) + world.vehicles.emplace_back(i, sampleSnapshot(5000 + i)); + c.push_back(encodeMessage(world)); + GameEventMsg ev; + ev.from = 1; + ev.type = static_cast(GameEventType::GoldDropped); + ev.time = 4242; + ev.payload = encodePayload(GoldEvent{{1.0f, 2.0f, 3.0f}, 1}); + c.push_back(encodeMessage(ev)); + PlayerPingsMsg pings; + pings.pings = {{1, 30}, {2, 60}}; + c.push_back(encodeMessage(pings)); + + LanAdvert advert; + advert.sessionName = "Fuzz"; + advert.hostName = "Host"; + advert.city = "london"; + advert.players = 2; + advert.maxPlayers = 8; + advert.build = "OpenMM2"; + c.push_back(encodeLanQuery(0x12345678)); + c.push_back(encodeLanAdvert(0x12345678, advert)); + + c.push_back(encodePayload(CheckpointEvent{3, 1000})); + c.push_back(encodePayload(LapEvent{2, 60000})); + c.push_back(encodePayload(FinishEvent{90000, 1})); + c.push_back(encodePayload(CollisionEvent{2, {1, 2, 3}, 50.0f})); + c.push_back(encodePayload(DamageEvent{0.5f, 3})); + + // A PCP MAP success (60 bytes) and NAT-PMP replies. + Bytes pcp(60, std::byte{0}); + pcp[0] = std::byte{2}; + pcp[1] = std::byte{0x81}; + pcp[36] = std::byte{17}; + pcp[40] = std::byte{0x08}; + pcp[41] = std::byte{0xFC}; + c.push_back(pcp); + c.push_back(bytesOf({0, 0x80, 0, 0, 0, 0, 1, 0, 203, 0, 113, 7})); + c.push_back(bytesOf({0, 0x81, 0, 0, 0, 0, 1, 0, 0x08, 0xFC, 0x08, 0xFC, 0, 0, 0x0E, 0x10})); + return c; +} + +Bytes mutate(const std::vector& corpus, std::mt19937& rng) { + auto pick = [&](std::size_t n) { return std::uniform_int_distribution(0, n - 1)(rng); }; + auto randomByte = [&] { return static_cast(rng() & 0xFF); }; + Bytes b = corpus[pick(corpus.size())]; + const int rounds = 1 + static_cast(pick(4)); + for (int r = 0; r < rounds; ++r) { + switch (pick(8)) { + case 0: // flip bits + for (std::size_t i = 0, n = 1 + pick(8); i < n && !b.empty(); ++i) + b[pick(b.size())] ^= static_cast(1u << pick(8)); + break; + case 1: // overwrite bytes + for (std::size_t i = 0, n = 1 + pick(4); i < n && !b.empty(); ++i) + b[pick(b.size())] = randomByte(); + break; + case 2: // truncate + if (!b.empty()) + b.resize(pick(b.size())); + break; + case 3: // append + for (std::size_t i = 0, n = 1 + pick(64); i < n; ++i) + b.push_back(randomByte()); + break; + case 4: // insert + for (std::size_t i = 0, n = 1 + pick(8); i < n; ++i) + b.insert(b.begin() + static_cast(pick(b.size() + 1)), randomByte()); + break; + case 5: { // splice with another sample + const Bytes& o = corpus[pick(corpus.size())]; + if (!b.empty() && !o.empty()) { + b.resize(pick(b.size())); + b.insert(b.end(), o.begin() + static_cast(pick(o.size())), o.end()); + } + break; + } + case 6: // another message type + if (!b.empty()) + b[0] = static_cast(pick(24)); + break; + default: // pure noise + b.resize(pick(300)); + for (auto& x : b) + x = randomByte(); + break; + } + } + return b; +} + +void checkSnapshot(const VehicleSnapshot& s) { + for (float v : {s.position.x, s.position.y, s.position.z, s.linearVelocity.x, s.linearVelocity.y, + s.linearVelocity.z, s.angularVelocity.x, s.angularVelocity.y, s.angularVelocity.z}) + ASSERT_TRUE(std::isfinite(v)); + ASSERT_LE(std::abs(s.position.x), kSnapshotPositionRange); + ASSERT_LE(std::abs(s.linearVelocity.y), kSnapshotVelocityRange); + ASSERT_LE(std::abs(s.angularVelocity.z), kSnapshotAngularRange); + const float n = std::sqrt(s.orientation.x * s.orientation.x + s.orientation.y * s.orientation.y + + s.orientation.z * s.orientation.z + s.orientation.w * s.orientation.w); + ASSERT_NEAR(n, 1.0f, 1e-3f); + ASSERT_GE(s.controls.steering, -1.0f); + ASSERT_LE(s.controls.steering, 1.0f); + ASSERT_GE(s.controls.gear, -1); + ASSERT_LE(s.controls.gear, 14); + ASSERT_GE(s.damage, 0.0f); + ASSERT_LE(s.damage, 1.0f); +} + +void checkPlayer(const PlayerInfo& p) { + ASSERT_LE(p.name.size(), kMaxNameLength); + ASSERT_LE(p.car.size(), kMaxShortStringLength); +} + +void checkSettings(const SessionSettings& s) { + ASSERT_LE(s.name.size(), kMaxNameLength * 2); + ASSERT_LE(s.city.size(), kMaxShortStringLength); + ASSERT_LE(s.mode, GameMode::Last); + ASSERT_LE(s.timeOfDay, TimeOfDay::Last); + ASSERT_LE(s.weather, Weather::Last); + ASSERT_GE(s.maxPlayers, 1); + ASSERT_LE(s.maxPlayers, kMaxPlayers); + ASSERT_LE(s.extra.size(), kMaxExtraSettings); + for (const auto& [k, v] : s.extra) { + ASSERT_LE(k.size(), kMaxShortStringLength); + ASSERT_LE(v.size(), kMaxExtraValueLength); + } +} + +template +std::optional decoded(std::span b) { + M m{}; + if (!decodeMessage(b, m)) + return std::nullopt; + return m; +} + +template +std::optional payload(std::span b) { + E e{}; + if (!decodePayload(b, e)) + return std::nullopt; + return e; +} + +// Every decoder on one buffer; whatever decodes must be within the limits. +void decodeEverything(std::span b) { + (void)peekMessageType(b); + (void)decoded(b); + (void)decoded(b); + (void)decoded(b); + (void)decoded(b); + (void)decoded(b); + if (const auto m = decoded(b)) { + ASSERT_LE(m->build.size(), kMaxShortStringLength); + ASSERT_LE(m->name.size(), kMaxNameLength); + ASSERT_LE(m->car.size(), kMaxShortStringLength); + } + if (const auto m = decoded(b)) { + checkSettings(m->settings); + ASSERT_LE(m->players.size(), kMaxPlayers); + for (const auto& p : m->players) + checkPlayer(p); + ASSERT_LE(m->phase, SessionPhase::Last); + } + if (const auto m = decoded(b)) { + ASSERT_LE(m->reason, DisconnectReason::Last); + ASSERT_LE(m->message.size(), kMaxReasonLength); + } + if (const auto m = decoded(b)) { + checkPlayer(m->player); + } + if (const auto m = decoded(b)) { + ASSERT_LE(m->reason, DisconnectReason::Last); + } + if (const auto m = decoded(b)) { + checkPlayer(m->player); + } + if (const auto m = decoded(b)) { + ASSERT_LE(m->car.size(), kMaxShortStringLength); + } + if (const auto m = decoded(b)) { + ASSERT_LE(m->text.size(), kMaxChatLength); + } + if (const auto m = decoded(b)) { + checkSettings(m->settings); + } + if (const auto m = decoded(b)) { + ASSERT_LE(m->reason.size(), kMaxReasonLength); + } + if (const auto m = decoded(b)) { + checkSnapshot(m->state); + } + if (const auto m = decoded(b)) { + ASSERT_LE(m->vehicles.size(), kMaxPlayers); + for (const auto& [id, s] : m->vehicles) + checkSnapshot(s); + } + if (const auto m = decoded(b)) { + ASSERT_LE(m->payload.size(), kMaxEventPayload); + } + if (const auto m = decoded(b)) { + ASSERT_LE(m->pings.size(), kMaxPlayers); + } + + std::uint32_t nonce = 0; + (void)decodeLanQuery(b, nonce); + if (LanAdvert a; decodeLanAdvert(b, nonce, a)) { + ASSERT_LE(a.sessionName.size(), kMaxNameLength * 2); + ASSERT_LE(a.city.size(), kMaxShortStringLength); + ASSERT_LE(a.mode, GameMode::Last); + } + (void)natpmp::decodePcpResponse(b); + (void)natpmp::decodeNatPmpResponse(b); + + (void)payload(b); + (void)payload(b); + (void)payload(b); + if (const auto e = payload(b)) { + ASSERT_TRUE(std::isfinite(e->position.x) && std::isfinite(e->position.y) && std::isfinite(e->position.z)); + } + if (const auto e = payload(b)) { + ASSERT_TRUE(std::isfinite(e->impulse) && std::isfinite(e->position.x)); + } + if (const auto e = payload(b)) { + ASSERT_TRUE(std::isfinite(e->damage)); + } + + // Text cleanup on arbitrary bytes. + const std::string_view text(reinterpret_cast(b.data()), b.size()); + const std::string clean = sanitizeText(text, kMaxChatLength); + ASSERT_LE(clean.size(), kMaxChatLength); + ASSERT_EQ(sanitizeText(clean, kMaxChatLength), clean); // idempotent: already clean +} + +bool pumpFor(int ms, const std::function& step) { + const auto end = std::chrono::steady_clock::now() + std::chrono::milliseconds(ms); + while (std::chrono::steady_clock::now() < end) { + step(); + std::this_thread::sleep_for(std::chrono::milliseconds(1)); + } + return true; +} + +template +std::optional takeMessage(std::vector& events) { + for (auto it = events.begin(); it != events.end(); ++it) { + M m; + if (it->type == TransportEvent::Type::Received && decodeMessage(it->payload, m)) { + events.erase(it); + return m; + } + } + return std::nullopt; +} + +} // namespace + +TEST(Fuzz, DecodersSurviveMutatedMessages) { + const auto c = corpus(); + for (const auto& b : c) + decodeEverything(b); // the valid samples themselves + std::mt19937 rng(20261008); + for (int i = 0; i < 40000; ++i) { + const Bytes b = mutate(c, rng); + decodeEverything(b); + if (HasFatalFailure()) { + ADD_FAILURE() << "iteration " << i; + return; + } + } +} + +// A host keeps serving its session while a joined client and a peer that +// never said Hello send it mutated messages. +TEST(Fuzz, HostSurvivesMutatedTraffic) { + SessionConfig cfg; + cfg.joinTimeoutMs = 60000; + Session host(cfg); + HostParams hp; + hp.bind = Address::loopback(0); + hp.advertiseOnLan = false; + hp.player.name = "Host"; + ASSERT_TRUE(host.host(hp)); + + // `joined` completes the handshake; `stranger` stays before Hello. + Transport joined, stranger; + ASSERT_TRUE(joined.startClient()); + ASSERT_TRUE(stranger.startClient()); + const PeerId toHost = joined.connect(Address::loopback(host.port())); + const PeerId strangerToHost = stranger.connect(Address::loopback(host.port())); + std::vector events, strangerEvents; + std::optional challenge; + pumpFor(300, [&] { + host.update(); + joined.service(events); + stranger.service(strangerEvents); + if (!challenge) + challenge = takeMessage(events); + }); + ASSERT_TRUE(challenge); + HelloMsg hello; + hello.name = "Fuzzer"; + hello.car = "vpbug"; + joined.send(toHost, Channel::Control, encodeMessage(hello)); + pumpFor(200, [&] { + host.update(); + joined.service(events); + stranger.service(strangerEvents); + }); + ASSERT_EQ(host.players().size(), 2u); + + // And raw datagrams at the game port, below ENet (its own headers, + // checksums-free framing and range-coder decompression). + auto raw = UdpSocket::open(Address::loopback(0), false, false); + ASSERT_TRUE(raw); + + const auto c = corpus(); + std::mt19937 rng(4242); + for (int i = 0; i < 3000; ++i) { + const Bytes b = mutate(c, rng); + const auto channel = static_cast(rng() % kChannelCount); + joined.send(toHost, channel, b); + if (i % 4 == 0) + stranger.send(strangerToHost, channel, b); + if (i % 3 == 0) + raw->sendTo(Address::loopback(host.port()), b); + if (i % 50 == 0) { + host.update(); + joined.service(events); + stranger.service(strangerEvents); + events.clear(); + strangerEvents.clear(); + } + } + pumpFor(300, [&] { + host.update(); + joined.service(events); + stranger.service(strangerEvents); + events.clear(); + strangerEvents.clear(); + }); + EXPECT_EQ(host.state(), Session::State::Active); + EXPECT_LE(host.players().size(), static_cast(host.settings().maxPlayers)); + for (const auto& p : host.players()) { + EXPECT_TRUE(isValidAssetName(p.car)) << p.car; + EXPECT_EQ(sanitizeText(p.name, kMaxNameLength), p.name); + } + for (auto& e : host.takeEvents()) { + if (const auto* chat = std::get_if(&e)) { + EXPECT_EQ(sanitizeText(chat->text, kMaxChatLength), chat->text); + } + } +} + +// A client keeps a consistent view while a host sends it mutated messages +// (except the ones that end the session). +TEST(Fuzz, ClientSurvivesMutatedTraffic) { + Transport fakeHost; + ASSERT_TRUE(fakeHost.listen(Address::loopback(0))); + Session client; + JoinParams jp; + jp.host = Address::loopback(fakeHost.port()); + jp.player.name = "Victim"; + ASSERT_TRUE(client.join(jp)); + std::vector events; + PeerId peer = kInvalidPeer; + pumpFor(200, [&] { + client.update(); + fakeHost.service(events); + for (const auto& e : events) + if (e.type == TransportEvent::Type::Connected) + peer = e.peer; + }); + ASSERT_NE(peer, kInvalidPeer); + fakeHost.send(peer, Channel::Control, encodeMessage(ChallengeMsg{})); + pumpFor(100, [&] { + client.update(); + fakeHost.service(events); + }); + WelcomeMsg w; + w.yourId = 1; + w.players = {samplePlayer(0), samplePlayer(1)}; + fakeHost.send(peer, Channel::Control, encodeMessage(w)); + pumpFor(100, [&] { + client.update(); + fakeHost.service(events); + }); + ASSERT_EQ(client.state(), Session::State::Active); + + const auto c = corpus(); + std::mt19937 rng(777); + for (int i = 0; i < 3000; ++i) { + Bytes b = mutate(c, rng); + if (const auto t = peekMessageType(b); t == MsgType::Kick || t == MsgType::Reject) + b[0] = static_cast(MsgType::Chat); + fakeHost.send(peer, static_cast(rng() % kChannelCount), b); + if (i % 50 == 0) { + client.update(); + fakeHost.service(events); + events.clear(); + } + } + pumpFor(300, [&] { + client.update(); + fakeHost.service(events); + events.clear(); + }); + EXPECT_EQ(client.state(), Session::State::Active); + EXPECT_LE(client.players().size(), kMaxPlayers); + EXPECT_EQ(client.player(kInvalidPlayerId), nullptr); + for (const auto& p : client.players()) { + EXPECT_TRUE(isValidAssetName(p.car)) << p.car; + EXPECT_EQ(sanitizeText(p.name, kMaxNameLength), p.name); + } + const auto& s = client.settings(); + EXPECT_TRUE(s.city.empty() || isValidAssetName(s.city)) << s.city; + EXPECT_EQ(sanitizeText(s.name, kMaxNameLength * 2), s.name); + VehicleSnapshot snap; + for (int id = 0; id < 256; ++id) { + if (client.sampleRemote(static_cast(id), snap) != SnapshotBuffer::Result::Empty) { + checkSnapshot(snap); + } + } +} From 5ac76a6b3b049c3d53eec2d54bbb221c9836e1f9 Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Thu, 8 Oct 2026 22:26:43 -0700 Subject: [PATCH 17/54] Find chat lines by serial so the lobby and race show the right ones NetGame keeps the last 64 chat lines. The lobby remembered how many there were when it opened (NetArena::ResetGameChat) and the race how many it had posted, both as indices into that list: once it was full the indices stopped moving, and neither showed a new line again for the rest of the session. The race also started counting at 0, so its first frame posted every line typed in the lobby on the HUD (seen at the start of a cruise), with a network alert for each of the others' lines. Every line now has a serial; the lobby shows the lines from the serial it opened at, and the race posts the lines from the serial it started at. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/app/RaceScreen.cpp | 16 +++++++--------- src/app/frontend/PagesMulti.cpp | 15 +++++++++------ src/game/net/NetGame.cpp | 11 ++++++----- src/game/net/NetGame.h | 8 ++++++++ tests/game/test_netgame_lobby.cpp | 27 +++++++++++++++++++++++++++ 5 files changed, 57 insertions(+), 20 deletions(-) diff --git a/src/app/RaceScreen.cpp b/src/app/RaceScreen.cpp index 278b9b5..2cc5b0f 100644 --- a/src/app/RaceScreen.cpp +++ b/src/app/RaceScreen.cpp @@ -104,8 +104,10 @@ class RaceScreen final : public Screen { m_result.config = config; // The network race this screen runs (FrontendScreen starts it when // the countdown arrives). - if (config.multiplayer && ctx.netGame) + if (config.multiplayer && ctx.netGame) { m_netRace = ctx.netGame->raceNumber(); + m_chatSeen = ctx.netGame->chatSerial(); // the lobby's lines stay there + } // GetLoadScreenName: _.jpg (cruise "roam" and Cops and // Robbers "multicop" without a number), else the generic one. const std::string prefix = modePrefix(config.mode); @@ -1981,12 +1983,8 @@ class RaceScreen final : public Screen { void postIncomingChat(Context& ctx) { if (!multiplayer(ctx) || !m_hud) return; - const auto& lines = ctx.netGame->chat(); - if (m_chatSeen > lines.size()) - m_chatSeen = 0; - for (std::size_t i = m_chatSeen; i < lines.size(); ++i) { - const auto& line = lines[i]; - if (line.system || line.text.starts_with("/wav")) + for (const auto& line : ctx.netGame->chat()) { + if (line.serial < m_chatSeen || line.system || line.text.starts_with("/wav")) continue; const bool own = line.from == ctx.netGame->localId(); m_hud->postChat(own ? line.text : std::format("{}: {}", line.name, line.text)); @@ -1995,7 +1993,7 @@ class RaceScreen final : public Screen { if (!own) playGameSound(ctx, game::session::GameSound::NetAlert, 0.0f); } - m_chatSeen = lines.size(); + m_chatSeen = ctx.netGame->chatSerial(); } // mmMultiRoam / Race / Circuit / Blitz / CR::SystemMessage 0x2d: a @@ -3917,7 +3915,7 @@ class RaceScreen final : public Screen { std::set m_crPlayers; // the players last frame (who left) bool m_regen = false; // mmPlayer::EnableRegen float m_throttleCap = 1.0f; // mmGame +0x40c - std::size_t m_chatSeen = 0; // chat lines already posted on the HUD + std::uint64_t m_chatSeen = 0; // NetChatLine::serial of the next line to post bool m_textInput = false; // SDL text input on for the chat line std::optional m_progress; // the reward rules (loaded at the first finish) const vfs::Vfs* m_vfs = nullptr; diff --git a/src/app/frontend/PagesMulti.cpp b/src/app/frontend/PagesMulti.cpp index 6ceb7a6..d8d853b 100644 --- a/src/app/frontend/PagesMulti.cpp +++ b/src/app/frontend/PagesMulti.cpp @@ -602,7 +602,7 @@ class LobbyPage final : public Page { const auto& l = fe.layout; // NetArena::ResetGameChat: entering the lobby (after hosting, // joining or a race) starts an empty chat log. - m_chatStart = net.chat().size(); + m_chatStart = net.chatSerial(); menu.add(l.widget(id, 0, {274, 274, 355, 20}), [&fe](const std::string& text) { if (fe.ctx.netGame) @@ -807,11 +807,14 @@ class LobbyPage final : public Page { { const Vec4 clip{274, 300, 355, 66}; f.overlay.setClip(&clip); - const auto& chat = net.chat(); - const std::size_t from = std::min(chat.size(), std::max(m_chatStart, chat.size() > 3 ? chat.size() - 3 : 0)); + std::vector lines; + for (const auto& c : net.chat()) + if (c.serial >= m_chatStart) + lines.push_back(&c); + const std::size_t from = lines.size() > 3 ? lines.size() - 3 : 0; float y = 300; - for (std::size_t i = from; i < chat.size(); ++i, y += step) { - const auto& c = chat[i]; + for (std::size_t i = from; i < lines.size(); ++i, y += step) { + const auto& c = *lines[i]; const std::string text = c.system ? " " + c.text : std::format(" {}> {}", c.name, c.text); f.text.draw(f.overlay, font, text, 274, y, ui::style::kValueText); } @@ -939,7 +942,7 @@ class LobbyPage final : public Page { ui::Picture* m_map = nullptr; std::string m_mapPath; std::string m_settingsKey; - std::size_t m_chatStart = 0; + std::uint64_t m_chatStart = 0; // NetGame::chatSerial() on entering }; // --- Host settings (host_bk) ----------------------------------------------------------------- diff --git a/src/game/net/NetGame.cpp b/src/game/net/NetGame.cpp index 753be12..d60c793 100644 --- a/src/game/net/NetGame.cpp +++ b/src/game/net/NetGame.cpp @@ -292,7 +292,7 @@ void NetGame::handleEvents() { ev.reason == net::DisconnectReason::Kicked ? "has been ejected" : "has left")); // string 69 } else if constexpr (std::is_same_v) { - m_chat.push_back({ev.from, playerName(ev.from), ev.text, false}); + addChatLine({ev.from, playerName(ev.from), ev.text, false}); } else if constexpr (std::is_same_v) { ++m_raceNumber; log::info("netgame: race {} starts at session time {}", m_raceNumber, ev.startTime); @@ -318,8 +318,6 @@ void NetGame::handleEvents() { }, e); } - while (m_chat.size() > kMaxChatLines) - m_chat.pop_front(); } NetGame::Phase NetGame::phase() const { @@ -676,8 +674,11 @@ std::vector NetGame::takeGameEvents() { return std::exchange(m_gam // --- Helpers ----------------------------------------------------------------------------- // NetArena::AddGameChatLine. -void NetGame::addSystemLine(std::string text) { - m_chat.push_back({net::kInvalidPlayerId, {}, std::move(text), true}); +void NetGame::addSystemLine(std::string text) { addChatLine({net::kInvalidPlayerId, {}, std::move(text), true}); } + +void NetGame::addChatLine(NetChatLine line) { + line.serial = m_chatSerial++; + m_chat.push_back(std::move(line)); while (m_chat.size() > kMaxChatLines) m_chat.pop_front(); } diff --git a/src/game/net/NetGame.h b/src/game/net/NetGame.h index b01d92a..79b1989 100644 --- a/src/game/net/NetGame.h +++ b/src/game/net/NetGame.h @@ -73,6 +73,10 @@ struct NetChatLine { std::string name; std::string text; bool system = false; // "has joined", "has left", "You are now the Host" ... + // Counts every line this NetGame has had, from 0: a page remembers + // chatSerial() to show only the lines that came after it (chat() keeps + // the last lines only, so its indices move). + std::uint64_t serial = 0; }; // Another player's car this frame, interpolated ~100 ms in the past. @@ -180,6 +184,8 @@ class NetGame { bool hasPassword() const; int goldMass() const; // 0..kGoldMassChoices-1 const std::deque& chat() const { return m_chat; } + // The serial the next chat line will get. + std::uint64_t chatSerial() const { return m_chatSerial; } std::uint16_t pingMs(std::uint8_t playerId) const; void setLocalCar(const NetCar& car); @@ -244,6 +250,7 @@ class NetGame { struct Impl; void handleEvents(); void addSystemLine(std::string text); + void addChatLine(NetChatLine line); std::string playerName(std::uint8_t id) const; void startPortMapping(); void stopPortMapping(); @@ -252,6 +259,7 @@ class NetGame { NetCar m_car; std::unique_ptr m_impl; std::deque m_chat; + std::uint64_t m_chatSerial = 0; std::deque m_notices; std::vector m_gameEvents; bool m_raceStartPending = false; diff --git a/tests/game/test_netgame_lobby.cpp b/tests/game/test_netgame_lobby.cpp index e0b37c2..9675501 100644 --- a/tests/game/test_netgame_lobby.cpp +++ b/tests/game/test_netgame_lobby.cpp @@ -248,6 +248,33 @@ TEST(NetGameLobby, CopsVsRobbersCarsAreTheSameOnEveryMachine) { EXPECT_EQ(l.host.playerCar(robber).color, 3); } +// The chat keeps its last 64 lines. The lobby and the race used to remember +// an index into them, which stopped moving once the list was full: after 64 +// lines in a session neither showed new lines any more. Serials keep going. +TEST(NetGameLobby, ChatLinesAreFoundBySerialAfterTheListIsFull) { + NetGame host(options("Hosty")); + std::string err; + ASSERT_TRUE(host.host(cruise(), hostOptions(), {}, &err)) << err; + const std::uint64_t entered = host.chatSerial(); // e.g. the lobby opens + for (int i = 0; i < 70; ++i) + host.sendChat(std::format("line {}", i)); + host.update(); + EXPECT_EQ(host.chat().size(), 64u); + EXPECT_EQ(host.chatSerial(), entered + 70); + const std::uint64_t mark = host.chatSerial(); // e.g. a race starts + host.sendChat("after the mark"); + host.update(); + EXPECT_EQ(host.chat().size(), 64u); + std::vector since; + for (const auto& c : host.chat()) + if (c.serial >= mark) + since.push_back(c.text); + ASSERT_EQ(since.size(), 1u); + EXPECT_EQ(since[0], "after the mark"); + EXPECT_EQ(host.chat().back().serial, mark); + EXPECT_EQ(host.chat().front().serial, mark - 63); +} + // The driver's transmission choice reaches the network race: MM2's session // data carries none, and mmGame::Init sets the car's from the player's own // state. It used to be automatic for everyone. From f5ee71f1ed4c65e48953700e4cf969a74ce790bc Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Thu, 8 Oct 2026 22:27:46 -0700 Subject: [PATCH 18/54] Turn an address away for a minute after five wrong passwords A wrong password cost a guesser only a reconnect: the host answered each new connection with a fresh challenge, so a lobby password could be guessed online at the rate of round trips, on up to 16 connections at once. The host now counts wrong passwords per address and, after five within a minute, disconnects that address's new connections with BadPassword before sending a challenge until the minute is over. The joiner sees the usual "wrong password". Co-Authored-By: Claude Opus 5.5 (1M context) --- src/net/Session.cpp | 30 +++++++++++++++++++++++++++++- src/net/Session.h | 2 ++ tests/net/test_hostile_input.cpp | 30 ++++++++++++++++++++++++++++++ 3 files changed, 61 insertions(+), 1 deletion(-) diff --git a/src/net/Session.cpp b/src/net/Session.cpp index 6fec028..4111724 100644 --- a/src/net/Session.cpp +++ b/src/net/Session.cpp @@ -67,6 +67,26 @@ constexpr double kChatRate = 2.0, kChatBurst = 8.0; constexpr double kUpdateRate = 10.0, kUpdateBurst = 20.0; constexpr double kEventRate = 30.0, kEventBurst = 60.0; +// An address that sent this many wrong passwords within the window is turned +// away until the window ends, so guessing a lobby password online costs +// minutes per handful of guesses instead of one round trip each. +constexpr int kPasswordAttempts = 5; +constexpr std::uint64_t kPasswordWindowMs = 60000; +using PasswordFailures = std::map>; + +bool passwordLockedOut(PasswordFailures& failures, std::uint32_t ip, std::uint64_t now) { + std::erase_if(failures, [&](const auto& f) { return now - f.second.second > kPasswordWindowMs; }); + const auto it = failures.find(ip); + return it != failures.end() && it->second.first >= kPasswordAttempts; +} + +void notePasswordFailure(PasswordFailures& failures, std::uint32_t ip, std::uint64_t now) { + const auto it = failures.try_emplace(ip, 0, now).first; + if (now - it->second.second > kPasswordWindowMs) + it->second = {0, now}; + ++it->second.first; +} + std::array randomNonce() { std::random_device rd; std::array n{}; @@ -191,6 +211,7 @@ void Session::resetState() { m_countdownEnd = 0; m_hostEpoch = 0; m_lastPingBroadcast = 0; + m_passwordFailures.clear(); m_hostPeer = kInvalidPeer; m_connectStarted = 0; m_clock.reset(); @@ -447,6 +468,11 @@ void Session::handleTransportEvent(TransportEvent& e) { m_transport->disconnect(e.peer, toData(DisconnectReason::ServerFull)); return; } + if (!m_password.empty() && + passwordLockedOut(m_passwordFailures, m_transport->stats(e.peer).address.ip, monotonicMs())) { + m_transport->disconnect(e.peer, toData(DisconnectReason::BadPassword)); + return; + } Remote r; r.peer = e.peer; r.nonce = randomNonce(); @@ -525,8 +551,10 @@ void Session::hostHandle(Remote& r, MsgType type, std::span dat }; if (hello.protocolVersion != kProtocolVersion) return reject(DisconnectReason::VersionMismatch); - if (!m_password.empty() && hello.passwordProof != passwordProof(r.nonce, m_password)) + if (!m_password.empty() && hello.passwordProof != passwordProof(r.nonce, m_password)) { + notePasswordFailure(m_passwordFailures, m_transport->stats(peer).address.ip, monotonicMs()); return reject(DisconnectReason::BadPassword); + } if (m_players.size() >= m_settings.maxPlayers) return reject(DisconnectReason::ServerFull); if (m_phase != SessionPhase::Lobby && !m_settings.allowJoinInProgress) diff --git a/src/net/Session.h b/src/net/Session.h index db71390..bc94c61 100644 --- a/src/net/Session.h +++ b/src/net/Session.h @@ -238,6 +238,8 @@ class Session { std::map m_pendingStates; // newest unsent state per player std::function m_eventFilter; std::unique_ptr m_beacon; + // Wrong passwords per address: count and the time of the first one. + std::map> m_passwordFailures; // Client PeerId m_hostPeer = kInvalidPeer; diff --git a/tests/net/test_hostile_input.cpp b/tests/net/test_hostile_input.cpp index f939844..431b900 100644 --- a/tests/net/test_hostile_input.cpp +++ b/tests/net/test_hostile_input.cpp @@ -339,6 +339,36 @@ TEST(HostileInput, HostRejectsCarNamesThatAreNotBaseNames) { EXPECT_EQ(host.session->player(id)->color, 2); } +// --- Password --------------------------------------------------------------------------- + +// Guessing a lobby password online: after five wrong ones an address is +// turned away when it connects, for a minute, the right password included. +TEST(HostileInput, WrongPasswordsLockTheAddressOut) { + HostParams hp = hostParams(); + hp.password = "hunter2"; + Peer host; + ASSERT_TRUE(host.session->host(hp)); + auto attempt = [&](const std::string& password) { + Peer guesser; + JoinParams jp; + jp.host = Address::loopback(host.session->port()); + jp.player.name = "Guesser"; + jp.password = password; + EXPECT_TRUE(guesser.session->join(jp)); + EXPECT_TRUE(waitFor([&] { + host.pump(); + guesser.pump(); + return guesser.find() || guesser.find(); + })); + const auto* failed = guesser.find(); + return failed ? failed->reason : DisconnectReason::None; + }; + for (int i = 0; i < 5; ++i) + EXPECT_EQ(attempt(std::format("guess{}", i)), DisconnectReason::BadPassword); + EXPECT_EQ(attempt("hunter2"), DisconnectReason::BadPassword); + EXPECT_EQ(host.session->players().size(), 1u); +} + // --- Floods ----------------------------------------------------------------------------- // A client cannot make the host relay an unbounded stream of chat lines, From d49c2e91c2090759f34697599b945bbc51198731 Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Thu, 8 Oct 2026 22:28:16 -0700 Subject: [PATCH 19/54] Keep one race's car states out of the lobby and the next race A client's session kept the last state its race screen had submitted and went on sending it at the snapshot rate all through the lobby; the host stored it and passed it on to the others. The next race then began with that car drawn, and collided with as a kinematic body, where it had stopped in the last race until its new states arrived. The session now replicates cars only during a race: a state submitted in the lobby is ignored, a late VehicleState or WorldState arriving there is dropped, and the countdown and the return to the lobby clear every car's buffer and the local state on the host and the clients alike. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/multiplayer.md | 5 +- src/net/Session.cpp | 25 ++++-- src/net/Session.h | 4 +- tests/net/test_session_lobby.cpp | 129 +++++++++++++++++++++++++++++++ 4 files changed, 154 insertions(+), 9 deletions(-) create mode 100644 tests/net/test_session_lobby.cpp diff --git a/docs/multiplayer.md b/docs/multiplayer.md index d919bd9..f77a916 100644 --- a/docs/multiplayer.md +++ b/docs/multiplayer.md @@ -46,7 +46,10 @@ In game, each machine simulates its own car and sends its state about 20 times a second. The host bundles the latest state of every car into one packet per tick for each client. Remote cars are drawn from an interpolation buffer about 100 ms in the past. When packets stop coming, a remote car is extrapolated for -up to 250 ms and then held still. +up to 250 ms and then held still. Cars are replicated during a race only (its +countdown and the game): a state submitted in the lobby is ignored, a late one +arriving there is dropped, and the countdown and the return to the lobby both +clear every car's buffer, so nothing of one race reaches the next. There is no host migration. If the host leaves, every client gets `Disconnected{HostShutdown}`. diff --git a/src/net/Session.cpp b/src/net/Session.cpp index de9d394..c2cd621 100644 --- a/src/net/Session.cpp +++ b/src/net/Session.cpp @@ -333,7 +333,7 @@ void Session::update() { ++m_timeRequestsSent; m_nextTimeRequest = now + (m_timeRequestsSent < 6 ? 150 : 2000); } - if (m_localState && now - m_lastSnapshotSent >= snapshotInterval) { + if (m_localState && m_phase != SessionPhase::Lobby && now - m_lastSnapshotSent >= snapshotInterval) { m_lastSnapshotSent = now; sendTo(m_hostPeer, Channel::State, VehicleStateMsg{*m_localState}); } @@ -533,7 +533,8 @@ void Session::hostHandle(Remote& r, MsgType type, std::span dat } case MsgType::VehicleState: { VehicleStateMsg msg; - if (!decodeMessage(data, msg)) + // Cars move in a race only (a late one from the last race is old). + if (m_phase == SessionPhase::Lobby || !decodeMessage(data, msg)) return; m_pendingStates[id] = msg.state; m_remoteStates[id].push(msg.state); @@ -727,6 +728,7 @@ void Session::clientHandle(MsgType type, std::span data) { return; m_phase = SessionPhase::Countdown; m_countdownEnd = m.startTime; + resetReplication(); emit(ev::CountdownStarted{m.startTime}); return; } @@ -738,7 +740,7 @@ void Session::clientHandle(MsgType type, std::span data) { for (auto& p : m_players) p.ready = false; m_request.ready = false; - m_remoteStates.clear(); + resetReplication(); emit(ev::ReturnedToLobby{}); return; } @@ -764,7 +766,7 @@ void Session::clientHandle(MsgType type, std::span data) { } case MsgType::WorldState: { WorldStateMsg m; - if (!decodeMessage(data, m)) + if (m_phase == SessionPhase::Lobby || !decodeMessage(data, m)) return; for (const auto& [id, state] : m.vehicles) if (id != m_localId) @@ -880,6 +882,7 @@ void Session::startCountdown(std::uint32_t delayMs) { return; m_phase = SessionPhase::Countdown; m_countdownEnd = time() + delayMs; + resetReplication(); sendToPlayers(Channel::Control, CountdownMsg{m_countdownEnd}); emit(ev::CountdownStarted{m_countdownEnd}); } @@ -890,18 +893,26 @@ void Session::returnToLobby() { m_phase = SessionPhase::Lobby; for (auto& p : m_players) p.ready = false; - m_remoteStates.clear(); - m_pendingStates.clear(); - m_localState.reset(); + resetReplication(); sendToPlayers(Channel::Control, ReturnToLobbyMsg{}); emit(ev::ReturnedToLobby{}); } void Session::submitLocalState(const VehicleSnapshot& state) { + if (m_phase == SessionPhase::Lobby) + return; // no race: nothing to show the others m_localState = state; m_localState->time = time(); } +// The cars of one race: dropped when it starts and when it ends, so neither +// the lobby nor the next race sees where they were. +void Session::resetReplication() { + m_remoteStates.clear(); + m_pendingStates.clear(); + m_localState.reset(); +} + SnapshotBuffer::Result Session::sampleRemoteAt(std::uint8_t playerId, double sessionTime, VehicleSnapshot& out) const { const auto it = m_remoteStates.find(playerId); if (it == m_remoteStates.end()) diff --git a/src/net/Session.h b/src/net/Session.h index a496596..27de4e7 100644 --- a/src/net/Session.h +++ b/src/net/Session.h @@ -166,7 +166,8 @@ class Session { } // --- Replication --- - // Latest state of the local vehicle; sent at snapshotRateHz. + // Latest state of the local vehicle; sent at snapshotRateHz during a + // race (countdown and game; ignored in the lobby). void submitLocalState(const VehicleSnapshot& state); // Remote vehicle state at (time() - interpolationDelay). SnapshotBuffer::Result sampleRemote(std::uint8_t playerId, VehicleSnapshot& out) const; @@ -198,6 +199,7 @@ class Session { void hostAdvertise(); void close(DisconnectReason reason, std::string message, bool failedJoin); void tickCountdown(); + void resetReplication(); PlayerInfo* findPlayer(std::uint8_t id); Remote* remoteForPlayer(std::uint8_t id); std::uint8_t allocatePlayerId() const; diff --git a/tests/net/test_session_lobby.cpp b/tests/net/test_session_lobby.cpp new file mode 100644 index 0000000..5e24d3a --- /dev/null +++ b/tests/net/test_session_lobby.cpp @@ -0,0 +1,129 @@ +// Session phases between races (docs/review/multiplayer-lobby.md): what a +// race leaves behind must not reach the lobby or the next race. +#include "net/Session.h" + +#include + +#include +#include +#include + +using namespace mm2; +using namespace mm2::net; + +namespace { + +SessionConfig fastConfig() { + SessionConfig c; + c.connectTimeoutMs = 3000; + c.joinTimeoutMs = 3000; + c.snapshotRateHz = 50; + c.interpolationDelayMs = 50; + return c; +} + +struct Peer { + std::unique_ptr session = std::make_unique(fastConfig()); + std::vector events; + + void pump() { + session->update(); + for (auto& e : session->takeEvents()) + events.push_back(std::move(e)); + } + template + int count() const { + int n = 0; + for (const auto& e : events) + n += std::holds_alternative(e); + return n; + } +}; + +bool pumpUntil(std::vector peers, const std::function& done, int timeoutMs = 3000) { + const auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(timeoutMs); + while (std::chrono::steady_clock::now() < deadline) { + for (Peer* p : peers) + p->pump(); + if (done()) + return true; + std::this_thread::sleep_for(std::chrono::milliseconds(2)); + } + return false; +} + +void settle(std::vector peers, int ms) { pumpUntil(std::move(peers), [] { return false; }, ms); } + +HostParams hostParams() { + HostParams h; + h.bind = Address::loopback(0); // ephemeral port + h.advertiseOnLan = false; + h.player.name = "Host"; + h.settings.name = "Lobby"; + return h; +} + +JoinParams joinParams(const Peer& host, const std::string& name) { + JoinParams j; + j.host = Address::loopback(host.session->port()); + j.player.name = name; + return j; +} + +VehicleSnapshot at(float x) { + VehicleSnapshot s; + s.position = {x, 0, 0}; + return s; +} + +} // namespace + +// After a race the client's race screen is gone, but the session still held +// 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. +TEST(SessionLobby, NoVehicleStatesBetweenRaces) { + 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(); + + host.session->startCountdown(100); + ASSERT_TRUE(pumpUntil({&host, &a, &b}, [&] { return a.session->phase() == SessionPhase::InGame; })); + VehicleSnapshot seen; + ASSERT_TRUE(pumpUntil({&host, &a, &b}, [&] { + a.session->submitLocalState(at(500.0f)); // where A ends the race + return b.session->sampleRemote(aId, seen) != SnapshotBuffer::Result::Empty; + })); + + // The host ends the race; nobody submits any more. + host.session->returnToLobby(); + ASSERT_TRUE(pumpUntil({&host, &a, &b}, [&] { + return a.session->phase() == SessionPhase::Lobby && b.session->phase() == SessionPhase::Lobby; + })); + settle({&host, &a, &b}, 300); + EXPECT_EQ(host.session->sampleRemote(aId, seen), SnapshotBuffer::Result::Empty); + EXPECT_EQ(b.session->sampleRemote(aId, seen), SnapshotBuffer::Result::Empty); + + // A state handed over in the lobby (a race screen's last frame) is not sent. + a.session->submitLocalState(at(600.0f)); + settle({&host, &a, &b}, 200); + EXPECT_EQ(host.session->sampleRemote(aId, seen), SnapshotBuffer::Result::Empty); + + // The next race starts with nothing from the last one; A's new start + // place comes through. + host.session->startCountdown(100); + ASSERT_TRUE(pumpUntil({&host, &a, &b}, [&] { return b.session->phase() == SessionPhase::InGame; })); + settle({&host, &a, &b}, 100); + EXPECT_EQ(b.session->sampleRemote(aId, seen), SnapshotBuffer::Result::Empty); + ASSERT_TRUE(pumpUntil({&host, &a, &b}, [&] { + a.session->submitLocalState(at(-20.0f)); + return b.session->sampleRemote(aId, seen) != SnapshotBuffer::Result::Empty; + })); + EXPECT_NEAR(seen.position.x, -20.0f, 0.01f); +} From def868ba1fdc03fde51afbfe085b00af0431d834 Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Thu, 8 Oct 2026 22:29:44 -0700 Subject: [PATCH 20/54] Take everyone back to the lobby when a network race cannot load A city that failed to load sent the race screen straight to the menus. For the host of a network race that left the session in the race's countdown or game phase: the lobby's GO DRIVE could never start another race (NetGame::startRace needs the lobby phase) and the joiners waited in a race that would not end. A failed load now leaves through leaveRace, so the host takes everyone back to the lobby (seen with a session set to a city the data does not have: both machines back in the lobby, and the next GO DRIVE starts again); a network race whose own car cannot be loaded leaves the same way. While a network race loads, its session is serviced between the parts, so that a machine that loads more slowly than the others is not timed out by them (ENet gives up on a peer that has not answered for 5 s). Co-Authored-By: Claude Opus 5.5 (1M context) --- src/app/RaceScreen.cpp | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/app/RaceScreen.cpp b/src/app/RaceScreen.cpp index 2cc5b0f..3d8d7e8 100644 --- a/src/app/RaceScreen.cpp +++ b/src/app/RaceScreen.cpp @@ -181,6 +181,12 @@ class RaceScreen final : public Screen { } if (m_state == State::Load) { loadStep(ctx); + // A network race keeps its session serviced between the parts + // (acknowledgements and pings), so that the others do not time + // this machine out while it loads more slowly than they do. What + // arrives meanwhile waits for the first frame of the race. + if (multiplayer(ctx)) + ctx.netGame->update(); return; } // GameLoop: AudManager::Update before the game's update, then again @@ -746,7 +752,9 @@ class RaceScreen final : public Screen { auto city = city::loadCity(ctx.game->vfs, m_result.config.city, &error); if (!city) { log::error("race: cannot load city '{}': {}", m_result.config.city, error); - ctx.nextScreen = makeFrontendScreen(ctx, m_result); + // Back to the menus; the host of a network race takes everyone + // back to the lobby, which could otherwise never start another. + leaveRace(ctx, m_result); return; } for (const auto& w : city->warnings) @@ -899,6 +907,10 @@ class RaceScreen final : public Screen { m_player = game::SimVehicle::loadPlayer(ctx.game->vfs, m_result.config.vehicle, &error, withTrailer); if (!m_player) { log::error("race: vehicle '{}': {}", m_result.config.vehicle, error); + // A network race without the player's car cannot be driven or + // seen by the others: back to the lobby (the host takes everyone). + if (multiplayer(ctx)) + leaveRace(ctx, m_result); return; } m_player->sim().options.player = true; // mmPlayer::Update's input overrides From a69c5d7eea022e6642dc1028f355fba2963ba985 Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Thu, 8 Oct 2026 22:31:57 -0700 Subject: [PATCH 21/54] Take the transmission the control options set into a network race The control options page changes the driver's transmission too, and it can be opened from the lobby (the strip's OPTIONS); only the garage's PREV handed the choice to the lobby car. The menus now give the lobby car the transmission they have when the race starts. NetGame::setLocalCar sends the car to the session only when the car, paint job or team changed, so this costs no message. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/app/frontend/FrontendScreen.cpp | 5 +++++ src/game/net/NetGame.cpp | 5 ++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/app/frontend/FrontendScreen.cpp b/src/app/frontend/FrontendScreen.cpp index 5ef7e52..e6e3515 100644 --- a/src/app/frontend/FrontendScreen.cpp +++ b/src/app/frontend/FrontendScreen.cpp @@ -998,6 +998,11 @@ class FrontendScreen final : public Screen { if (ctx.netGame) { ctx.netGame->update(); if (ctx.netGame->takeRaceStart()) { + // The transmission as the menus have it now: the garage and + // the control options both change it. + game::NetCar car = ctx.netGame->localCar(); + car.automatic = m_fe.config.automatic; + ctx.netGame->setLocalCar(car); m_fe.saveNetEvent(); ctx.nextScreen = makeRaceScreen(ctx, ctx.netGame->raceConfig()); return; diff --git a/src/game/net/NetGame.cpp b/src/game/net/NetGame.cpp index d60c793..7d2e6b9 100644 --- a/src/game/net/NetGame.cpp +++ b/src/game/net/NetGame.cpp @@ -458,8 +458,11 @@ std::uint16_t NetGame::pingMs(std::uint8_t playerId) const { } void NetGame::setLocalCar(const NetCar& car) { + // The transmission stays here; the rest goes to the session when it + // changes. + const bool sent = car.vehicle != m_car.vehicle || car.color != m_car.color || car.team != m_car.team; m_car = car; - if (m_impl->session) + if (sent && m_impl->session) m_impl->session->setLocalPlayer(car.vehicle, static_cast(car.color), static_cast(car.team)); } From 76e127ab34018c66a66468598fa1b3810edf5bff Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Thu, 8 Oct 2026 22:33:18 -0700 Subject: [PATCH 22/54] Keep the input review's new lines within 110 columns Co-Authored-By: Claude Opus 5.5 (1M context) --- src/net/Discovery.cpp | 5 +++-- src/net/Protocol.cpp | 3 ++- src/net/Session.cpp | 7 ++++--- tests/net/test_fuzz.cpp | 3 ++- tests/net/test_hostile_input.cpp | 10 ++++++---- 5 files changed, 17 insertions(+), 11 deletions(-) diff --git a/src/net/Discovery.cpp b/src/net/Discovery.cpp index 3efd73e..c3397c8 100644 --- a/src/net/Discovery.cpp +++ b/src/net/Discovery.cpp @@ -247,8 +247,9 @@ bool answersLanQueryFrom(const Address& from, std::span subnets) { } if (from.isPrivate()) return true; - return std::ranges::any_of(subnets, - [&](const Subnet& s) { return s.mask != 0 && (ip & s.mask) == (s.ip & s.mask); }); + return std::ranges::any_of(subnets, [&](const Subnet& s) { + return s.mask != 0 && (ip & s.mask) == (s.ip & s.mask); + }); } std::uint32_t primaryLocalAddress() { diff --git a/src/net/Protocol.cpp b/src/net/Protocol.cpp index 1cb7ef4..14ef2d9 100644 --- a/src/net/Protocol.cpp +++ b/src/net/Protocol.cpp @@ -77,7 +77,8 @@ bool isValidAssetName(std::string_view name) { if (name.empty() || name.size() > kMaxShortStringLength) return false; return std::ranges::all_of(name, [](char c) { - return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_' || c == '-'; + return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_' || + c == '-'; }); } diff --git a/src/net/Session.cpp b/src/net/Session.cpp index 4111724..97d7ed3 100644 --- a/src/net/Session.cpp +++ b/src/net/Session.cpp @@ -110,7 +110,8 @@ std::array passwordProof(const std::array& nonce, } bool Session::RateLimit::take(std::uint64_t now, double perSecond, double burst) { - tokens = tokens < 0.0 ? burst : std::min(burst, tokens + static_cast(now - last) * perSecond / 1000.0); + const double refill = static_cast(now - last) * perSecond / 1000.0; + tokens = tokens < 0.0 ? burst : std::min(burst, tokens + refill); last = now; if (tokens < 1.0) return false; @@ -841,8 +842,8 @@ void Session::clientHandle(MsgType type, std::span data) { KickMsg m; decodeMessage(data, m); const std::string reason = sanitize(m.reason, kMaxReasonLength); - close(DisconnectReason::Kicked, reason.empty() ? describe(DisconnectReason::Kicked) : "kicked: " + reason, - false); + close(DisconnectReason::Kicked, + reason.empty() ? std::string(describe(DisconnectReason::Kicked)) : "kicked: " + reason, false); return; } case MsgType::TimeResponse: { diff --git a/tests/net/test_fuzz.cpp b/tests/net/test_fuzz.cpp index a5f5897..7862887 100644 --- a/tests/net/test_fuzz.cpp +++ b/tests/net/test_fuzz.cpp @@ -317,7 +317,8 @@ void decodeEverything(std::span b) { (void)payload(b); (void)payload(b); if (const auto e = payload(b)) { - ASSERT_TRUE(std::isfinite(e->position.x) && std::isfinite(e->position.y) && std::isfinite(e->position.z)); + ASSERT_TRUE(std::isfinite(e->position.x) && std::isfinite(e->position.y) && + std::isfinite(e->position.z)); } if (const auto e = payload(b)) { ASSERT_TRUE(std::isfinite(e->impulse) && std::isfinite(e->position.x)); diff --git a/tests/net/test_hostile_input.cpp b/tests/net/test_hostile_input.cpp index 431b900..6c71e3e 100644 --- a/tests/net/test_hostile_input.cpp +++ b/tests/net/test_hostile_input.cpp @@ -92,7 +92,8 @@ struct RawEnd { return std::nullopt; } bool connected() const { - return std::ranges::any_of(events, [](const TransportEvent& e) { return e.type == TransportEvent::Type::Connected; }); + return std::ranges::any_of(events, + [](const TransportEvent& e) { return e.type == TransportEvent::Type::Connected; }); } }; @@ -157,7 +158,8 @@ void rawHost(RawEnd& raw, Peer& client, WelcomeMsg welcome) { ASSERT_TRUE(waitFor([&] { client.pump(); raw.pump(); - return client.session->state() == Session::State::Active || client.session->state() == Session::State::Closed; + const auto state = client.session->state(); + return state == Session::State::Active || state == Session::State::Closed; })); } @@ -493,8 +495,8 @@ TEST(HostileInput, BeaconAnswersOnlyLanSources) { } } -// The beacon answers a query at a bounded rate (it would otherwise reflect -// ~17 times the traffic it receives to whatever source a query claims). +// The beacon answers queries at a bounded rate (it would otherwise reflect up +// to 16 times the traffic it receives to whatever source a query claims). TEST(HostileInput, BeaconRepliesAreRateLimited) { LanBeacon beacon; ASSERT_TRUE(beacon.start(0)); From 84fda689eea48dd6d171bfa80efa32b944da9b37 Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Thu, 8 Oct 2026 22:33:18 -0700 Subject: [PATCH 23/54] Record the multiplayer network input review docs/review/multiplayer-input.md lists what was read and tested, the fifteen fixed findings with their commits, what was checked and found correct, the open findings and what other areas should pick up. docs/multiplayer.md describes the limits now applied to untrusted input: packet sizes, text cleanup, base-name checks for cars and cities, player counts, relay budgets, the password lockout, who the LAN beacon answers and the checks on the port mapping record. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/multiplayer.md | 64 +++++++++++- docs/review/multiplayer-input.md | 163 +++++++++++++++++++++++++++++++ 2 files changed, 224 insertions(+), 3 deletions(-) create mode 100644 docs/review/multiplayer-input.md diff --git a/docs/multiplayer.md b/docs/multiplayer.md index 4ae5642..39a1856 100644 --- a/docs/multiplayer.md +++ b/docs/multiplayer.md @@ -104,7 +104,11 @@ continue and the status recovers on success. `stop()` removes the mapping. `/portmap.ini`), the active mapping is recorded on disk. On the next start, a recorded mapping is removed before anything else, but only on the same gateway, and with UPnP only if the router still shows it pointing at this -machine. Mappings also expire on their own once the lease runs out. +machine. Mappings also expire on their own once the lease runs out. A record +whose ports are not 1-65535 is ignored, and the PCP/NAT-PMP backend never +sends a deletion for internal port 0: both RFCs define that as "every mapping +of this machine". An external address a UPnP device reports is shown only if +it parses as an address (any device on the LAN can answer SSDP as the router). **Status for the UI.** `PortMappingStatus::message` is a one-line summary such as: @@ -139,6 +143,15 @@ send queries from an ephemeral port, both to every interface's directed broadcast address and to 255.255.255.255. The reply's nonce gives the ping. Entries expire after 6 s without an answer. +An advert can be 16 times the size of a query, so the beacon must not answer +anyone who asks: it answers only loopback, private addresses (RFC 1918, +CGNAT 100.64/10, link-local) and addresses on one of this machine's interface +subnets (so a VPN adapter on a public range, such as Hamachi's 25/8, still +works) (`answersLanQueryFrom`). It never answers a broadcast, multicast or zero +source address, and sends at most 20 replies a second with a burst of 40. +A scanner lists at most `kMaxLanSessions` (64) sessions, skips adverts with +game port 0, and cleans an advert's text as described under "Untrusted input". + Passive listening for unsolicited adverts is optional and off by default. On Linux, a UDP unicast datagram to a port that several sockets share (`SO_REUSEADDR`) reaches only one of them. A passive scanner socket can @@ -185,7 +198,11 @@ client host The password never crosses the network. The proof is a SHA-256 over a fresh 16-byte nonce and the password. This only protects the password from casual -sniffing; traffic is not encrypted. +sniffing; traffic is not encrypted, and someone who records a handshake can +still try passwords offline against it. Online guessing is slowed down: after +five wrong passwords from one address within a minute, the host disconnects +that address's new connections with `BadPassword` before the challenge until +the minute is over. A handshake must finish within `joinTimeoutMs` (10 s), and an ENet connect attempt within `connectTimeoutMs` (8 s). @@ -219,6 +236,41 @@ weather, traffic and pedestrian density (percent), cops on/off, max players join-in-progress policy, and up to 32 extra key/value pairs. The extra pairs let the game add options without a protocol bump. +### Untrusted input + +Everything that arrives is untrusted: other players, anyone who can send a UDP +packet to the game or discovery port, and whatever answers as the router. + +* **Sizes.** ENet accepts at most a 64 KiB packet and 1 MiB of a peer's + undelivered data (`TransportConfig::maxPacketSize`, `maxWaitingData`; ENet's + own defaults, 32 MiB each, let any connected peer make this machine allocate + a whole packet with its first fragment). The largest message, a 16-player + `Welcome` with 32 extra settings, is about 5 KiB. `Transport::send` refuses an + empty packet (ENet's range coder would read its null data). +* **Text.** Names, chat, the session name, kick and reject reasons and an + advert's strings are cleaned on receipt by `sanitizeText`: well-formed UTF-8 + only, no control characters (tab and line breaks become spaces), trimmed, cut + on a character boundary. The host does it for what joiners send, clients for + what the host sends. +* **Names of files.** Car and city names must be plain base names + (`isValidAssetName`: 1-32 characters of `[A-Za-z0-9_-]`). The host gives a + joiner with any other car `kDefaultCar` and keeps the previous car on a + request; a client does the same with the host's player list and ignores + (empties) such a city, which then loads nothing. A valid name this machine + lacks, such as an add-on car, is shown as MM2's default vehicle `vpcoop` + (`game::netVehicle`, after `mmVehList::GetVehicleInfo`). +* **Counts.** A client keeps at most `kMaxPlayers` players, never one with + `kInvalidPlayerId`, and refuses a `Welcome` that does not list it. +* **Floods.** The host relays what one joiner sends to every other player, so + each joiner has a budget: chat 2 lines a second (burst 8), player updates 10 + (burst 20), game events 30 (burst 60). Chat and events beyond it are dropped; + a `PlayerRequest` is always applied and its `PlayerUpdate` relayed once the + budget allows, so the latest car, colour, team and ready state still + arrives. +* **Floats.** Snapshot fields are quantized to fixed ranges and event floats + must be finite (`ReadStream::f32`), so no NaN or infinity reaches physics or + rendering. + ### Session clock The host's clock (ms since the session started) is the shared timeline. @@ -299,7 +351,13 @@ if (session.phase() == net::SessionPhase::InGame) { filtering, kick, host shutdown, join-in-progress). It also covers `PortMapper` against fake backends: failure reporting, fallback, port conflicts, renewal and loss, crash cleanup, other-gateway records, double NAT - and restart. None of it needs a router. + and restart. None of it needs a router. `test_hostile_input.cpp` drives a + real session from a hand-built host or client that sends what an honest one + never would, and `test_fuzz.cpp` feeds fixed-seed random and mutated + messages to every decoder and to a live host and client. Build `test_net` + with `-fsanitize=address,undefined` to run them under the sanitizers (ENet's + range decoder shifts a byte into an `int`'s sign bit; suppress + `shift-base:compress.c`). * `netprobe info | host | join | scan | portmap` for manual testing on real networks. `netprobe portmap --discover-only` is read-only: it finds the gateway and the external address without creating a mapping. diff --git a/docs/review/multiplayer-input.md b/docs/review/multiplayer-input.md new file mode 100644 index 0000000..1a663b9 --- /dev/null +++ b/docs/review/multiplayer-input.md @@ -0,0 +1,163 @@ +# Multiplayer review: network input robustness and security + +Reviewed on 2026-10-08 from `integration` at 2734bea. Line numbers below are +at that commit. + +Everything that arrives from the network is treated as hostile: other players, +anyone who can send a UDP packet to the game port (2300) or the LAN discovery +port (2301), and any device that answers as the router (SSDP/UPnP, PCP, +NAT-PMP). The question for every input: can it crash, hang or exhaust the +machine, corrupt state or files, or make the UI or the race misbehave? + +## What I read + +* `src/net/BitStream.{h,cpp}`, `Protocol.{h,cpp}`, `Snapshot.{h,cpp}`, + `Session.{h,cpp}`, `Transport.{h,cpp}`, `ClockSync.{h,cpp}`, `Net.{h,cpp}`, + `Discovery.{h,cpp}`, `PortMapper.{h,cpp}`, `NatPmp.{h,cpp}`, + `NatPmpBackend.cpp`, `UpnpBackend.cpp`, `Sha256.{h,cpp}`, all completely. +* ENet 1.3.18's packet, fragment and range-coder code (`peer.c`, + `protocol.c`, `compress.c`) where our limits depend on it, and miniupnpc + 2.3.3's buffer contracts for the calls `UpnpBackend` makes. +* Where received strings and numbers end up: `src/game/net/NetGame.{h,cpp}`; + `src/app/RaceScreen.cpp` (remote cars, the race and Cops and Robbers event + handlers, net player list, city loading, `leaveRace`); + `src/app/frontend/PagesMulti.cpp` (session list, lobby roster, chat, + settings lines); `Frontend::saveNetEvent` in `FrontendScreen.cpp`; + `game::session::Session` net racers, `CopsAndRobbers::receive`, + `hud::netIconColor`, `GpuModel::materials`, `ui::nextCodepoint`, + `city::loadCity`, `vfs::DirectoryFs`, `IniFile::serialize`. +* MM2Recomp for the one place parity applies: `mmNetObject::Init` / + `ReInit`, `mmVehList::GetVehicleInfo`, `SetDefaultVehicle` and `LoadAll` + (the default vehicle), and `mmMultiCR::GameMessage` (who may send what). + +## How it was tested + +* `tests/net/test_hostile_input.cpp`: a hand-built host or client on a raw + `Transport` sends what an honest OpenMM2 never would (control characters, + path-like car and city names, 250 players, floods, oversized packets, + spoofed LAN traffic, damaged state files, password guessing) to a real + `Session`, `LanBeacon` or `LanScanner`. Every behaviour test there fails + without its fix. +* `tests/net/test_fuzz.cpp`: fixed seeds; 40 000 random and mutated buffers + (bit flips, overwrites, truncation, appends, inserts, splices, type swaps, + noise) through every message decoder, the LAN and router decoders and the + event payloads, checking that whatever decodes stays within what a writer + could produce; then 3 000 mutated messages each into a live host (from a + joined peer, a peer that never sent Hello and raw datagrams below ENet) and + into a live client (from a fake host), checking the session stays + consistent. +* `tests/game/test_netgame_input.cpp`: received densities and the default + vehicle for an unknown car. +* `test_net` built with `-fsanitize=address,undefined` (a separate build + directory): all 65 tests pass with no AddressSanitizer, LeakSanitizer or + UBSan report in OpenMM2 code. UBSan reports one shift in ENet's range + decoder (`compress.c`, `ENET_RANGE_CODER_SEED`: a byte shifted by 24 into an + `int`'s sign bit) on ordinary traffic; it is third-party, benign with GCC and + Clang, and suppressed for the run (`shift-base:compress.c`). +* A host and a client on loopback (UDP 2330, frontend script) into a cruise + race after the changes, with the default cars and with a client car the + host's catalog does not have. + +## Findings + +Severity: crash / gameplay-breaking / visible / minor / code quality. + +### Fixed + +| # | Severity | Location | Scenario | Confirmed | Fix | +| --- | --- | --- | --- | --- | --- | +| 1 | visible | `src/net/Session.cpp:22-37` (`sanitize`), `:247` (`uniqueName`) | The host's cleanup popped every trailing byte with the high bit set, so a complete last character was dropped: "José" joined as "Jos", a chat line "à bientôt" lost its last letter. `uniqueName` cut a name to fit " (2)" in the middle of a UTF-8 sequence. | test `NonAsciiNamesAndChatSurviveTheHost` | 13634e9 | +| 2 | gameplay-breaking (profile corruption) | `Session.cpp:638-752` (`clientHandle`: Welcome, PlayerJoined, PlayerUpdate, Chat, Settings, Kick, Reject); `FrontendScreen.cpp:315`; `src/core/Ini.cpp:72` | A client used everything the host sent as is. The city went into `Profile::city` (`Frontend::saveNetEvent`) and `IniFile` writes values raw, so a host sending `city = "x\n[Races]\n..."` added arbitrary lines to the joiner's profile file. Names, chat, the session name and kick/reject reasons reached the UI and the log with control characters and invalid UTF-8. | test `ClientCleansUpWhatTheHostSends` | 13634e9: `sanitizeText` on all received text; car and city names must be base names (`isValidAssetName`), otherwise the default car / an empty city | +| 3 | minor (performance) | `Session.cpp:675-709` | A host could make a client list 255 players (PlayerJoined with every id, including `kInvalidPlayerId`), and every listed player gets a car in the race. | test `ClientKeepsAtMostMaxPlayers` | 13634e9: at most `kMaxPlayers`, never id 255; a Welcome that does not list the joiner fails the join | +| 4 | minor | `Session.cpp:501-515`, `:565` | A joiner's car name was any 32 bytes ("../../etc/passwd"), relayed to everyone and loaded by name. The VFS is a closed index (`DirectoryFs` scans its root; `..` collapses inside it), so no file outside the game data could be read, but the name reached file names and the UI. | test `HostRejectsCarNamesThatAreNotBaseNames` | 13634e9: default car in Hello, previous car kept on a request | +| 5 | gameplay-breaking (denial of service) | `Session.cpp:501-549` | One joiner's chat, PlayerRequest and GameEvent messages were each relayed to every other player on reliable channels with no limit. 300 of each from a raw client reached the other player 300 times each; ENet's outgoing queues are unbounded. | test `HostRateLimitsWhatItRelays` | cbd7079: per-joiner budgets (chat 2/s burst 8, updates 10/s burst 20, events 30/s burst 60); requests are coalesced, never lost | +| 6 | crash (memory exhaustion) | `src/net/Transport.cpp:58`, `:79` | ENet's defaults allow a 32 MiB packet and 32 MiB of waiting data per peer, and it allocates a fragmented packet's whole size on its first fragment. Any connected peer, before Hello, could make the host allocate tens of MiB with a couple of datagrams (16 slots: about 1 GiB). A 256 KiB packet was delivered. | test `OversizedPacketsAreNotDelivered` | 3029e41: 64 KiB per packet, 1 MiB waiting per peer | +| 7 | gameplay-breaking (reflection) | `src/net/Discovery.cpp:290` (`LanBeacon::update`) | The beacon answered every well-formed 10-byte query from any source with an advert of up to about 160 bytes, 64 per frame. With a reachable discovery port (a host with a public address, no firewall), a spoofed source made it a reflector of up to 16 times the traffic; a spoofed LAN broadcast source sent each reply to a whole subnet. 400 queries got 400 replies. | tests `BeaconRepliesAreRateLimited`, `BeaconAnswersOnlyLanSources` | 31cd6de: loopback, private and own-subnet sources only (VPN subnets included), never broadcast/multicast/zero; 20 replies/s, burst 40 | +| 8 | minor (memory, UI) | `Discovery.cpp:357` (`LanScanner::receive`) | Spoofed adverts (any source, any game port) each added a session: 2 000 adverts listed 2 000 sessions, with control characters in their names; a game port of 0 was listed. | test `ScannerBoundsAndCleansAdverts` | 31cd6de: at most 64 sessions, text cleaned, port 0 skipped | +| 9 | gameplay-breaking (other programs) | `src/net/PortMapper.cpp:82-83`, `PortMapper.h:99`; `NatPmpBackend.cpp:79`, `:194` | The crash-safe record's ports were cast to 16 bits unchecked (70000 became 4464) and internal port 0 passed. The next start's clean-up then sent a NAT-PMP/PCP deletion for internal port 0, which RFC 6886 3.4 and RFC 6887 11.1 define as "every mapping of this machine", other programs' included. The nonce's hex accepted signs ("0x-1"). | test `DamagedPortMappingRecordIsIgnored` (failed on the old code) | 255e792 | +| 10 | minor | `src/net/UpnpBackend.cpp:96-101` | Any LAN device can answer SSDP as the router; its "external address" string went into the lobby status line as given. | by reading | 255e792: dropped unless it parses as an address | +| 11 | visible (parity) | `src/app/RaceScreen.cpp:3335-3347` | A remote car this machine lacks (an add-on car on the other machine) failed to load, and because the condition included `!rv.renderer` the load was retried and a warning logged every frame (~200 a second) while the car stayed invisible and could not be hit. MM2's `mmNetObject::Init` goes through `mmVehList::GetVehicleInfo`, which answers an unknown name with the default vehicle, vpcoop (`mmVehList::LoadAll`). | fallback: test `UnknownRemoteCarIsTheDefaultVehicle`; the per-frame retry by reading (an honest client cannot drive a car it lacks, so it was not reproduced live) | 622dd1c: `game::netVehicle`; a load that still fails is tried once per car and paint job | +| 12 | minor | `src/game/net/NetGame.cpp:136-137` | Traffic and pedestrian densities arrive as a percent byte; a host could send 255, 2.55 times full traffic. | test `DensitiesFromTheNetworkAreAtMostFull` | 622dd1c | +| 13 | visible | `src/app/frontend/PagesMulti.cpp:777` | The roster's "%.6s..." cut six bytes, splitting a UTF-8 character ("aéééé" showed a replacement glyph). | by reading | 5e227d3: six whole characters, like `fit()` on the main menu | +| 14 | crash (latent) | `Transport.cpp:179` (`Transport::send`) | An empty packet makes ENet's range coder (`enet_range_coder_compress`) read the packet's null data: the sender segfaults. Found by the fuzz test. No path sends one today (every message has its type byte), and a remote peer cannot cause one. | fuzz test (segfault) | 810f5a9: `send` refuses empty data | +| 15 | minor (security) | `Session.cpp:489` | A wrong password cost a guesser only a reconnect; with 16 connection slots a lobby password could be guessed at round-trip speed. | test `WrongPasswordsLockTheAddressOut` | f5ee71f: five wrong passwords from an address within a minute lock it out until the minute ends | + +### Checked and correct + +* **BitStream** (`BitStream.cpp`): every read checks the remaining bits and the + error is sticky (further reads return 0); varints reject more than 10 bytes + and 64-bit overflow; `string`/`bytes` check the length against both the + limit and the remaining bits before allocating; `ranged`/`enumeration` + reject values above the maximum; `f32` rejects NaN and infinities; quantized + values stay within their range; a decoded quaternion is normalized and its + largest component is rebuilt from the others, so it is never zero. +* **Protocol** (`Protocol.h`): every count (players, extras, vehicles, pings) + is checked before the vector is resized; `decodeMessage` rejects a type + mismatch and trailing bytes; `maxPlayers` 0 or above 16 is rejected; event + payloads are at most 1 KiB. +* **Snapshots**: all fields are quantized (position ±16384 m, velocity + ±200 m/s, angular ±64 rad/s, controls 0-1/±1, gear −1-14), so no NaN or + infinity reaches physics or rendering. Interpolation between snapshots with + extreme times stays finite (positions up to ~2.5e8 m at a 49-day span). +* **Protocol version**: the ENet connect data (`kConnectData`) and Hello's + version are both checked; LAN adverts of another version are listed as + incompatible without parsing the body. +* **SHA-256** matches the FIPS 180-4 vectors (`Sha256.KnownVectors`). +* **PCP/NAT-PMP decoders** check every length before reading; replies are only + accepted from the gateway's address, and MAP replies must echo the nonce. +* **miniupnpc**: the buffers passed to `UPNP_GetSpecificPortMappingEntry`, + `UPNP_GetExternalIPAddress` and `UPNP_GetValidIGD` meet the library's size + contracts (client 64 ≥ 16, port 8 ≥ 6, description 80, enabled 8 ≥ 4, + lease 16). +* **Indexes from the network**: a paint job is taken modulo the model's + shader sets (`GpuModel::materials`, as `vehCarModel::Init`); icon colours + clamp the player id (`hud::netIconColor`); the team is only compared with 0; + waypoint counts are taken modulo the course; Cops and Robbers look cars up + by id (`CopsAndRobbers::teamOf`, `score`), never index by them; race ids + are searched for, not indexed (`loadRaceSetup`, `hostSettingsLines`). +* **Names as paths**: car, city and race names only reach the VFS, which only + knows the files it indexed, and `city::loadCity` failing sends the race + screen back to the menus without leaving the session. +* **Chat history** is capped at 64 lines; the UI's UTF-8 decoder replaces + invalid sequences rather than reading past the string. + +### Open + +| # | Severity | Location | Scenario | Status / what it needs | +| --- | --- | --- | --- | --- | +| O1 | minor (denial of service) | `Session.cpp:401` (`Connected`), `TransportConfig::maxPeers` | The host has 16 ENet peer slots. A peer that connects and never sends Hello keeps one for `joinTimeoutMs` (10 s), and ENet keeps half-open connects from spoofed sources for its own timeout. Sixteen such connections keep real players out. | plausible, not reproduced. Needs a per-address limit on pending handshakes and a shorter host-side handshake timeout (an honest client sends Hello one round trip after the Challenge); ENet's own half-open state cannot be limited without patching it. | +| O2 | minor (security, design) | `Session.cpp:52` (`passwordProof`) | Someone who records a handshake (nonce and proof) can try passwords offline: SHA-256 of nonce and password is fast. | by design and documented in `docs/multiplayer.md`; a fix needs a slow hash or a PAKE and a protocol version bump. | +| O3 | minor | `NatPmpBackend.cpp:252` (`request`) | When the socket wait fails (for example `EINTR`), the inner loop retries at once and spins the worker thread until the retransmit interval ends (at most 2 s). | by reading; needs a short sleep or a break on error. | +| O4 | code quality | `BitStream.cpp:132` (`readQuantized`) | With `bits == 0` it returns 0/0 = NaN. No caller passes 0 (all widths are constants of 4 or more), so the network cannot reach it. | by reading; could return `min`. | +| O5 | minor | `PortMapper.cpp:332` (stale clean-up) | Two OpenMM2 processes sharing one user data directory and hosting on different ports: the second removes the first one's live mapping as "stale". | by reading; needs the owner (process id or internal port) in the record. | +| O6 | minor | `Discovery.cpp:357` | An advert's address is its source address, which a LAN attacker can spoof: a fake session can point at any IP and port, and a player who joins it sends ENet connect attempts there. | by reading; inherent to unauthenticated LAN discovery. | + +## For the other areas + +* **In-race sync**: the host relays a client's `VehicleSnapshot::time` as + sent (`Session.cpp:538`), and remote buffers interpolate between whatever + times arrive. A client with a wildly wrong time (or a hostile one) moves its + own car far from where it is; values stay finite. The host could clamp + snapshot times to a window around its own clock. +* **Cops and Robbers**: any player can send the events only the host sends + (`GoldPickedUp`, the new set `kCrNewSet`; `RaceScreen.cpp:2171-2180`) and + every machine applies them, so a modified client can take the gold or move + the places. MM2's `mmMultiCR::GameMessage` does not check the sender either + (0x25a, 0x261), so a check would be an OpenMM2 deviation: accept them only + from `kHostPlayerId`, or install a host event filter that drops them from + joiners. +* **Lobby flow**: when a client cannot load the host's city + (`RaceScreen.cpp:740`), its race screen goes back to the lobby page without + a message while the others race. +* **Vehicles**: a local car the catalog lacks (only reachable through the + `vehicle:` automation command or a hand-edited profile) leaves the player + without a car ("geometry/.pkg not found"); MM2 would load vpcoop + (`mmVehList::GetVehicleInfo`). + +## The reported problems + +Nothing in this area makes a host leave the race after loading or explains the +lobby bounce or the cars bouncing: honest clients never hit the new budgets +or name checks (the loopback host/client run into cruise behaved as before), +and none of the old input handling ended a race. Those belong to the lobby +flow and in-race sync reviews. From c1918c94ea937fcbc735215ca0b7c08556c2c8a3 Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Thu, 8 Oct 2026 22:35:36 -0700 Subject: [PATCH 24/54] Run the host's traffic and police for every player of a network cruise With the host's lobby option on (it is by default), a network cruise keeps the traffic and the police that mmGameMulti::Init takes away: - The host's lobby has a SHARED TRAFFIC panel in cruise (on/off, the single-player cruise's traffic and cop density sliders, defaults 0.5 and 1 as RaceMenuBase::SetStateRace), carried to the clients by the session settings; off gives MM2's network cruise exactly. - The host runs ai::World's traffic at its density with every player in aiMap's player list and the cruise's police posts; the other players' kinematic cars are players to the traffic and suspects to the police, and strike traffic cars as moving cars. Every 50 ms each client gets its cars (game::TrafficHost), with the light sets' step count. - A client runs no traffic or police of its own: it interpolates the received cars (game::TrafficClient), draws them with the traffic renderer and the police as cars with their sirens, plays their engines, horns and sirens (a cop chasing it starts the chase music), shows the police in pursuit on the map, collides with them as moving kinematic instances (game::TrafficProxies), runs its light sets to the host's steps, and its pedestrians shy from the received cars off their rails. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/app/RaceScreen.cpp | 497 +++++++++++++++++++++++++++- src/app/frontend/Frontend.h | 4 + src/app/frontend/FrontendScreen.cpp | 15 +- src/app/frontend/PagesMulti.cpp | 91 ++++- src/game/AiRenderer.cpp | 7 +- src/game/AiRenderer.h | 7 + src/game/TrafficBodies.cpp | 9 + src/game/TrafficBodies.h | 4 + src/game/net/TrafficProxies.cpp | 101 ++++++ src/game/net/TrafficProxies.h | 60 ++++ src/game/net/TrafficSync.cpp | 71 ++++ src/game/net/TrafficSync.h | 23 ++ src/game/session/RaceSetup.cpp | 8 +- 13 files changed, 880 insertions(+), 17 deletions(-) create mode 100644 src/game/net/TrafficProxies.cpp create mode 100644 src/game/net/TrafficProxies.h diff --git a/src/app/RaceScreen.cpp b/src/app/RaceScreen.cpp index 5c40cf3..247a50b 100644 --- a/src/app/RaceScreen.cpp +++ b/src/app/RaceScreen.cpp @@ -41,6 +41,8 @@ #include "game/fx/VehicleEffects.h" #include "game/fx/Weather.h" #include "game/net/NetGame.h" +#include "game/net/TrafficProxies.h" +#include "game/net/TrafficSync.h" #include "game/CamMirror.h" #include "game/CamPlayer.h" #include "game/CityRenderer.h" @@ -65,6 +67,8 @@ #include #include #include +#include +#include namespace mm2::app { namespace { @@ -84,6 +88,13 @@ bool serialize(S& s, CrSetEvent& e) { return s.ok(); } +// OpenMM2's shared traffic of a network cruise: the AI's ids of the other +// players' cars (ai::TrackedCar), the network ids of the police cars (after +// the traffic pool's), and how often the host sends (the snapshot rate). +constexpr int kNetPlayerTrackedId = 30000; +constexpr int kNetPoliceId = 400; +constexpr std::uint64_t kNetTrafficIntervalMs = 50; + const char* modePrefix(game::GameMode m) { switch (m) { case game::GameMode::Blitz: return "blitz"; @@ -120,6 +131,11 @@ class RaceScreen final : public Screen { ~RaceScreen() override { if (m_cityLevel && m_trafficBodies) m_cityLevel->removeSource(m_trafficBodies.get()); + if (m_cityLevel && m_trafficProxies) + m_cityLevel->removeSource(m_trafficProxies.get()); + for (auto& [id, c] : m_netCops) + if (c.audio) + c.audio->stop(); if (m_cityLevel && m_bangers) m_cityLevel->removeSource(m_bangers.get()); if (m_cityLevel && m_gizmos) @@ -286,8 +302,9 @@ class RaceScreen final : public Screen { const ai::TrackedCar player = m_player ? trackedCar(m_player->sim(), 0, true) : ai::TrackedCar{}; m_cableCars->update(static_cast(dt), m_player ? &player : nullptr, *m_world); } - if (m_ai) - m_ai->updateLights(); // the light sets last (aiMap::Update) + if (m_ai && !netTrafficClient(ctx)) + m_ai->updateLights(); // the light sets last (aiMap::Update); a shared-traffic client follows the host's + // The gizmo managers, nodes of mmGame (bridges, trains, ferries, // sailboats); the player's car is the bridges' proximity trigger. if (m_gizmos) { @@ -297,6 +314,7 @@ class RaceScreen final : public Screen { m_gizmos->update(static_cast(dt), trigger); } updateRemoteCars(ctx); + updateNetTraffic(ctx, static_cast(dt)); // OpenMM2: a client's shared traffic // aiVehicleManager::Update and the rail cars' rooms, before the // collision manager runs. if (m_trafficBodies) @@ -332,6 +350,7 @@ class RaceScreen final : public Screen { if (multiplayer(ctx)) sendLocalState(ctx); } + sendNetTraffic(ctx); // OpenMM2: the host's shared traffic if (m_flyCamera || !m_player) updateFlyCamera(ctx, static_cast(dt)); else @@ -521,6 +540,10 @@ class RaceScreen final : public Screen { for (const auto& c : m_cops) if (c.driver->mode() != ai::PoliceCar::Mode::Parked) blips.push_back({c.sim->sim().body.ics.matrix, game::session::MapBlip::Kind::Police}); + // OpenMM2's shared traffic: the host's police in pursuit, on a client. + for (const auto& [id, c] : m_netCops) + if (c.sim && (c.state.flags & net::kAmbientPursuit)) + blips.push_back({c.sim->sim().body.ics.matrix, game::session::MapBlip::Kind::Police}); return blips; } @@ -537,6 +560,7 @@ class RaceScreen final : public Screen { m_aiRenderer->draw(*m_ai, camera, frustum, m_result.config.timeOfDay, carLights(), m_detail.objects, [this](int id) { return physicalTrafficCar(id); }); drawRemoteCars(ctx, dt, camera); + drawNetCops(dt, camera); const bool night = m_result.config.timeOfDay == game::TimeOfDay::Night; if (m_bangers) m_bangers->draw(dev, *m_models, *m_textures, m_cards, frustum, camera, @@ -1221,6 +1245,10 @@ class RaceScreen final : public Screen { void spawnPolice(Context& ctx) { if (!m_session || !m_world || !m_ai) return; + // A network client drives no police: the shared traffic's come from + // the host (OpenMM2 extra; the other network games have no posts). + if (multiplayer(ctx) && !ctx.netGame->isHost()) + return; const auto& posts = m_session->police(); const std::size_t count = ai::PoliceSquad::countForDensity(posts.size(), m_result.config.copDensity); std::optional chaseDistance; @@ -1258,6 +1286,8 @@ class RaceScreen final : public Screen { livery = str::iequals(m_result.config.city, "sf") ? 0 : 1; cop.renderer = std::make_unique(ctx.device(), *m_textures, *m_models, cop.sim->model(), livery); + cop.vehicle = p.vehicle; + cop.livery = livery; cop.renderer->setSirenFlares(flares); setupVehicleRenderer(ctx, *cop.renderer); cop.audio = loadAiCarAudio(ctx, p.vehicle, true); @@ -1316,6 +1346,11 @@ class RaceScreen final : public Screen { for (const auto& o : m_opponents) racers.push_back(o.sim->sim().body.ics.matrix.m3); m_ai->setOpponents(racers); + // OpenMM2's shared traffic: on the host, the other players too. + if (netTrafficHost(ctx)) { + collectNetPlayers(ctx); + m_ai->setOtherPlayers(m_netTrafficPlayers); + } m_ai->update(dt, pc); } @@ -1336,6 +1371,10 @@ class RaceScreen final : public Screen { if (m_ai) m_ai->map().trackPlayer(player); cars.push_back(player); + // OpenMM2's shared traffic: the host's police chase the other + // players too (after the local one, aiMap::Player(0)). + for (const auto& t : m_netTrackedPlayers) + cars.push_back(t); for (const auto& o : m_opponents) { ai::TrackedCar t = trackedCar(o.sim->sim(), 1 + static_cast(o.sessionIndex)); t.suspect = true; @@ -1463,7 +1502,9 @@ class RaceScreen final : public Screen { audio::game::CreatureVoice::advanceClock(dt); audio::game::CreatureVoice::advanceClock(dt); std::vector seen; - for (const ai::AmbientCar& c : m_ai->cars()) { + // (A shared-traffic client hears the cars the host sends.) + const auto& ambient = netTrafficClient(ctx) ? m_netCars : m_ai->cars(); + for (const ai::AmbientCar& c : ambient) { AmbientAudio* a = ambientAudio(ctx, c); if (!a) continue; @@ -1491,6 +1532,11 @@ class RaceScreen final : public Screen { if (AmbientAudio* a = ambientAudioOf(id); a && a->car.honk()) a->car.avoidReaction(); } + // A shared-traffic client: the horns the host's cars sounded. + if (netTrafficClient(ctx)) + for (const ai::AmbientCar& c : m_netCars) + if (AmbientAudio* a = c.horn ? ambientAudioOf(c.id) : nullptr; a && a->car.honk()) + a->car.avoidReaction(); // aiVehicleActive's impact callback: AudImpact::Play and // PlayImpactHorn with |x| + |y| + |z| of the impulse, then // PlayImpactReaction. @@ -2837,8 +2883,12 @@ class RaceScreen final : public Screen { settings.maxPeds = std::max(0, *ctx.commandLine.pedPool); // mmGameMulti::Init: no traffic (nor cops, racers or rail cars) in // multiplayer cruise and Cops and Robbers; the pedestrians stay, at - // the host's density (the only density the session carries). - if (multiplayer(ctx)) + // the host's density, each machine its own. OpenMM2 extra (the host's + // lobby option, on by default): in cruise the host runs the traffic + // at its traffic density, and the police, for every player + // (sendNetTraffic); the clients run none and show the host's + // (updateNetTraffic). + if (multiplayer(ctx) && !netTrafficHost(ctx)) settings.trafficDensity = 0.0f; // mmSingleStunt::LoadEventFile sets the traffic density to the last // event's AmbDensity before aiMap::Init reads it. @@ -2908,6 +2958,7 @@ class RaceScreen final : public Screen { return true; }); } + setupNetTraffic(ctx); } // aiMap's load lists the props standing in the city for the pedestrians @@ -3273,6 +3324,7 @@ class RaceScreen final : public Screen { // The listener follows the camera. ctx.mixer->setListener(m_camera.transform, m_player->sim().body.ics.frameVelocity); updateAiAudio(dt); + updateNetCopsAudio(ctx, dt); updateAmbientAudio(ctx, dt); updatePedestrianAudio(dt); // mmGame::Update's mmSpeechContainer::Update (AudSpeech::Update); the @@ -3291,6 +3343,399 @@ class RaceScreen final : public Screen { m_rain.update(m_result.config.weather == game::Weather::Rain, interior, m_tunnel, dt); } + // --- Shared traffic of a network cruise (OpenMM2 extra) --------------------------------- + // MM2's network cruise has neither traffic nor police (mmGameMulti::Init). + // With the host's lobby option on, the host runs both for every player + // and sends each client the cars near it (game/net/TrafficSync, + // docs/multiplayer.md "Shared traffic"); the clients draw, hear and + // collide with them, and their pedestrians stay their own. + + bool netTraffic(Context& ctx) const { + return multiplayer(ctx) && m_result.config.mode == game::GameMode::Cruise && m_result.config.netTraffic; + } + bool netTrafficHost(Context& ctx) const { return netTraffic(ctx) && ctx.netGame->isHost(); } + bool netTrafficClient(Context& ctx) const { return netTraffic(ctx) && !ctx.netGame->isHost(); } + + // The models a shared car can be (the traffic's types, then the police + // posts' cars), and the host's or the client's side. + void setupNetTraffic(Context& ctx) { + if (!netTraffic(ctx) || !m_ai) + return; + std::vector police; + if (m_session) + for (const auto& p : m_session->police()) + police.push_back(p.vehicle); + m_trafficCatalog = game::buildTrafficCatalog(m_ai->traffic().types(), police); + if (ctx.netGame->isHost()) { + m_trafficHost.emplace(); + } else { + m_trafficClient.emplace(m_trafficCatalog.size(), m_trafficCatalog.checksum()); + if (m_aiRenderer) + m_aiRenderer->setCars(&m_netCars); + if (m_cityLevel) { + m_trafficProxies = std::make_unique(m_cityLevel.get()); + m_cityLevel->addSource(m_trafficProxies.get()); + } + // The pedestrians shy away from the received cars that are out of + // normal driving, as from the host's (aiPedestrian::Accident). + m_ai->pedestrians().setAccidentQuery( + [this](int node, int path, int) { return netAccidentAt(node, path); }); + } + log::info("race: shared traffic, {} ({} models, catalog {:04x})", ctx.netGame->isHost() ? "host" : "client", + m_trafficCatalog.size(), m_trafficCatalog.checksum()); + } + + // The other players as the host's traffic and police see them: their + // kinematic cars (updateRemoteCars) with the controls they sent. + void collectNetPlayers(Context& ctx) { + m_netTrafficPlayers.clear(); + m_netTrackedPlayers.clear(); + for (const auto& rc : ctx.netGame->remoteCars()) { + const auto it = m_remotes.find(rc.id); + if (!rc.hasState || it == m_remotes.end() || !it->second.sim || rc.id == 0 || + rc.id >= ai::kMaxTrafficPlayers) + continue; + const phys::CarSim& sim = it->second.sim->sim(); + ai::PlayerCar pc; + pc.transform = sim.body.ics.matrix; + pc.velocity = rc.velocity; + pc.width = sim.params.inertiaBox.x; + pc.length = sim.params.inertiaBox.z; + pc.radius = sim.body.radius(); + pc.steering = rc.controls.steering; + pc.reversing = rc.controls.gear < 0; + pc.horn = (rc.flags & net::kVehicleHorn) != 0; + m_netTrafficPlayers.push_back({rc.id, pc}); + ai::TrackedCar t = trackedCar(sim, kNetPlayerTrackedId + rc.id, true); + t.velocity = rc.velocity; + t.speed = std::abs(rc.velocity.dot(sim.body.ics.matrix.m2)); + t.suspect = true; + t.reversing = rc.controls.gear < 0; + if (m_ai) + m_ai->map().trackPlayer(t); + m_netTrackedPlayers.push_back(t); + } + } + + // Host: every 50 ms, each client its cars (the police chasing it, then + // the nearest) with the light sets' steps. + void sendNetTraffic(Context& ctx) { + if (!m_trafficHost || !m_ai || !netTrafficHost(ctx)) + return; + // aiGoalAvoidPlayer::Reset's horn (the car's horn flag, for the step + // it started avoiding) waits for the next message. + for (const ai::AmbientCar& c : m_ai->cars()) + if (c.horn) + m_hornLatch.insert(c.id); + const std::uint64_t now = net::monotonicMs(); + if (now - m_trafficSentAt < kNetTrafficIntervalMs) + return; + m_trafficSentAt = now; + std::vector cars; + cars.reserve(m_ai->cars().size() + m_cops.size()); + for (const ai::AmbientCar& c : m_ai->cars()) { + const int model = m_trafficCatalog.find(c.model); + if (model < 0 || c.id < 0 || c.id >= kNetPoliceId) + continue; + int paint = 0; + if (m_aiRenderer) { + const int jobs = m_aiRenderer->paintJobs(c.model); + paint = jobs > 1 ? static_cast(c.paint * static_cast(jobs - 1)) : 0; + } + std::optional body; + if (const Mat34* m = m_trafficBodies ? m_trafficBodies->transformOf(c.id) : nullptr) { + body.emplace(); + body->transform = *m; + m_trafficBodies->motionOf(c.id, body->velocity, body->angularVelocity); + } + cars.push_back(game::shareTrafficCar(c, model, paint, body ? &*body : nullptr, m_hornLatch.contains(c.id))); + } + m_hornLatch.clear(); + for (std::size_t i = 0; i < m_cops.size(); ++i) { + const Cop& cop = m_cops[i]; + const int model = m_trafficCatalog.find(cop.vehicle); + if (model < 0 || kNetPoliceId + static_cast(i) >= static_cast(net::kMaxAmbientIds)) + continue; + const phys::CarSim& sim = cop.sim->sim(); + game::SharedCar s; + s.id = kNetPoliceId + static_cast(i); + s.kind = net::AmbientKind::Police; + s.generation = cop.resets; + s.model = model; + s.paint = cop.livery; + s.transform = sim.modelMatrix(); + s.velocity = sim.body.ics.linearVelocity; + s.angularVelocity = sim.body.ics.angularVelocity; + s.speed = sim.speed(); + const auto mode = cop.driver->mode(); + if (cop.driver->siren()) + s.flags |= net::kAmbientSiren; + if (mode != ai::PoliceCar::Mode::Parked) // aiPoliceOfficer::InPersuit + s.flags |= net::kAmbientPursuit; + if (mode == ai::PoliceCar::Mode::Disabled) + s.flags |= net::kAmbientWrecked; + if (sim.brakes > 0.05f) + s.flags |= net::kAmbientBrake; + const int target = cop.driver->target(); + if (target == 0) + s.target = ctx.netGame->localId(); + else if (target > kNetPlayerTrackedId && target < kNetPlayerTrackedId + ai::kMaxTrafficPlayers) + s.target = static_cast(target - kNetPlayerTrackedId); + s.damage = sim.damage.damage; + s.rpm = sim.engine.rpm; + s.throttle = sim.engine.throttle; + s.gear = sim.trans.getCurrentGear(); + cars.push_back(s); + } + const std::uint32_t time = ctx.netGame->sessionTime(); + std::set viewers; + for (const auto& rc : ctx.netGame->remoteCars()) { + if (!rc.hasState) + continue; + viewers.insert(rc.id); + const auto msg = m_trafficHost->build({rc.id, rc.transform.m3}, cars, time, m_ai->lightSteps(), + m_trafficCatalog.checksum()); + const std::size_t bytes = ctx.netGame->sendAmbientState(rc.id, msg); + auto& st = m_trafficSent[rc.id]; + st.bytes += bytes; + st.messages += bytes > 0 ? 1 : 0; + st.cars += msg.entities.size(); + if (m_debugNetTraffic) // every message: the clients' logs are compared with these + for (const auto& e : msg.entities) + if (e.position.dist(rc.transform.m3) < 120.0f) + log::info("nettraffic: host t {} to {} id {} gen {} at {:.2f} {:.2f} {:.2f}", time, rc.id, e.id, + e.generation, e.position.x, e.position.y, e.position.z); + } + for (auto it = m_trafficSent.begin(); it != m_trafficSent.end();) { + if (viewers.contains(it->first)) { + ++it; + continue; + } + m_trafficHost->forget(it->first); + it = m_trafficSent.erase(it); + } + // Bandwidth, every 10 s. + if (m_trafficStatsAt == 0) + m_trafficStatsAt = now; + if (now - m_trafficStatsAt >= 10000) { + const double seconds = static_cast(now - m_trafficStatsAt) / 1000.0; + for (auto& [id, st] : m_trafficSent) { + log::info("nettraffic: to player {}: {:.1f} msg/s, {:.0f} B/s, {:.1f} cars a message", id, + static_cast(st.messages) / seconds, static_cast(st.bytes) / seconds, + st.messages ? static_cast(st.cars) / static_cast(st.messages) : 0.0); + st = {}; + } + m_trafficStatsAt = now; + } + } + + // The client's vehicle data of a traffic model (null: not a traffic model). + const ai::VehicleData* netVehicleData(const std::string& model) const { + for (const ai::VehicleData& d : m_ai->traffic().types()) + if (str::iequals(d.model, model)) + return &d; + return nullptr; + } + + // Client, before the physics step: the host's messages, the cars at + // the interpolation delay, the light sets at the host's steps, the + // traffic's physics proxies and the police cars. + void updateNetTraffic(Context& ctx, float dt) { + if (!m_trafficClient || !m_ai || !netTrafficClient(ctx)) + return; + for (const auto& m : ctx.netGame->takeAmbientStates()) + m_trafficClient->receive(m); + if (m_trafficClient->catalogMismatch() && !m_trafficCatalogWarned) { + log::warn("race: the host's traffic models differ from this game's data; some cars may look wrong"); + m_trafficCatalogWarned = true; + } + const double renderTime = + static_cast(ctx.netGame->sessionTime()) - m_trafficClient->options().interpolationDelayMs; + m_trafficClient->update(renderTime); + if (const auto steps = m_trafficClient->lightSteps(renderTime)) + m_ai->advanceLightsTo(*steps); + m_netCars.clear(); + std::unordered_set seen; + for (const auto& c : m_trafficClient->cars()) { + const std::string* model = m_trafficCatalog.name(c.model); + if (c.kind != net::AmbientKind::Traffic || !model) + continue; + // aiVehicleSpline::Update's tyre turn, kept here. + float& turn = m_netTireRotation[c.id]; + if (c.fresh) + turn = 0.0f; + turn += dt * c.speed; + if (turn > ai::kTireRotationWrap) + turn -= ai::kTireRotationWrap; + m_netCars.push_back(game::ambientCarOf(c, *model, netVehicleData(*model), + m_aiRenderer ? m_aiRenderer->paintJobs(*model) : 1, turn)); + seen.insert(c.id); + } + std::erase_if(m_netTireRotation, [&](const auto& e) { return !seen.contains(e.first); }); + if (m_trafficProxies) + m_trafficProxies->update(m_netCars); + // The pedestrians' accidents: the components of the cars off their rails. + m_netAccidentNodes.clear(); + m_netAccidentPaths.clear(); + for (const ai::AmbientCar& c : m_netCars) { + if (c.goal == ai::AmbientGoal::RandomDrive) + continue; + int id = 0, type = ai::kNoComponent; + int& room = m_netAccidentRooms[c.id]; + room = m_ai->map().mapComponent(c.transform.m3, id, type, room); + if (type == ai::kIntersectionComponent) + m_netAccidentNodes.insert(id); + else if (type == ai::kRoadComponent || type == ai::kShortcutComponent) + m_netAccidentPaths.insert(id); + } + updateNetCops(ctx, dt); + if (m_debugNetTraffic) { + const std::uint64_t now = net::monotonicMs(); + if (now - m_trafficLoggedAt >= 500) { + m_trafficLoggedAt = now; + const Vec3 me = m_player ? m_player->sim().modelMatrix().m3 : m_camera.position(); + for (const auto& c : m_trafficClient->cars()) + if (c.transform.m3.dist(me) < 120.0f) + log::info("nettraffic: client t {:.0f} id {} gen {} at {:.2f} {:.2f} {:.2f}{}", renderTime, + c.id, c.generation, c.transform.m3.x, c.transform.m3.y, c.transform.m3.z, + c.extrapolated ? " (extrapolated)" : ""); + } + } + const std::uint64_t now = net::monotonicMs(); + if (m_trafficStatsAt == 0) + m_trafficStatsAt = now; + if (now - m_trafficStatsAt >= 10000) { + const auto& st = m_trafficClient->stats(); + log::info("nettraffic: received {} messages ({} cars), {} refused, {} late, {} moved; showing {} cars, " + "{} police", + st.messages, st.entities, st.refused, st.outdated, st.teleports, m_netCars.size(), + m_netCops.size()); + m_trafficStatsAt = now; + } + } + + // aiPedestrian::Accident's question on a client: a received car out of + // normal driving at the intersection or on the road (inferred: the + // host's section lists are not sent, so the whole road counts). + bool netAccidentAt(int node, int path) const { + return m_netAccidentNodes.contains(node) || (path >= 0 && m_netAccidentPaths.contains(path)); + } + + // The host's police on a client: kinematic cars (as the network + // players', updateRemoteCars) at their interpolated places, drawn and + // heard as the host's cops. + void updateNetCops(Context& ctx, float dt) { + std::unordered_set seen; + for (const auto& c : m_trafficClient->cars()) { + const std::string* model = m_trafficCatalog.name(c.model); + if (c.kind != net::AmbientKind::Police || !model || !m_world) + continue; + seen.insert(c.id); + NetCop& cop = m_netCops[c.id]; + if (cop.model != *model || cop.paint != c.paint || cop.generation != c.generation) { + if (cop.sim) + cop.sim->removeFrom(*m_world); + if (cop.audio) + cop.audio->stop(); + cop = {}; + cop.model = *model; + cop.paint = c.paint; + cop.generation = c.generation; + std::string error; + cop.sim = game::SimVehicle::load(ctx.game->vfs, *model, &error, {}, false, false); + if (!cop.sim) { + log::warn("race: shared police car {}: {}", *model, error); + continue; + } + auto& sim = cop.sim->sim(); + sim.options.weatherFriction = weatherFriction(); + sim.body.kinematic = true; + sim.body.resetCollider(); + cop.sim->addTo(*m_world); + cop.sim->reset(c.transform); + cop.renderer = std::make_unique(ctx.device(), *m_textures, *m_models, + cop.sim->model(), c.paint); + setupVehicleRenderer(ctx, *cop.renderer); + cop.audio = loadAiCarAudio(ctx, *model, true); + } + if (!cop.sim) + continue; + auto& sim = cop.sim->sim(); + Mat34 ics = c.transform; + ics.m3 = c.transform.m3 - c.transform.transformDir(sim.centerOfGravity); + sim.body.place(ics); + sim.body.ics.linearVelocity = c.velocity; + sim.body.ics.angularVelocity = c.angularVelocity; + sim.body.kinematicMoves = true; + sim.body.kinematicVelocity = c.velocity; + sim.body.kinematicSpin = c.angularVelocity; + sim.body.declare(2, 0x1b); + cop.state = c; + // vehSiren::Update: the beams turn 2.5 pi a second while on. + if (c.flags & net::kAmbientSiren) + cop.sirenAngle = std::fmod(cop.sirenAngle + dt * 2.5f * 3.1415927f, 6.2831855f); + } + for (auto it = m_netCops.begin(); it != m_netCops.end();) { + if (seen.contains(it->first)) { + ++it; + continue; + } + if (it->second.sim) + it->second.sim->removeFrom(*m_world); + if (it->second.audio) + it->second.audio->stop(); + it = m_netCops.erase(it); + } + } + + void drawNetCops(float dt, const game::Camera& camera) { + const bool lights = carLights(); + for (auto& [id, c] : m_netCops) { + if (!c.sim || !c.renderer) + continue; + game::VehiclePose pose; + pose.body = c.state.transform; + // The wheels roll with the forward speed (as the network players'). + for (const auto& w : c.sim->model().wheels) { + const auto i = static_cast(std::clamp(w.index, 0, 5)); + c.spin[i] -= c.state.speed / std::max(w.radius, 0.1f) * dt; + pose.wheelSpin[i] = c.spin[i]; + } + pose.headlights = lights; + pose.brakeLights = (c.state.flags & net::kAmbientBrake) != 0; + pose.reverseLights = c.state.gear < 0; + pose.siren = (c.state.flags & net::kAmbientSiren) != 0; + pose.sirenAngle = c.sirenAngle; + c.renderer->draw(pose, camera.transform); + } + } + + // The host's police cars' engines and sirens on a client; a cop chasing + // this player counts for the cop chase music (aiPoliceOfficer::StartSiren). + void updateNetCopsAudio(Context& ctx, float dt) { + if (!netTrafficClient(ctx)) + return; + for (auto& [id, c] : m_netCops) { + if (!c.audio) + continue; + audio::game::CarAudioInputs in; + in.rpm = c.state.rpm; + in.throttle = c.state.throttle; + in.brake = (c.state.flags & net::kAmbientBrake) != 0 ? 1.0f : 0.0f; + in.speed = std::abs(c.state.speed); + in.gear = c.state.gear; + for (auto& w : in.wheels) + w.onGround = true; + in.velocity = c.state.velocity; + in.inTunnel = m_tunnel; + in.transform = c.state.transform; + in.siren = (c.state.flags & net::kAmbientSiren) != 0; + in.sirenPursuingPlayer = c.state.target == ctx.netGame->localId(); + in.wrecked = (c.state.flags & net::kAmbientWrecked) != 0; + c.audio->update(in, dt, m_camera.transform); + } + } + void sendLocalState(Context& ctx) { const auto& sim = m_player->sim(); net::VehicleControls controls; @@ -3371,6 +3816,11 @@ class RaceScreen final : public Screen { sim.body.place(ics); sim.body.ics.linearVelocity = rc.velocity; sim.body.ics.angularVelocity = rc.angularVelocity; + // OpenMM2's shared traffic: the traffic and police meet the car + // as a moving one (so does the local car). + sim.body.kinematicMoves = netTraffic(ctx); + sim.body.kinematicVelocity = rc.velocity; + sim.body.kinematicSpin = rc.angularVelocity; sim.setInputs(rc.controls.throttle, rc.controls.brake, rc.controls.steering, rc.controls.handbrake); sim.body.declare(3, 0x1b); // mmNetObject::Update if (auto* trailer = rv.sim->trailer()) { @@ -3943,6 +4393,9 @@ class RaceScreen final : public Screen { std::unique_ptr sim; std::unique_ptr renderer; ai::PoliceCar* driver = nullptr; // owned by m_police + std::string vehicle; // its [Police] car (the shared traffic's model) + int livery = 0; // its paint job + int resets = 0; // the shared traffic's generation std::unique_ptr audio; std::unique_ptr fx; std::shared_ptr> impacts = @@ -3995,6 +4448,40 @@ class RaceScreen final : public Screen { std::unique_ptr m_aiRenderer; std::unique_ptr m_trafficBodies; + // OpenMM2's shared traffic of a network cruise (see netTraffic()). + game::TrafficCatalog m_trafficCatalog; + std::optional m_trafficHost; // host: each client's cars + std::optional m_trafficClient; // client: the received cars + std::vector m_netTrafficPlayers; // host: the other players for the traffic + std::vector m_netTrackedPlayers; // host: and for the police + std::unordered_set m_hornLatch; // host: horns since the last message + std::uint64_t m_trafficSentAt = 0, m_trafficStatsAt = 0, m_trafficLoggedAt = 0; + struct TrafficSent { + std::uint64_t bytes = 0, messages = 0, cars = 0; + }; + std::map m_trafficSent; // host: per client, since the last log + std::vector m_netCars; // client: the received traffic this frame + std::unordered_map m_netTireRotation; // client: their wheels' turn + std::unique_ptr m_trafficProxies; // client: their physics + std::unordered_set m_netAccidentNodes, m_netAccidentPaths; // client: off-rail cars' components + std::unordered_map m_netAccidentRooms; + struct NetCop { + std::string model; + int paint = -1; + int generation = -1; + std::unique_ptr sim; // kinematic body + std::unique_ptr renderer; + std::unique_ptr audio; + game::TrafficClient::Car state; + std::array spin{}; + float sirenAngle = 0.0f; + }; + std::map m_netCops; // client: the host's police + bool m_trafficCatalogWarned = false; + // Development aid: OPENMM2_DEBUG_NETTRAFFIC logs the shared cars near + // each client twice a second, on the host and on the client. + bool m_debugNetTraffic = std::getenv("OPENMM2_DEBUG_NETTRAFFIC") != nullptr; + // Sound: the player's car, city ambience and rain (src/audio/game). std::unique_ptr m_bank; audio::game::PlayerCarAudio m_carAudio; diff --git a/src/app/frontend/Frontend.h b/src/app/frontend/Frontend.h index b63907f..c8ccc21 100644 --- a/src/app/frontend/Frontend.h +++ b/src/app/frontend/Frontend.h @@ -200,6 +200,10 @@ std::unique_ptr makeEjectDialog(Frontend& fe); void drawMenuPointer(Frontend& fe, ui::UiFrame& f); // Automation helpers: host with the current config / join an address. bool frontendHostSession(Frontend& fe, const std::string& password = {}); +// A network session's traffic and cop densities and the shared traffic +// option (OpenMM2 extra) for its mode: a cruise takes the single-player +// cruise's defaults with the option on, the other modes none. +void applyNetTrafficDefaults(game::RaceConfig& cfg); void frontendJoinSession(Frontend& fe, const std::string& address, const std::string& password); // MM2's menu ids: the first column of tune/menu.csv and the second of diff --git a/src/app/frontend/FrontendScreen.cpp b/src/app/frontend/FrontendScreen.cpp index e7f0eb8..bfaec7a 100644 --- a/src/app/frontend/FrontendScreen.cpp +++ b/src/app/frontend/FrontendScreen.cpp @@ -750,7 +750,8 @@ void Script::run(Frontend& fe, const std::string& cmd, const std::string& arg) { fe.startRace(); } else if (cmd == "mp") { // Multiplayer automation: mp:host[:], mp:join:
[|], mp:chat:, - // mp:ready, mp:start, mp:team:<0|1>, mp:mode:. + // mp:ready, mp:start, mp:team:<0|1>, mp:mode:, + // mp:traffic:[::] (the shared cruise traffic). const auto sub = arg.substr(0, arg.find(':')); const auto rest = arg.find(':') == std::string::npos ? std::string() : arg.substr(arg.find(':') + 1); if (sub == "host") { @@ -782,6 +783,18 @@ void Script::run(Frontend& fe, const std::string& cmd, const std::string& arg) { c.raceIndex = c.mode == GameMode::Cruise || c.mode == GameMode::CopsAndRobbers ? -1 : 0; if (c.mode == GameMode::CopsAndRobbers) c.timeLimitMinutes = 10; + const bool traffic = c.netTraffic; + applyNetTrafficDefaults(c); // as the host settings' game type lamps + c.netTraffic = traffic; + fe.ctx.netGame->setRaceConfig(c); + } else if (fe.ctx.netGame && sub == "traffic") { + game::RaceConfig c = fe.ctx.netGame->raceConfig(); + const auto parts = str::split(rest, ':'); + c.netTraffic = parts.empty() || parts[0] != "off"; + if (parts.size() >= 3) { + c.trafficDensity = std::clamp(static_cast(str::parseDouble(parts[1]).value_or(0.5)), 0.0f, 1.0f); + c.copDensity = std::clamp(static_cast(str::parseDouble(parts[2]).value_or(1.0)), 0.0f, 1.0f); + } fe.ctx.netGame->setRaceConfig(c); } } else if (cmd == "result") { diff --git a/src/app/frontend/PagesMulti.cpp b/src/app/frontend/PagesMulti.cpp index 367514b..2fce8d6 100644 --- a/src/app/frontend/PagesMulti.cpp +++ b/src/app/frontend/PagesMulti.cpp @@ -15,8 +15,10 @@ #include "core/Paths.h" #include "core/StringUtil.h" #include "game/net/NetGame.h" +#include "game/session/RaceSetup.h" #include +#include #include #include #include @@ -357,9 +359,9 @@ class HostOptionsDialog final : public Page { cfg.laps = 3; // HostRaceMenu (RaceMenuBase::Init(1)) has no traffic, cop or // opponent settings, and mmGameMulti::Init runs the network games - // without traffic, police or racers: the session carries none. - cfg.trafficDensity = 0.0f; - cfg.copDensity = 0.0f; + // without traffic, police or racers. OpenMM2's shared cruise traffic + // takes the single-player cruise's densities (applyNetTrafficDefaults). + applyNetTrafficDefaults(cfg); cfg.opponents = 0; cfg.multiplayer = true; game::NetHostOptions opts; @@ -1100,6 +1102,23 @@ class HostSettingsPage final : public Page { m_goldBox->help = "jpg/host_gm.jpg"; m_goldArrows = arrows(fe, 20, {530, 220}, {530, 238}, *m_goldBox); + // OpenMM2 extra: the shared traffic of a network cruise, in the + // upper right panel that cruise leaves empty: on or off (off is MM2's + // network cruise, with neither traffic nor police), and the + // single-player cruise's traffic and cop density sliders. Their + // places and the panel's look are OpenMM2's. + m_trafficBox = &menu.add( + Box{kTrafficValueX, kTrafficRowY[0], 123, 24}, + [] { return std::vector{"Off", "On"}; }, [this] { return m_cfg.netTraffic ? 1 : 0; }, + [this](int i) { m_cfg.netTraffic = i != 0; }); + m_trafficSliders[0] = &menu.add( + Box{kTrafficValueX, kTrafficRowY[1], 139, 31}, [this] { return m_cfg.trafficDensity; }, + [this](float v) { m_cfg.trafficDensity = v; }); + // A cop density above 1 (none from this menu) shows as full. + m_trafficSliders[1] = &menu.add( + Box{kTrafficValueX, kTrafficRowY[2], 139, 31}, [this] { return std::min(m_cfg.copDensity, 1.0f); }, + [this](float v) { m_cfg.copDensity = v; }); + // 22-24: RACE LOCALE; 25-27: TIME (629-632); 28-30: WEATHER (625-628: // the menu offers no snow, RaceMenuBase::IncWeather stops at 3); all // with clamping arrows. @@ -1167,6 +1186,12 @@ class HostSettingsPage final : public Page { m_limitArrows.show(m_limitValue->visible); m_goldBox->visible = cr; m_goldArrows.show(cr); + const bool cruise = m_cfg.mode == GameMode::Cruise; + m_trafficBox->visible = cruise; + for (auto* s : m_trafficSliders) { + s->visible = cruise; + s->enabled = m_cfg.netTraffic; + } } // The panels go under the widgets: their value boxes and lamps sit on them. @@ -1180,6 +1205,8 @@ class HostSettingsPage final : public Page { ui::drawImage(f.overlay, f.textures.get("jpg/host_rnm.jpg"), 267, 59); if (m_cfg.mode == GameMode::Circuit) ui::drawImage(f.overlay, f.textures.get("jpg/host_lap.jpg"), 267, 94); + if (m_cfg.mode == GameMode::Cruise) + drawTrafficPanel(f); // Race map in the lower left panel, as on the races screen. std::string pic; if (race) @@ -1193,6 +1220,34 @@ class HostSettingsPage final : public Page { } private: + // OpenMM2's shared traffic panel (see the constructor): the rows' labels + // and the dark boxes the value box and sliders sit on, in the style of + // host_cr's. + static constexpr float kTrafficValueX = 470.0f; + static constexpr float kTrafficRowY[3] = {70.0f, 128.0f, 186.0f}; + void drawTrafficPanel(ui::UiFrame& f) const { + f.overlay.rect(267, 44, 373, 214, render::packColor(0, 0, 0, 110)); + const char* labels[3] = {"SHARED TRAFFIC", "TRAFFIC DENSITY", "COP DENSITY"}; + const float heights[3] = {24.0f, 31.0f, 31.0f}; + const float widths[3] = {123.0f, 139.0f, 139.0f}; + const auto font = ui::style::valueFont(); + for (int i = 0; i < 3; ++i) { + const float y = kTrafficRowY[i]; + const float lh = f.text.lineHeight(f.overlay, font); + const float ty = y + (heights[i] - lh) * 0.5f; + f.text.draw(f.overlay, font, labels[i], 287, ty + 1, render::packColor(0, 0, 0)); + f.text.draw(f.overlay, font, labels[i], 286, ty, render::packColor(255, 255, 255)); + f.overlay.rect(kTrafficValueX - 2, y - 2, widths[i] + 4, heights[i] + 4, render::packColor(70, 70, 170)); + f.overlay.rect(kTrafficValueX, y, widths[i], heights[i], render::packColor(8, 8, 40)); + if (i > 0) + f.overlay.rect(278, y - 13, 352, 2, render::packColor(20, 20, 60, 200)); + } + if (!m_cfg.netTraffic) + f.text.drawWrapped(f.overlay, ui::style::smallFont(), + "Off: no traffic and no police, as in the original game.", 286, 228, 340, + render::packColor(255, 255, 255), ui::Align::Left); + } + // The roller_up / roller_down buttons beside a box (clamping). struct Arrows { ui::SpriteButton* up = nullptr; @@ -1265,11 +1320,12 @@ class HostSettingsPage final : public Page { // RaceMenuBase::SetStateRace: the race table's time of day, weather, // pedestrian density and laps (cruise: noon, clear, 0.25), which the // host can then change. The host menu has no traffic, cops or - // opponents. + // opponents, but OpenMM2's shared cruise traffic (applyNetTrafficDefaults). void applyDefaults(Frontend& fe) { fe.applyRaceDefaults(m_cfg); - m_cfg.trafficDensity = 0.0f; - m_cfg.copDensity = 0.0f; + const bool traffic = m_cfg.netTraffic; + applyNetTrafficDefaults(m_cfg); + m_cfg.netTraffic = traffic; // the host's choice stays m_cfg.opponents = 0; } @@ -1316,6 +1372,8 @@ class HostSettingsPage final : public Page { ui::ValueBox* m_goldBox = nullptr; Arrows m_goldArrows; std::vector m_crItems; + ui::ValueBox* m_trafficBox = nullptr; + std::array m_trafficSliders{}; }; // --- Eject (ejct_dlg) ------------------------------------------------------------------------- @@ -1378,6 +1436,24 @@ std::unique_ptr makeLobbyPage(Frontend& fe) { return std::make_unique makeHostSettingsPage(Frontend& fe) { return std::make_unique(fe); } std::unique_ptr makeEjectDialog(Frontend& fe) { return std::make_unique(fe); } +void applyNetTrafficDefaults(game::RaceConfig& cfg) { + // OpenMM2 extra, the shared traffic of a network cruise: the + // single-player cruise's traffic and cop densities + // (RaceMenuBase::SetStateRace: 0.5 and 1) with the option on; the other + // network games have neither (mmGameMulti::Init). + game::RaceConfig cruise; + cruise.mode = GameMode::Cruise; + game::session::applyRaceTableDefaults(cruise, nullptr); + cfg.netTraffic = true; + if (cfg.mode == GameMode::Cruise) { + cfg.trafficDensity = cruise.trafficDensity; + cfg.copDensity = cruise.copDensity; + } else { + cfg.trafficDensity = 0.0f; + cfg.copDensity = 0.0f; + } +} + bool frontendHostSession(Frontend& fe, const std::string& password) { NetGame& net = ensureNetGame(fe); game::RaceConfig cfg = fe.config; @@ -1386,8 +1462,7 @@ bool frontendHostSession(Frontend& fe, const std::string& password) { cfg.mode = GameMode::Cruise; if (cfg.laps <= 0) cfg.laps = 3; - cfg.trafficDensity = 0.0f; // as HostOptionsDialog::host - cfg.copDensity = 0.0f; + applyNetTrafficDefaults(cfg); // as HostOptionsDialog::host cfg.opponents = 0; net.stopLanScan(); std::string error; diff --git a/src/game/AiRenderer.cpp b/src/game/AiRenderer.cpp index 50fba45..f5c9626 100644 --- a/src/game/AiRenderer.cpp +++ b/src/game/AiRenderer.cpp @@ -247,7 +247,7 @@ void AiRenderer::draw(const ai::World& world, const Camera& camera, const Frustu const Vec3 eye = camera.position(); const int blinkClock = world.blinkClock(); const bool rooms = m_rooms && m_rooms->active(); - for (const auto& car : world.cars()) { + for (const auto& car : m_carsOverride ? *m_carsOverride : world.cars()) { const std::optional physical = physicalCar ? physicalCar(car.id) : std::nullopt; const Mat34& transform = physical ? physical->transform : car.transform; // cityLevel::DrawRooms: from the car's room; the renderer's @@ -340,6 +340,11 @@ void AiRenderer::draw(const ai::World& world, const Camera& camera, const Frustu } } +int AiRenderer::paintJobs(const std::string& model) { + const CarModel* cm = carModel(model); + return cm && cm->model ? cm->paintjobs : 1; +} + RoomVisibility::Passes AiRenderer::roomPasses(std::unordered_map& rooms, int id, const Vec3& position) { int& room = rooms[id]; room = m_rooms->findRoom(position, room); diff --git a/src/game/AiRenderer.h b/src/game/AiRenderer.h index 444271a..3a20107 100644 --- a/src/game/AiRenderer.h +++ b/src/game/AiRenderer.h @@ -65,6 +65,12 @@ class AiRenderer { // in (RoomVisibility). void setRooms(const RoomVisibility* rooms) { m_rooms = rooms; } + // OpenMM2 extra (a network client of the shared cruise traffic): draws + // these cars instead of the world's traffic; null draws the world's. + void setCars(const std::vector* cars) { m_carsOverride = cars; } + // The paint jobs of a traffic model (its package's), 1 without any. + int paintJobs(const std::string& model); + struct Stats { int cars = 0; int peds = 0; @@ -105,6 +111,7 @@ class AiRenderer { // spline, aiVehicleActive::Update), the pedestrians (aiPedestrian::Update) // and the signals (aiTrafficLightSet::SetFourWay), by id or index. std::unordered_map m_carRooms, m_pedRooms, m_signalRooms; + const std::vector* m_carsOverride = nullptr; }; // aiTrafficLightInstance::DrawGlow's matrix for the glow and WALK meshes, diff --git a/src/game/TrafficBodies.cpp b/src/game/TrafficBodies.cpp index 5e69c70..306c6b0 100644 --- a/src/game/TrafficBodies.cpp +++ b/src/game/TrafficBodies.cpp @@ -691,6 +691,15 @@ void TrafficBodies::instancesIn(int room, std::vector& out) con out.push_back(car); } +bool TrafficBodies::motionOf(int carId, Vec3& velocity, Vec3& spin) const { + const RailCar* r = findRailCar(carId); + if (!r || !r->active) + return false; + velocity = r->active->body.ics.linearVelocity; + spin = r->active->body.ics.angularVelocity; + return true; +} + std::optional TrafficBodies::wheelsOf(int carId) const { // aiVehicleInstance::Draw with the car's active: WHL0-3 at the four // vehWheelCheaps' drawing matrices; WHL4 / WHL5 unturned at their pivots diff --git a/src/game/TrafficBodies.h b/src/game/TrafficBodies.h index e638e01..a5e4716 100644 --- a/src/game/TrafficBodies.h +++ b/src/game/TrafficBodies.h @@ -94,6 +94,10 @@ class TrafficBodies final : public InstanceSource { std::array valid{}; }; std::optional wheelsOf(int carId) const; + // The velocity and spin of a car that has a body (its ICS's); false + // without one (OpenMM2: the shared traffic of a network cruise sends + // them). + bool motionOf(int carId, Vec3& velocity, Vec3& spin) const; // Cars with a body (at most 32). std::size_t activeCount() const { return static_cast(m_count); } diff --git a/src/game/net/TrafficProxies.cpp b/src/game/net/TrafficProxies.cpp new file mode 100644 index 0000000..8b0692b --- /dev/null +++ b/src/game/net/TrafficProxies.cpp @@ -0,0 +1,101 @@ +// The shared traffic of a network cruise in a client's physics world +// (OpenMM2 extra). See TrafficProxies.h. +#include "game/net/TrafficProxies.h" + +#include +#include + +namespace mm2::game { + +// A received traffic car: aiVehicleInstance's box and place (as +// TrafficBodies' rail cars), and its motion. +class TrafficProxies::Proxy final : public phys::Instance { +public: + const phys::Bound* bound(int) const override { return box; } + const Mat34& matrix() const override { return transform; } + // aiVehicleInstance::GetPosition: one up-axis above the model origin. + Vec3 position() const override { return transform.m3 + transform.m1; } + float radius() const override { return sphereRadius; } + bool kinematicMotion(Vec3& v, Vec3& w, Vec3& centre) const override { + v = velocity; + w = spin; + centre = position(); + return true; + } + + const ai::VehicleData* data = nullptr; + const phys::BoundBox* box = nullptr; + float sphereRadius = 0.0f; + Mat34 transform; + Vec3 velocity, spin; + bool seen = false; +}; + +TrafficProxies::TrafficProxies(const phys::Level* level) : m_level(level) {} + +TrafficProxies::~TrafficProxies() = default; + +const TrafficProxies::BoundEntry& TrafficProxies::boundFor(const ai::VehicleData& data) { + // aiVehicleManager::AddVehicleDataEntry's box, as TrafficBodies builds it. + BoundEntry& entry = m_bounds[&data]; + if (entry.box) + return entry; + entry.box = std::make_unique(); + entry.box->makeOwnMaterial(); + entry.box->setOffset(data.cg); + entry.box->setSize(data.size); + const Vec3& c = entry.box->centroid; + const float dy = c.y - 1.0f; + entry.radius = std::sqrt(c.x * c.x + dy * dy + c.z * c.z) + entry.box->radius; + return entry; +} + +void TrafficProxies::update(std::span cars) { + for (auto& [id, p] : m_proxies) + p->seen = false; + for (const ai::AmbientCar& c : cars) { + if (!c.data) + continue; + auto& slot = m_proxies[c.id]; + if (!slot) + slot = std::make_unique(); + Proxy& p = *slot; + if (p.data != c.data) { + const BoundEntry& b = boundFor(*c.data); + p.data = c.data; + p.box = b.box.get(); + p.sphereRadius = b.radius; + p.room = 0; + } + p.transform = c.transform; + p.velocity = c.velocity; + p.spin = {}; + p.seen = true; + p.collidable = true; + p.room = m_level ? m_level->findRoom(p.position(), p.room) : 0; + } + std::erase_if(m_proxies, [](const auto& e) { return !e.second->seen; }); + m_byRoom.clear(); + for (auto& [id, p] : m_proxies) + m_byRoom.emplace_back(p->room, p.get()); + std::ranges::sort(m_byRoom, [](const auto& a, const auto& b) { return a.first < b.first; }); +} + +std::vector TrafficProxies::takeHits() { + std::vector hits; + for (auto& [id, p] : m_proxies) { + if (p->hitByPlayer) + hits.push_back(id); + p->hitByPlayer = false; + } + std::ranges::sort(hits); + return hits; +} + +void TrafficProxies::instancesIn(int room, std::vector& out) const { + const auto range = std::ranges::equal_range(m_byRoom, room, {}, &std::pair::first); + for (const auto& [r, p] : range) + out.push_back(p); +} + +} // namespace mm2::game diff --git a/src/game/net/TrafficProxies.h b/src/game/net/TrafficProxies.h new file mode 100644 index 0000000..6af35a4 --- /dev/null +++ b/src/game/net/TrafficProxies.h @@ -0,0 +1,60 @@ +#pragma once + +// The shared traffic of a network cruise in a client's physics world +// (OpenMM2 extra; see TrafficSync.h): each received traffic car is an +// instance of the level with its aiVehicleData box (as the host's rail cars, +// game::TrafficBodies), at its interpolated place and moving at its +// interpolated velocity (phys::Instance::kinematicMotion). The local car +// collides with it as with a moving car of infinite mass; nothing knocks it +// loose here, since only the host simulates the traffic: the host sees the +// client's car hit its own car, and the result comes back with the next +// messages. + +#include "ai/Traffic.h" +#include "game/CityLevel.h" +#include "phys/Bound.h" +#include "phys/Level.h" + +#include +#include +#include +#include +#include + +namespace mm2::game { + +class TrafficProxies final : public InstanceSource { +public: + // `level` gives the cars their rooms; the level must list instancesIn() + // (CityLevel::addSource). + explicit TrafficProxies(const phys::Level* level); + ~TrafficProxies() override; + TrafficProxies(const TrafficProxies&) = delete; + TrafficProxies& operator=(const TrafficProxies&) = delete; + + // The received traffic cars this frame, before the physics step (cars + // without vehicle data are left out). The ids are the network's. + void update(std::span cars); + // The cars the local player's car hit in the last step (the level's + // hit-by-player mark), cleared. + std::vector takeHits(); + + // InstanceSource. + void instancesIn(int room, std::vector& out) const override; + std::size_t size() const { return m_byRoom.size(); } + +private: + class Proxy; + struct BoundEntry { + std::unique_ptr box; + float radius = 0.0f; + }; + const BoundEntry& boundFor(const ai::VehicleData& data); + + const phys::Level* m_level; + std::unordered_map> m_proxies; + std::unordered_map m_bounds; + std::vector> m_byRoom; // sorted by room +}; + +} // namespace mm2::game diff --git a/src/game/net/TrafficSync.cpp b/src/game/net/TrafficSync.cpp index 8f83ce2..df9b374 100644 --- a/src/game/net/TrafficSync.cpp +++ b/src/game/net/TrafficSync.cpp @@ -286,6 +286,77 @@ void TrafficClient::update(double renderTime) { } } +TrafficCatalog buildTrafficCatalog(std::span types, std::span police) { + TrafficCatalog c; + for (const ai::VehicleData& t : types) + c.add(t.model); + for (const std::string& p : police) + c.add(p); + return c; +} + +SharedCar shareTrafficCar(const ai::AmbientCar& car, int model, int paint, const TrafficBodyState* body, bool horn) { + SharedCar s; + s.id = car.id; + s.kind = net::AmbientKind::Traffic; + s.generation = car.spawns; + s.model = model; + s.paint = paint; + s.transform = body ? body->transform : car.transform; + s.speed = car.speed; + s.velocity = body ? body->velocity : car.velocity; + s.angularVelocity = body ? body->angularVelocity : Vec3{}; + std::uint8_t f = 0; + if (car.braking) + f |= net::kAmbientBrake; + if (horn) + f |= net::kAmbientHorn; + if (car.signal == ai::TurnSignal::Left || car.signal == ai::TurnSignal::Hazard) + f |= net::kAmbientSignalLeft; + if (car.signal == ai::TurnSignal::Right || car.signal == ai::TurnSignal::Hazard) + f |= net::kAmbientSignalRight; + // Out of normal driving (aiObstacle::InAccident: any goal but driving + // its rail), or simulated. + if (body || car.physical || car.goal != ai::AmbientGoal::RandomDrive) + f |= net::kAmbientOffRail; + if (car.wreck) + f |= net::kAmbientWrecked; + s.flags = f; + return s; +} + +ai::AmbientCar ambientCarOf(const TrafficClient::Car& car, const std::string& model, const ai::VehicleData* data, + int paintJobs, float tireRotation) { + ai::AmbientCar a; + a.id = car.id; + a.data = data; + a.model = model; + // AiRenderer's paint job is trunc(paint x (jobs - 1)): the middle of the + // job's range gives it back exactly. + a.paint = paintJobs > 1 ? (static_cast(car.paint) + 0.5f) / static_cast(paintJobs - 1) : 0.0f; + a.transform = car.transform; + a.velocity = car.velocity; + a.speed = car.speed; + a.tireRotation = tireRotation; + a.braking = (car.flags & net::kAmbientBrake) != 0; + const bool left = (car.flags & net::kAmbientSignalLeft) != 0; + const bool right = (car.flags & net::kAmbientSignalRight) != 0; + a.signal = left && right ? ai::TurnSignal::Hazard + : left ? ai::TurnSignal::Left + : right ? ai::TurnSignal::Right + : ai::TurnSignal::None; + // The blink phase is the host's slot's own random number; the client + // keeps one per id (inferred: the blinking need not match). + a.blinkPhase = (car.id * 37) & 0xFF; + a.horn = car.hornStarted; + const bool offRail = (car.flags & net::kAmbientOffRail) != 0; + a.goal = offRail ? ai::AmbientGoal::Collision : ai::AmbientGoal::RandomDrive; + a.physical = offRail; + a.wreck = (car.flags & net::kAmbientWrecked) != 0; + a.spawns = car.generation; + return a; +} + std::optional TrafficClient::lightSteps(double renderTime) const { if (!m_any) return std::nullopt; diff --git a/src/game/net/TrafficSync.h b/src/game/net/TrafficSync.h index 2642f5a..7ef696e 100644 --- a/src/game/net/TrafficSync.h +++ b/src/game/net/TrafficSync.h @@ -17,6 +17,7 @@ // the host's cars in and sends the messages (game::NetGame), and feeds the // client's messages in and draws, hears and collides with what comes out. +#include "ai/Traffic.h" #include "core/Math.h" #include "net/AmbientState.h" #include "net/Snapshot.h" @@ -202,4 +203,26 @@ class TrafficClient { Stats m_stats; }; +// The catalog both machines build: the ambient traffic's vehicle types in +// the AI map's order (ai::Traffic::types()), then the police posts' cars. +TrafficCatalog buildTrafficCatalog(std::span types, std::span police); + +// Host: an ambient car as shared. `model` and `paint` are its catalog index +// and paint job; `body` is its physics body's pose and motion while it has +// one (game::TrafficBodies), else null; `horn`: it honked since the last +// message. +struct TrafficBodyState { + Mat34 transform; + Vec3 velocity; + Vec3 angularVelocity; +}; +SharedCar shareTrafficCar(const ai::AmbientCar& car, int model, int paint, const TrafficBodyState* body, bool horn); + +// Client: a received traffic car as the renderer, the audio and the +// physics take an ambient car. `data` is the client's vehicle data of its +// model (null when it has none), `paintJobs` the model's paint jobs and +// `tireRotation` the wheels' turn the client keeps for it. +ai::AmbientCar ambientCarOf(const TrafficClient::Car& car, const std::string& model, const ai::VehicleData* data, + int paintJobs, float tireRotation); + } // namespace mm2::game diff --git a/src/game/session/RaceSetup.cpp b/src/game/session/RaceSetup.cpp index 8fb0676..b234269 100644 --- a/src/game/session/RaceSetup.cpp +++ b/src/game/session/RaceSetup.cpp @@ -341,7 +341,8 @@ std::optional loadRaceSetup(const RaceConfig& config, const city::Cit // race. Crash courses load all of theirs; each event's "numopp" says // which take part. Multiplayer has neither racers nor police: // mmGameMulti::Init sets the opponent and cop densities to 0 (and the race - // modes load no AI map at all). + // modes load no AI map at all); OpenMM2's shared cruise traffic brings + // the police back (below). const bool racing = config.mode == GameMode::Circuit || config.mode == GameMode::Checkpoint; if (s.aiMap && s.race && !config.multiplayer && (racing || config.mode == GameMode::CrashCourse)) { const std::string& any = !s.race->aiMap.empty() ? s.race->aiMap : s.race->waypoints; @@ -372,7 +373,10 @@ std::optional loadRaceSetup(const RaceConfig& config, const city::Cit s.opponents.push_back(std::move(op)); } } - if (s.aiMap && !config.multiplayer) { + // OpenMM2 extra: a network cruise with the host's shared traffic has + // the cruise's police too (the host drives them, the clients show them). + const bool netPolice = config.multiplayer && config.mode == GameMode::Cruise && config.netTraffic; + if (s.aiMap && (!config.multiplayer || netPolice)) { for (const auto& p : s.aiMap->police) { PoliceSetup ps; ps.vehicle = str::lower(p.car); From acb820c99d5c67ef21192ee5be537f6366d67208 Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Thu, 8 Oct 2026 22:36:23 -0700 Subject: [PATCH 25/54] Meet network cars at their relative speed The other players' cars are kinematic bodies placed every frame from their snapshots. Their colliders had no velocity, so every impact treated them as walls standing still: closing on a car doing 30 m/s at 1 m/s cost the chaser 12.6 m/s, a rear tap threw the car back and rubbing side by side dragged it as against a barrier. In MM2 those cars were simulated vehCars (mmNetObject) with velocities of their own. A kinematic collider now takes its velocity from its body's ICS (which RaceScreen fills from the snapshot), so impacts work on the relative speed; the network car still does not give way (infinite mass). Co-Authored-By: Claude Opus 5.5 (1M context) --- src/phys/Collider.cpp | 3 +- src/phys/Collider.h | 8 +++- src/phys/World.cpp | 1 + tests/phys/test_kinematic.cpp | 71 +++++++++++++++++++++++++++++++++++ 4 files changed, 81 insertions(+), 2 deletions(-) create mode 100644 tests/phys/test_kinematic.cpp diff --git a/src/phys/Collider.cpp b/src/phys/Collider.cpp index 69f7171..cd08241 100644 --- a/src/phys/Collider.cpp +++ b/src/phys/Collider.cpp @@ -18,6 +18,7 @@ void Collider::init(const Bound* b, const Mat34* m, InertialCS* inertia) { bound = b; matrix = m; ics = inertia; + motion = nullptr; handler = nullptr; joint = nullptr; body = nullptr; @@ -88,7 +89,7 @@ Mat34 Collider::copyLastMatrix(const void* pusher) const { Vec3 Collider::localVelocity(const Vec3& position) const { // phColliderBase::GetLocalVelocity. if (!ics) - return {}; + return motion ? motion->getVelocity(&position) : Vec3{}; return ics->getVelocity(&position); } diff --git a/src/phys/Collider.h b/src/phys/Collider.h index a1e0adc..5c68407 100644 --- a/src/phys/Collider.h +++ b/src/phys/Collider.h @@ -50,7 +50,7 @@ class Collider { Mat34 copyLastMatrix(const void* pusher) const; // phColliderBase::GetLocalVelocity: the ICS's velocity at a world point - // (0 without an ICS). + // (0 without an ICS; a kinematic collider's `motion`, see below). Vec3 localVelocity(const Vec3& position) const; // phColliderBase::GetInvMassMatrix / phColliderJointed::GetInvMassMatrix: // the inverse mass matrix at a world point, through the joint while it @@ -73,6 +73,12 @@ class Collider { const Bound* bound = nullptr; InertialCS* ics = nullptr; const Mat34* matrix = nullptr; // the bound's world matrix + // OpenMM2: a kinematic collider (a network player's car, which nothing + // here moves) has no ICS but moves all the same; impacts take its + // velocity from this (its body's ICS, not integrated), so a car touching + // it meets it at their relative speed instead of as a wall. MM2's network + // cars were simulated vehCars with velocities of their own. + const InertialCS* motion = nullptr; Mat34 lastMatrix; // the matrix at the end of the previous sample bool justReset = true; bool barelyMoved = false; diff --git a/src/phys/World.cpp b/src/phys/World.cpp index ac3ea4c..41a2ec4 100644 --- a/src/phys/World.cpp +++ b/src/phys/World.cpp @@ -91,6 +91,7 @@ void Body::syncBoundMatrix() { void Body::resetCollider() { ImpactHandler* handler = collider.handler; collider.init(collisionBound, &boundMatrix, kinematic ? nullptr : &ics); + collider.motion = kinematic ? &ics : nullptr; collider.handler = handler; collider.joint = joint; collider.id = audioId; diff --git a/tests/phys/test_kinematic.cpp b/tests/phys/test_kinematic.cpp new file mode 100644 index 0000000..78c2904 --- /dev/null +++ b/tests/phys/test_kinematic.cpp @@ -0,0 +1,71 @@ +// Kinematic bodies (the network players' cars, placed every frame from their +// snapshots): what a simulated body that touches one takes from it. +#include "phys/Bound.h" +#include "phys/World.h" + +#include + +#include + +using namespace mm2; +using namespace mm2::phys; + +namespace { + +struct Box { + Body body; + std::unique_ptr bound; + Box(float mass, const Vec3& position) { + bound = std::make_unique(Vec3{1, 1, 1}); + bound->makeOwnMaterial(); + bound->setElasticity(0.5f); + bound->setFriction(1.0f); + body.ics.setMass(1, 1, 1, mass); + body.ics.gravity = {0, 0, 0}; + body.collisionBound = bound.get(); + Mat34 m = Mat34::identity(); + m.m3 = position; + body.place(m); + } +}; + +// A simulated box catches up with a kinematic box that moves at `ahead` +// m/s along +X (placed at each sample as RaceScreen places a remote car each +// frame); returns the simulated box's speed once they have touched. +float speedAfterCatchingUp(float ahead, float behind) { + World world; + Box chaser(1000.0f, {-1.06f, 5, 0}); + Box lead(1000.0f, {0, 5, 0}); + chaser.body.ics.linearVelocity = {behind, 0, 0}; + chaser.body.ics.linearMomentum = {behind * 1000.0f, 0, 0}; + lead.body.kinematic = true; + lead.body.resetCollider(); + world.add(&chaser.body); + world.add(&lead.body); + const float dt = 1.0f / 60.0f; + for (int i = 0; i < 30; ++i) { + Mat34 m = Mat34::identity(); + m.m3 = {ahead * dt * static_cast(i), 5, 0}; + lead.body.place(m); + lead.body.ics.linearVelocity = {ahead, 0, 0}; + world.step(dt); + } + return chaser.body.ics.linearVelocity.x; +} + +} // namespace + +TEST(Kinematic, TouchingAMovingKinematicBodyTakesTheRelativeSpeed) { + // Closing at 1 m/s on a car doing 30 m/s: the chaser ends up a little + // slower than the car in front (it bounces off at the relative speed), + // not thrown back as from a wall at 31 m/s. + const float v = speedAfterCatchingUp(30.0f, 31.0f); + EXPECT_GT(v, 28.0f); + EXPECT_LE(v, 30.0f); +} + +TEST(Kinematic, AStillKinematicBodyIsAWall) { + // At rest, a kinematic body is hit like a wall: the 5 m/s chaser stops. + const float v = speedAfterCatchingUp(0.0f, 5.0f); + EXPECT_LT(v, 0.5f); +} From 3edfaf6fa5e1246ae52a74dc9bdf2ef2e3892296 Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Thu, 8 Oct 2026 22:36:35 -0700 Subject: [PATCH 26/54] Keep network cars in step with the simulation and their snapshots The "cars slightly bounce" report, measured with a host and a client on one machine (and through a 100 ms round trip with jitter and loss): - Snapshots were stamped with the frame's time, but the state they carry is the last fixed simulation step's, up to 16.7 ms older. 50 ms of stamps carried 3 or 4 steps of motion, so a car at 140 km/h surged back and forth by up to 0.35 m (0.1 m vertically on a slope) even on loopback. NetGame::submitLocalState now takes the state's age (the fixed step's unstepped remainder) and stamps the session time the state belongs to, measured once a frame (NetGame::frameTime). - Remote cars were sampled at the frame's time while the local car, the camera and every simulated object move in whole 60 Hz steps: a car alongside shook by half a step (29 cm rms at 35 m/s, 144 fps), and at 60 fps 4-10% of frames jumped by a whole step. RaceScreen now samples them once a frame at the time the simulation reaches after the frame's steps (World::remainderAfter) and uses that one sample for the physics body, the drawing, the HUD and Cops and Robbers. - The fixed 100 ms interpolation delay is too short over the Internet: at 100 ms round trip 14-40% of frames extrapolated and snapped back (up to 0.56 m, 0.48 m vertically). Each remote car now has its own playout delay, what its snapshots needed to arrive in time over the last 3 s (SnapshotBuffer::requiredDelay), between 50 and 500 ms, growing quickly and shrinking slowly so the shown time never jumps. - A client's clock estimate stepped whenever the best sample changed, moving every car by its speed times the step. The shown offset now slews toward the estimate at 5% while a race runs (SlewedClock), and the clock has sub-millisecond precision. - Snapshots go out on a steady 20 Hz cadence instead of drifting to the next frame each time, and only new states are sent. - States are ignored in the lobby, and a client forgets its car on the return to the lobby, so the last race's car does not appear in the next one. OPENMM2_NET_TRACE= writes the snapshots taken in and, once a frame, the local car and the remote cars as drawn, for diagnosing replication. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/app/RaceScreen.cpp | 32 ++++++--- src/game/net/NetGame.cpp | 57 +++++++++++++-- src/game/net/NetGame.h | 27 +++++-- src/net/ClockSync.cpp | 21 ++++-- src/net/ClockSync.h | 41 ++++++++--- src/net/Net.cpp | 16 ++++- src/net/Net.h | 2 + src/net/Session.cpp | 151 +++++++++++++++++++++++++++++++-------- src/net/Session.h | 45 ++++++++++-- src/net/Snapshot.cpp | 32 +++++++++ src/net/Snapshot.h | 34 ++++++++- src/phys/World.cpp | 13 ++++ src/phys/World.h | 4 ++ 13 files changed, 403 insertions(+), 72 deletions(-) diff --git a/src/app/RaceScreen.cpp b/src/app/RaceScreen.cpp index 5c40cf3..1e1a109 100644 --- a/src/app/RaceScreen.cpp +++ b/src/app/RaceScreen.cpp @@ -296,7 +296,7 @@ class RaceScreen final : public Screen { trigger = m_player->sim().body.ics.matrix.m3; m_gizmos->update(static_cast(dt), trigger); } - updateRemoteCars(ctx); + updateRemoteCars(ctx, static_cast(dt)); // aiVehicleManager::Update and the rail cars' rooms, before the // collision manager runs. if (m_trafficBodies) @@ -486,7 +486,7 @@ class RaceScreen final : public Screen { // an icon turned off, place 0, once that player has finished). if (multiplayer(ctx)) { const auto& players = ctx.netGame->players(); - for (const auto& rc : ctx.netGame->remoteCars()) { + for (const auto& rc : m_netCars) { if (!rc.hasState) continue; int slot = 0, other = 0, racer = -1; @@ -2145,7 +2145,7 @@ class RaceScreen final : public Screen { // fall out of the city), not when the car touches the water. cars.push_back({m_crSelf, m_crMyTeam, m_player->sim().body.ics.matrix.m3, m_playerState.wrecked, std::exchange(m_crWaterHandled, false)}); - for (const auto& rc : ctx.netGame->remoteCars()) + for (const auto& rc : m_netCars) if (rc.hasState) cars.push_back({rc.id, m_cr->teamOf(rc.id), rc.transform.m3, (rc.flags & net::kVehicleWrecked) != 0, false}); @@ -3308,8 +3308,12 @@ class RaceScreen final : public Screen { flags |= net::kVehicleWrecked; if (hornDown(ctx)) flags |= net::kVehicleHorn; + // The pose is the last simulation sample's, which is behind the + // frame by the time the fixed step left unstepped. + const double age = m_world ? static_cast(m_world->remainder()) * 1000.0 : 0.0; ctx.netGame->submitLocalState(m_pose.body, sim.body.ics.frameVelocity, sim.body.ics.angularVelocity, controls, - sim.damage.damage, flags); + sim.damage.damage, flags, age); + ctx.netGame->traceFrame(m_pose.body, sim.body.ics.frameVelocity, age, m_netCars); } // mmNetObject: every other player's car is a vehCar of the level (built @@ -3319,15 +3323,24 @@ class RaceScreen final : public Screen { // with its trailer. MM2 drives it with the player's inputs and pulls it // toward the received positions (mmNetObject::Predict, Update); OpenMM2 // places it at its interpolated snapshot as a kinematic body, which the - // local car collides with as with a wall (deviation), and simulates only - // the trailer behind it. - void updateRemoteCars(Context& ctx) { + // local car collides with as with an immovable body moving at the + // snapshot's velocity (deviation: MM2's car had a mass and gave way), + // and simulates only the trailer behind it. + // + // The cars are sampled once a frame (the HUD, the rules and the drawing + // use the same sample) at the time the simulation will have reached + // after this frame's fixed steps: the local car, the camera and every + // simulated object move in whole simulation steps, and a remote car + // sampled at the frame's own time would shake against them by up to a + // step's travel. + void updateRemoteCars(Context& ctx, float dt) { if (!multiplayer(ctx) || !m_world) return; + m_netCars = ctx.netGame->remoteCars(static_cast(m_world->remainderAfter(dt)) * 1000.0); const auto mode = m_result.config.mode; const bool towing = mode != game::GameMode::Cruise && mode != game::GameMode::CopsAndRobbers; std::vector present; - for (const auto& rc : ctx.netGame->remoteCars()) { + for (const auto& rc : m_netCars) { if (!rc.hasState) continue; present.push_back(rc.id); @@ -3393,7 +3406,7 @@ class RaceScreen final : public Screen { void drawRemoteCars(Context& ctx, float dt, const game::Camera& camera) { if (!multiplayer(ctx)) return; - for (const auto& rc : ctx.netGame->remoteCars()) { + for (const auto& rc : m_netCars) { const auto it = m_remotes.find(rc.id); if (!rc.hasState || it == m_remotes.end() || !it->second.renderer || !it->second.sim) continue; @@ -3987,6 +4000,7 @@ class RaceScreen final : public Screen { std::array spin{}; }; std::map m_remotes; + std::vector m_netCars; // this frame's sample (updateRemoteCars) std::map m_netWaypoints; // the other players' waypoints passed std::set m_netFinished; // the other players that finished (or did not) std::map m_netPlayers; // the players last frame (who left) diff --git a/src/game/net/NetGame.cpp b/src/game/net/NetGame.cpp index ed96619..c236eb5 100644 --- a/src/game/net/NetGame.cpp +++ b/src/game/net/NetGame.cpp @@ -7,6 +7,8 @@ #include "net/Session.h" #include +#include +#include #include #include @@ -160,6 +162,27 @@ struct NetGame::Impl { int maxPlayers = 8; int goldMass = 0; // HostRaceMenu's GOLD MASS index starts at 0, Weightless bool joining = false; + std::unique_ptr trace{nullptr, &std::fclose}; // OPENMM2_NET_TRACE + + // A new session: the trace (when asked for) follows its snapshots. + void newSession() { + session = std::make_unique(); + const char* path = std::getenv("OPENMM2_NET_TRACE"); + if (!path || !*path) + return; + if (!trace) + trace.reset(std::fopen(path, "w")); + if (!trace) { + log::warn("netgame: cannot write the trace {}", path); + return; + } + session->setStateObserver([this](std::uint8_t id, const net::VehicleSnapshot& s, double arrival) { + // S wall id stamp arrival delay x y z vx vy vz + std::fprintf(trace.get(), "S %.3f %u %u %.3f %.3f %.3f %.3f %.3f %.3f %.3f %.3f\n", + net::monotonicMsPrecise(), id, s.time, arrival, session->playoutDelay(id), s.position.x, + s.position.y, s.position.z, s.linearVelocity.x, s.linearVelocity.y, s.linearVelocity.z); + }); + } }; NetGame::NetGame(NetOptions options) : m_options(std::move(options)), m_impl(std::make_unique()) {} @@ -194,7 +217,7 @@ bool NetGame::host(const RaceConfig& config, const NetHostOptions& hostOptions, params.advertiseOnLan = hostOptions.advertiseOnLan; params.discoveryPort = m_options.discoveryPort; - impl.session = std::make_unique(); + impl.newSession(); if (!impl.session->host(params, error)) { impl.session.reset(); return false; @@ -228,7 +251,7 @@ bool NetGame::join(const net::Address& address, const std::string& password, con params.password = password; params.player = {m_options.playerName, car.vehicle, static_cast(car.color), static_cast(car.team)}; - m_impl->session = std::make_unique(); + m_impl->newSession(); if (!m_impl->session->join(params, error)) { m_impl->session.reset(); return false; @@ -255,6 +278,7 @@ void NetGame::update() { auto& impl = *m_impl; if (impl.session) { impl.session->update(); + m_frameTime = impl.session->timeMs(); handleEvents(); } if (impl.scanner) { @@ -571,11 +595,11 @@ double NetGame::secondsToStart() const { bool NetGame::raceStarted() const { return m_raceStarted; } void NetGame::submitLocalState(const Mat34& transform, const Vec3& velocity, const Vec3& angularVelocity, - const net::VehicleControls& controls, float damage, std::uint8_t flags) { + const net::VehicleControls& controls, float damage, std::uint8_t flags, + double stateAgeMs) { if (!m_impl->session) return; net::VehicleSnapshot s; - s.time = m_impl->session->time(); s.position = transform.m3; s.orientation = Quat::fromMatrix(transform); s.linearVelocity = velocity; @@ -583,13 +607,14 @@ void NetGame::submitLocalState(const Mat34& transform, const Vec3& velocity, con s.controls = controls; s.damage = std::clamp(damage, 0.0f, 1.0f); s.flags = flags; - m_impl->session->submitLocalState(s); + m_impl->session->submitLocalState(s, m_frameTime - stateAgeMs); } -std::vector NetGame::remoteCars() const { +std::vector NetGame::remoteCars(double stateAgeMs) const { std::vector out; if (!m_impl->session) return out; + const double at = m_frameTime - stateAgeMs; for (const auto& p : players()) { if (p.id == localId()) continue; @@ -598,7 +623,8 @@ std::vector NetGame::remoteCars() const { car.name = p.name; car.car = {p.car, p.color, p.team}; net::VehicleSnapshot snap; - const auto r = m_impl->session->sampleRemote(p.id, snap); + car.time = at - m_impl->session->playoutDelay(p.id); + const auto r = m_impl->session->sampleRemoteAt(p.id, car.time, snap); if (r != net::SnapshotBuffer::Result::Empty) { car.hasState = true; car.stale = r != net::SnapshotBuffer::Result::Interpolated; @@ -614,6 +640,23 @@ std::vector NetGame::remoteCars() const { return out; } +void NetGame::traceFrame(const Mat34& transform, const Vec3& velocity, double stateAgeMs, + const std::vector& cars) { + std::FILE* f = m_impl->trace.get(); + if (!f || !m_impl->session) + return; + // F wall frameTime stateAge x y z vx vy vz; R wall id stale x y z vx vy vz sampleTime + const double wall = net::monotonicMsPrecise(); + const Vec3& p = transform.m3; + std::fprintf(f, "F %.3f %.3f %.3f %.3f %.3f %.3f %.3f %.3f %.3f\n", wall, m_frameTime, stateAgeMs, p.x, p.y, p.z, + velocity.x, velocity.y, velocity.z); + for (const auto& c : cars) + if (c.hasState) + std::fprintf(f, "R %.3f %u %d %.3f %.3f %.3f %.3f %.3f %.3f %.3f\n", wall, c.id, c.stale ? 1 : 0, + c.transform.m3.x, c.transform.m3.y, c.transform.m3.z, c.velocity.x, c.velocity.y, + c.velocity.z, c.time); +} + void NetGame::sendEvent(std::uint16_t type, std::vector payload, std::uint8_t target) { if (m_impl->session) m_impl->session->sendGameEvent(type, std::move(payload), target); diff --git a/src/game/net/NetGame.h b/src/game/net/NetGame.h index 946894a..3db0cfb 100644 --- a/src/game/net/NetGame.h +++ b/src/game/net/NetGame.h @@ -63,11 +63,12 @@ struct NetChatLine { bool system = false; // "has joined", "has left", "You are now the Host" ... }; -// Another player's car this frame, interpolated ~100 ms in the past. +// Another player's car this frame, interpolated a playout delay in the past. struct NetRemoteCar { std::uint8_t id = net::kInvalidPlayerId; std::string name; NetCar car; + double time = 0.0; // the session time (ms) it was sampled at Mat34 transform; // model space -> world (car model origin, Angel conventions) Vec3 velocity; Vec3 angularVelocity; @@ -200,11 +201,26 @@ class NetGame { double secondsToStart() const; bool raceStarted() const; - // The local car's state; sent at the session's snapshot rate. + // The local car's state; sent at the session's snapshot rate. The state + // is the simulation's as it stood `stateAgeMs` before this frame's + // session time (the time update() saw): a fixed-step simulation is behind + // the frame by its unstepped remainder, and the snapshot must carry the + // time its position belongs to. void submitLocalState(const Mat34& transform, const Vec3& velocity, const Vec3& angularVelocity, - const net::VehicleControls& controls, float damage, std::uint8_t flags); - // Every other player in the session, sampled for this frame. - std::vector remoteCars() const; + const net::VehicleControls& controls, float damage, std::uint8_t flags, + double stateAgeMs = 0.0); + // Every other player in the session, sampled at this frame's session + // time less `stateAgeMs` (so they move with a simulation that is behind + // the frame) and less each one's playout delay. + std::vector remoteCars(double stateAgeMs = 0.0) const; + // The session time update() last saw (ms). + double frameTime() const { return m_frameTime; } + // Development aid: with OPENMM2_NET_TRACE= set, each session + // writes every remote snapshot it takes in and, through this call from + // the race once a frame, the local car and the remote cars as drawn (see + // docs/multiplayer.md, "Diagnosing replication"). + void traceFrame(const Mat34& transform, const Vec3& velocity, double stateAgeMs, + const std::vector& cars); // Game events (reliable, ordered). Race time is ms since the race start. void sendCheckpoint(int index, std::uint32_t raceTimeMs); @@ -236,6 +252,7 @@ class NetGame { bool m_raceStarted = false; bool m_closed = false; net::DisconnectReason m_joinFailure = net::DisconnectReason::None; + double m_frameTime = 0.0; }; } // namespace mm2::game diff --git a/src/net/ClockSync.cpp b/src/net/ClockSync.cpp index ce85a69..65938b0 100644 --- a/src/net/ClockSync.cpp +++ b/src/net/ClockSync.cpp @@ -1,15 +1,15 @@ #include "net/ClockSync.h" #include +#include namespace mm2::net { -void ClockSync::addSample(std::uint64_t localSend, std::uint32_t hostTime, std::uint64_t localReceive) { +void ClockSync::addSample(double localSend, double hostTime, double localReceive) { if (localReceive < localSend) return; - const auto rtt = static_cast(localReceive - localSend); - const std::int64_t offset = - static_cast(hostTime) + rtt / 2 - static_cast(localReceive); + const double rtt = localReceive - localSend; + const double offset = hostTime + rtt * 0.5 - localReceive; m_samples.push_back({offset, rtt}); while (m_samples.size() > m_window) m_samples.pop_front(); @@ -18,4 +18,17 @@ void ClockSync::addSample(std::uint64_t localSend, std::uint32_t hostTime, std:: m_rtt = best->rtt; } +void SlewedClock::update(double localMs, double target, bool slewing, double slewRate, double stepMs) { + const double elapsed = std::max(0.0, localMs - m_lastLocal); + m_lastLocal = localMs; + const double diff = target - m_offset; + if (!m_valid || !slewing || std::abs(diff) > stepMs) { + m_offset = target; + m_valid = true; + return; + } + const double limit = elapsed * slewRate; + m_offset += std::clamp(diff, -limit, limit); +} + } // namespace mm2::net diff --git a/src/net/ClockSync.h b/src/net/ClockSync.h index 7d70525..dd98088 100644 --- a/src/net/ClockSync.h +++ b/src/net/ClockSync.h @@ -17,30 +17,51 @@ class ClockSync { void reset() { m_samples.clear(); - m_offset = 0; - m_rtt = 0; + m_offset = 0.0; + m_rtt = 0.0; } // All values in milliseconds: local send time of the request, the host's // session time in the reply, local receive time of the reply. - void addSample(std::uint64_t localSend, std::uint32_t hostTime, std::uint64_t localReceive); + void addSample(double localSend, double hostTime, double localReceive); bool synced() const { return !m_samples.empty(); } std::size_t sampleCount() const { return m_samples.size(); } // hostTime ~= localTime + offset() - std::int64_t offset() const { return m_offset; } - std::uint32_t rtt() const { return m_rtt; } - double toHostTime(double localMs) const { return localMs + static_cast(m_offset); } + double offset() const { return m_offset; } + double rtt() const { return m_rtt; } + double toHostTime(double localMs) const { return localMs + m_offset; } private: struct Sample { - std::int64_t offset; - std::uint32_t rtt; + double offset; + double rtt; }; std::deque m_samples; std::size_t m_window; - std::int64_t m_offset = 0; - std::uint32_t m_rtt = 0; + double m_offset = 0.0; + double m_rtt = 0.0; +}; + +// The session clock a client shows: the local clock plus an offset that +// follows ClockSync's estimate. A new estimate is not applied at once while a +// race runs: the shown offset slews toward it at `slewRate` ms per ms (the +// clock runs up to that much faster or slower), so the remote cars, which are +// drawn at session times, never jump and the clock never runs backwards. +// Differences above `stepMs`, and every change while `slewing` is off (the +// lobby), are applied at once. +class SlewedClock { +public: + void reset() { m_valid = false; } + // Moves the shown offset toward `target` for the local time `localMs`. + void update(double localMs, double target, bool slewing, double slewRate = 0.05, double stepMs = 250.0); + bool valid() const { return m_valid; } + double offset() const { return m_offset; } + +private: + bool m_valid = false; + double m_offset = 0.0; + double m_lastLocal = 0.0; }; } // namespace mm2::net diff --git a/src/net/Net.cpp b/src/net/Net.cpp index cdaba8d..f6d2eab 100644 --- a/src/net/Net.cpp +++ b/src/net/Net.cpp @@ -104,10 +104,24 @@ bool Address::isPrivate() const { (a == 169 && b == 254) || (a == 100 && b >= 64 && b <= 127) || ip == 0; } -std::uint64_t monotonicMs() { +namespace { + +std::chrono::steady_clock::time_point clockStart() { static const auto start = std::chrono::steady_clock::now(); + return start; +} + +} // namespace + +std::uint64_t monotonicMs() { + const auto start = clockStart(); return static_cast( std::chrono::duration_cast(std::chrono::steady_clock::now() - start).count()); } +double monotonicMsPrecise() { + const auto start = clockStart(); + return std::chrono::duration(std::chrono::steady_clock::now() - start).count(); +} + } // namespace mm2::net diff --git a/src/net/Net.h b/src/net/Net.h index 1a13c3d..ab6575b 100644 --- a/src/net/Net.h +++ b/src/net/Net.h @@ -58,5 +58,7 @@ struct Address { // Monotonic milliseconds since the first call in this process. std::uint64_t monotonicMs(); +// The same clock with sub-millisecond precision (monotonicMs() is its floor). +double monotonicMsPrecise(); } // namespace mm2::net diff --git a/src/net/Session.cpp b/src/net/Session.cpp index de9d394..18b5f20 100644 --- a/src/net/Session.cpp +++ b/src/net/Session.cpp @@ -5,6 +5,7 @@ #include "net/Sha256.h" #include +#include #include #include @@ -156,13 +157,16 @@ void Session::resetState() { m_hostPeer = kInvalidPeer; m_connectStarted = 0; m_clock.reset(); + m_shownClock.reset(); m_nextTimeRequest = 0; m_timeRequestsSent = 0; m_joinPassword.clear(); m_joinPlayer = {}; m_request = {}; m_localState.reset(); + m_lastSentStateTime = 0; m_lastSnapshotSent = 0; + m_lastPlayoutUpdate = -1.0; } void Session::leave() { @@ -197,13 +201,21 @@ void Session::close(DisconnectReason reason, std::string message, bool failedJoi // --- Helpers ---------------------------------------------------------------------- -std::uint32_t Session::time() const { +std::uint32_t Session::time() const { return static_cast(timeMs()); } + +double Session::timeMs() const { if (m_role == Role::Host) - return static_cast(monotonicMs() - m_hostEpoch); - if (!m_clock.synced()) - return 0; - const double t = m_clock.toHostTime(static_cast(monotonicMs())); - return t <= 0.0 ? 0u : static_cast(t); + return std::max(0.0, monotonicMsPrecise() - static_cast(m_hostEpoch)); + if (!m_shownClock.valid()) + return 0.0; + return std::max(0.0, monotonicMsPrecise() + m_shownClock.offset()); +} + +void Session::updateShownClock() { + // Corrections slew while a race is loading or running (the remote cars + // and the countdown are drawn on this clock); the lobby takes them at once. + if (m_clock.synced()) + m_shownClock.update(monotonicMsPrecise(), m_clock.offset(), m_phase != SessionPhase::Lobby); } const PlayerInfo* Session::player(std::uint8_t id) const { @@ -283,6 +295,15 @@ void Session::update() { const std::uint64_t now = monotonicMs(); const std::uint64_t snapshotInterval = 1000 / m_config.snapshotRateHz; + // Snapshots go out on a steady cadence (the first frame at or after each + // tick; after a stall the cadence starts again from now). + auto snapshotDue = [&] { + if (now - m_lastSnapshotSent < snapshotInterval) + return false; + m_lastSnapshotSent = now - m_lastSnapshotSent < 2 * snapshotInterval ? m_lastSnapshotSent + snapshotInterval + : now; + return true; + }; if (m_role == Role::Host) { // Handshake timeouts. @@ -310,10 +331,8 @@ void Session::update() { sendToPlayers(Channel::Control, pings); } - if (now - m_lastSnapshotSent >= snapshotInterval) { - m_lastSnapshotSent = now; + if (snapshotDue() && replicating()) hostSendWorldState(); - } if (m_beacon) { hostAdvertise(); @@ -333,22 +352,67 @@ void Session::update() { ++m_timeRequestsSent; m_nextTimeRequest = now + (m_timeRequestsSent < 6 ? 150 : 2000); } - if (m_localState && now - m_lastSnapshotSent >= snapshotInterval) { - m_lastSnapshotSent = now; + updateShownClock(); + // Only a state the game has simulated since the last one is sent. + if (snapshotDue() && replicating() && m_localState && + (m_lastSentStateTime == 0 || m_localState->time != m_lastSentStateTime)) { + m_lastSentStateTime = m_localState->time; sendTo(m_hostPeer, Channel::State, VehicleStateMsg{*m_localState}); } } tickCountdown(); - - const double renderTime = static_cast(time()) - m_config.interpolationDelayMs; - for (auto& [id, buffer] : m_remoteStates) - buffer.prune(renderTime); + updatePlayout(); if (m_transport) m_transport->flush(); } +void Session::updatePlayout() { + // The delay grows quickly when snapshots start arriving later (the + // remote car is shown up to a quarter slower for a moment rather than + // extrapolated) and shrinks slowly; it never makes the shown time run + // backwards. + constexpr double kGrowRate = 0.25; + constexpr double kShrinkRate = 0.02; + // A snapshot that arrives in a frame is usable from that frame on. + constexpr double kMarginMs = 4.0; + // Snapshots kept behind the sample time: callers sample up to a + // simulation step (and more after a stall) behind the frame's time. + constexpr double kPruneSlackMs = 100.0; + const double now = timeMs(); + const double elapsed = m_lastPlayoutUpdate < 0.0 ? 0.0 : std::max(0.0, now - m_lastPlayoutUpdate); + m_lastPlayoutUpdate = now; + const double lo = m_config.interpolationDelayMs; + const double hi = std::max(lo, m_config.maxInterpolationDelayMs); + for (auto& [id, rv] : m_remoteStates) { + const double need = rv.buffer.requiredDelay(); + if (need < 0.0) { + if (rv.delay < 0.0) + rv.delay = lo; + } else { + const double target = std::clamp(need + kMarginMs, lo, hi); + if (!rv.measured) + rv.delay = target; + else if (target > rv.delay) + rv.delay = std::min(target, rv.delay + elapsed * kGrowRate); + else + rv.delay = std::max(target, rv.delay - elapsed * kShrinkRate); + rv.measured = true; + } + rv.buffer.prune(now - rv.delay - kPruneSlackMs); + } +} + +void Session::receiveState(std::uint8_t id, const VehicleSnapshot& state) { + if (!replicating()) + return; + const double now = timeMs(); + m_remoteStates[id].buffer.push(state, now); + if (m_stateObserver) + m_stateObserver(id, state, now); +} + void Session::tickCountdown() { if (m_phase != SessionPhase::Countdown || !clockSynced()) return; @@ -359,8 +423,10 @@ void Session::tickCountdown() { } void Session::hostSendWorldState() { - if (m_localState) + if (m_localState && m_localState->time != m_lastSentStateTime) { + m_lastSentStateTime = m_localState->time; m_pendingStates[kHostPlayerId] = *m_localState; + } if (m_pendingStates.empty()) return; for (const auto& [peer, r] : m_remotes) { @@ -533,10 +599,12 @@ void Session::hostHandle(Remote& r, MsgType type, std::span dat } case MsgType::VehicleState: { VehicleStateMsg msg; - if (!decodeMessage(data, msg)) + if (!decodeMessage(data, msg) || !replicating()) return; - m_pendingStates[id] = msg.state; - m_remoteStates[id].push(msg.state); + // The newest state is relayed with the next WorldState. + if (const auto it = m_pendingStates.find(id); it == m_pendingStates.end() || it->second.time < msg.state.time) + m_pendingStates[id] = msg.state; + receiveState(id, msg.state); return; } case MsgType::GameEvent: { @@ -649,8 +717,10 @@ void Session::clientHandle(MsgType type, std::span data) { if (const PlayerInfo* self = player(m_localId)) m_request = PlayerRequestMsg{self->car, self->color, self->team, self->ready}; // Rough clock until the first TimeResponse: assume a symmetric trip. - const std::uint32_t rtt = m_transport->stats(m_hostPeer).rttMs; - m_clock.addSample(now - std::min(rtt, now), w.hostTime, now); + const double receivedAt = monotonicMsPrecise(); + const double rtt = std::min(static_cast(m_transport->stats(m_hostPeer).rttMs), receivedAt); + m_clock.addSample(receivedAt - rtt, static_cast(w.hostTime) + 0.5, receivedAt); + updateShownClock(); m_nextTimeRequest = now; emit(ev::JoinAccepted{m_localId}); if (m_phase == SessionPhase::Countdown) @@ -739,6 +809,9 @@ void Session::clientHandle(MsgType type, std::span data) { p.ready = false; m_request.ready = false; m_remoteStates.clear(); + // The last race's car must not be sent (or shown) in the next one. + m_localState.reset(); + m_lastSentStateTime = 0; emit(ev::ReturnedToLobby{}); return; } @@ -754,21 +827,26 @@ void Session::clientHandle(MsgType type, std::span data) { TimeResponseMsg m; if (!decodeMessage(data, m)) return; - const std::uint64_t now = monotonicMs(); + const double receivedAt = monotonicMsPrecise(); + const auto now = static_cast(receivedAt); // Rebuild the 64-bit send time from the truncated 32-bit echo. std::uint64_t sent = (now & ~std::uint64_t{0xFFFFFFFF}) | m.clientTime; if (sent > now) sent -= std::uint64_t{1} << 32; - m_clock.addSample(sent, m.hostTime, now); + // Both times were whole milliseconds cut down: half a millisecond + // puts them in the middle of their millisecond. + m_clock.addSample(static_cast(sent) + 0.5, static_cast(m.hostTime) + 0.5, receivedAt); + updateShownClock(); return; } case MsgType::WorldState: { WorldStateMsg m; if (!decodeMessage(data, m)) return; - for (const auto& [id, state] : m.vehicles) + for (const auto& [id, state] : m.vehicles) { if (id != m_localId) - m_remoteStates[id].push(state); + receiveState(id, state); + } return; } case MsgType::GameEvent: { @@ -893,24 +971,39 @@ void Session::returnToLobby() { m_remoteStates.clear(); m_pendingStates.clear(); m_localState.reset(); + m_lastSentStateTime = 0; sendToPlayers(Channel::Control, ReturnToLobbyMsg{}); emit(ev::ReturnedToLobby{}); } -void Session::submitLocalState(const VehicleSnapshot& state) { +void Session::submitLocalState(const VehicleSnapshot& state) { submitLocalState(state, timeMs()); } + +void Session::submitLocalState(const VehicleSnapshot& state, double sessionTimeMs) { m_localState = state; - m_localState->time = time(); + m_localState->time = static_cast(std::llround(std::max(0.0, sessionTimeMs))); } SnapshotBuffer::Result Session::sampleRemoteAt(std::uint8_t playerId, double sessionTime, VehicleSnapshot& out) const { const auto it = m_remoteStates.find(playerId); if (it == m_remoteStates.end()) return SnapshotBuffer::Result::Empty; - return it->second.sample(sessionTime, out, m_config.maxExtrapolationMs); + return it->second.buffer.sample(sessionTime, out, m_config.maxExtrapolationMs); +} + +SnapshotBuffer::Result Session::sampleRemoteDelayed(std::uint8_t playerId, double sessionTimeMs, + VehicleSnapshot& out) const { + return sampleRemoteAt(playerId, sessionTimeMs - playoutDelay(playerId), out); } SnapshotBuffer::Result Session::sampleRemote(std::uint8_t playerId, VehicleSnapshot& out) const { - return sampleRemoteAt(playerId, static_cast(time()) - m_config.interpolationDelayMs, out); + return sampleRemoteDelayed(playerId, timeMs(), out); +} + +double Session::playoutDelay(std::uint8_t playerId) const { + const auto it = m_remoteStates.find(playerId); + if (it == m_remoteStates.end() || it->second.delay < 0.0) + return m_config.interpolationDelayMs; + return it->second.delay; } void Session::sendGameEvent(std::uint16_t type, std::vector payload, std::uint8_t target) { diff --git a/src/net/Session.h b/src/net/Session.h index a496596..455a116 100644 --- a/src/net/Session.h +++ b/src/net/Session.h @@ -56,7 +56,11 @@ struct JoinParams { struct SessionConfig { TransportConfig transport; std::uint32_t snapshotRateHz = 20; // own vehicle -> host, and host -> clients - double interpolationDelayMs = 100.0; // remote vehicles are shown this far in the past + // Remote vehicles are shown a playout delay in the past, which follows + // what each one's snapshots need to arrive in time + // (SnapshotBuffer::requiredDelay), within these bounds. + double interpolationDelayMs = 50.0; + double maxInterpolationDelayMs = 500.0; double maxExtrapolationMs = 250.0; // then held still std::uint32_t joinTimeoutMs = 10000; // handshake must finish within this std::uint32_t connectTimeoutMs = 8000; // ENet connect attempt @@ -141,8 +145,10 @@ class Session { const PlayerInfo* player(std::uint8_t id) const; std::uint16_t port() const { return m_transport ? m_transport->port() : 0; } - // Session clock in ms (host clock; estimated on clients). + // Session clock in ms (host clock; estimated on clients). On a client it + // follows the estimate smoothly while a race runs (SlewedClock). std::uint32_t time() const; + double timeMs() const; // the same with sub-millisecond precision bool clockSynced() const { return m_role == Role::Host || m_clock.synced(); } std::uint32_t countdownEnd() const { return m_countdownEnd; } @@ -166,11 +172,24 @@ class Session { } // --- Replication --- - // Latest state of the local vehicle; sent at snapshotRateHz. + // Latest state of the local vehicle; sent at snapshotRateHz (only while + // a race is loading or running). It is stamped with the session time at + // which the state was simulated, `sessionTimeMs`, or now. void submitLocalState(const VehicleSnapshot& state); - // Remote vehicle state at (time() - interpolationDelay). + void submitLocalState(const VehicleSnapshot& state, double sessionTimeMs); + // Remote vehicle state at (now - the player's playout delay), or at + // (`sessionTimeMs` - the playout delay). SnapshotBuffer::Result sampleRemote(std::uint8_t playerId, VehicleSnapshot& out) const; + SnapshotBuffer::Result sampleRemoteDelayed(std::uint8_t playerId, double sessionTimeMs, VehicleSnapshot& out) const; + // Remote vehicle state at exactly `sessionTime`. SnapshotBuffer::Result sampleRemoteAt(std::uint8_t playerId, double sessionTime, VehicleSnapshot& out) const; + // The playout delay (ms) a remote vehicle is shown with. + double playoutDelay(std::uint8_t playerId) const; + // Called with every remote vehicle snapshot taken in (player, snapshot, + // session time of arrival); for diagnostics. + void setStateObserver(std::function observer) { + m_stateObserver = std::move(observer); + } // Sends a game event to everyone (or one player). Delivered reliably and // in order; the sender does not receive its own event back. void sendGameEvent(std::uint16_t type, std::vector payload, @@ -198,6 +217,13 @@ class Session { void hostAdvertise(); void close(DisconnectReason reason, std::string message, bool failedJoin); void tickCountdown(); + void updateShownClock(); + // A remote vehicle's snapshot (ignored in the lobby: late packets of the + // last race). + void receiveState(std::uint8_t id, const VehicleSnapshot& state); + // Moves each remote vehicle's playout delay toward what it needs. + void updatePlayout(); + bool replicating() const { return m_phase != SessionPhase::Lobby; } PlayerInfo* findPlayer(std::uint8_t id); Remote* remoteForPlayer(std::uint8_t id); std::uint8_t allocatePlayerId() const; @@ -233,6 +259,7 @@ class Session { PeerId m_hostPeer = kInvalidPeer; std::uint64_t m_connectStarted = 0; ClockSync m_clock; + SlewedClock m_shownClock; // the offset time() uses std::uint64_t m_nextTimeRequest = 0; int m_timeRequestsSent = 0; std::string m_joinPassword; @@ -242,9 +269,17 @@ class Session { PlayerRequestMsg m_request; // Both - std::map m_remoteStates; + struct RemoteVehicle { + SnapshotBuffer buffer; + double delay = -1.0; // playout delay (ms); negative until the first snapshot + bool measured = false; // delay set from a measurement + }; + std::map m_remoteStates; + std::function m_stateObserver; std::optional m_localState; + std::uint32_t m_lastSentStateTime = 0; std::uint64_t m_lastSnapshotSent = 0; + double m_lastPlayoutUpdate = -1.0; }; // Proof sent in Hello: SHA-256(nonce || password); all zeros when empty. diff --git a/src/net/Snapshot.cpp b/src/net/Snapshot.cpp index bcb8421..2b9e993 100644 --- a/src/net/Snapshot.cpp +++ b/src/net/Snapshot.cpp @@ -2,6 +2,7 @@ #include #include +#include namespace mm2::net { @@ -20,6 +21,37 @@ void SnapshotBuffer::push(const VehicleSnapshot& s) { m_snapshots.insert(it, s); } +void SnapshotBuffer::push(const VehicleSnapshot& s, double arrivalMs) { + // Only a snapshot newer than everything before it moves the newest time + // a sample can reach; an older one arrived out of order and is late for + // nothing. + if (!m_newest || s.time > *m_newest) { + if (m_newest) + m_arrivals.push_back({arrivalMs, arrivalMs - static_cast(s.time), + static_cast(s.time - *m_newest)}); + m_newest = s.time; + } + while (!m_arrivals.empty() && m_arrivals.front().at < arrivalMs - kDelayWindowMs) + m_arrivals.pop_front(); + push(s); +} + +double SnapshotBuffer::requiredDelay() const { + if (m_arrivals.empty()) + return -1.0; + std::vector gaps; + gaps.reserve(m_arrivals.size()); + for (const Arrival& a : m_arrivals) + gaps.push_back(a.gap); + const auto mid = gaps.begin() + static_cast(gaps.size() / 2); + std::ranges::nth_element(gaps, mid); + const double maxGap = *mid * 1.5; + double need = 0.0; + for (const Arrival& a : m_arrivals) + need = std::max(need, a.lateness + std::min(a.gap, maxGap)); + return need; +} + void SnapshotBuffer::prune(double time) { // Keep the last snapshot at or before `time` (needed as the left end of // the interpolation interval) and everything after it. diff --git a/src/net/Snapshot.h b/src/net/Snapshot.h index 700f1cc..1023460 100644 --- a/src/net/Snapshot.h +++ b/src/net/Snapshot.h @@ -8,6 +8,7 @@ #include #include #include +#include namespace mm2::net { @@ -70,10 +71,17 @@ bool serialize(S& s, VehicleSnapshot& v) { } // Time-ordered buffer of snapshots for one remote vehicle. The renderer -// samples it at (session time - interpolation delay) so there is normally a +// samples it at (session time - playout delay) so there is normally a // snapshot on each side of the sample time; when the newest snapshot is older // than the sample time the state is extrapolated for a bounded time and then // held. +// +// The buffer also measures how late its snapshots arrive, which gives the +// playout delay the connection needs (requiredDelay()): to always have a +// snapshot after the sample time, the delay must cover, for every new +// snapshot, its lateness (arrival time minus its time stamp: the transit, +// the sender's send interval and frame, the host's relay and any error of the +// sender's clock) plus the gap since the previous snapshot. class SnapshotBuffer { public: enum class Result { Empty, Interpolated, Extrapolated, Held }; @@ -83,7 +91,14 @@ class SnapshotBuffer { // Inserts in time order. Duplicates (same time) replace the old entry; // snapshots older than everything buffered once the buffer is full are dropped. void push(const VehicleSnapshot& s); - void clear() { m_snapshots.clear(); } + // The same, for a snapshot that arrived at session time `arrivalMs` + // (feeds requiredDelay()). + void push(const VehicleSnapshot& s, double arrivalMs); + void clear() { + m_snapshots.clear(); + m_arrivals.clear(); + m_newest.reset(); + } // `time` is in session milliseconds (fractional allowed). Result sample(double time, VehicleSnapshot& out, double maxExtrapolationMs = 250.0) const; @@ -94,8 +109,23 @@ class SnapshotBuffer { // Drops snapshots that can no longer be used for interpolation at `time`. void prune(double time); + // The playout delay (ms) that would have let every new snapshot of the + // last kDelayWindowMs arrive before it was needed, or a negative value + // before any arrival was measured. A gap left by a lost snapshot counts + // as at most 1.5 times the usual gap, so a single loss is bridged by a + // short extrapolation rather than by a longer delay. + double requiredDelay() const; + static constexpr double kDelayWindowMs = 3000.0; + private: + struct Arrival { + double at; // session ms at which it arrived + double lateness; // arrival minus its time stamp + double gap; // since the previous newest snapshot's time stamp + }; std::deque m_snapshots; + std::deque m_arrivals; + std::optional m_newest; // time of the newest snapshot ever pushed std::size_t m_capacity; }; diff --git a/src/phys/World.cpp b/src/phys/World.cpp index 41a2ec4..e499b70 100644 --- a/src/phys/World.cpp +++ b/src/phys/World.cpp @@ -481,6 +481,19 @@ int World::advanceFixed(float frameDelta, float sampleStep, int maxSamples) { return n; } +float World::remainderAfter(float frameDelta, float sampleStep, int maxSamples) const { + // advanceFixed's arithmetic, without stepping. + float accumulator = m_accumulator + frameDelta; + int n = 0; + while (accumulator >= sampleStep && n < maxSamples) { + accumulator -= sampleStep; + ++n; + } + if (n == maxSamples && accumulator > sampleStep) + accumulator = 0; + return accumulator; +} + int World::advanceOversampled(float frameDelta, float sampleStep, int maxSamples) { // dgPhysManager::Update: a frame under a millisecond runs no sample (its // time is lost, as in the original). diff --git a/src/phys/World.h b/src/phys/World.h index aaf5a7e..08246ae 100644 --- a/src/phys/World.h +++ b/src/phys/World.h @@ -217,6 +217,10 @@ class World final : public GroundQuery { int advanceOversampled(float frameDelta, float sampleStep = kOversampleStep, int maxSamples = kOversampleMaxSamples); float interpolationAlpha(float sampleStep = kFixedSampleStep) const { return m_accumulator / sampleStep; } + // The time (s) advanceFixed(frameDelta) will leave unstepped: how far the + // simulation will be behind the frame once it has run. + float remainderAfter(float frameDelta, float sampleStep = kFixedSampleStep, int maxSamples = 32) const; + float remainder() const { return m_accumulator; } double time() const { return m_time; } From 196a08e0e239935e19a2ece0765cd57e8f13c840 Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Thu, 8 Oct 2026 22:40:07 -0700 Subject: [PATCH 27/54] Treat the shared traffic from the host as untrusted input As the network review asks of every received value: - A received car's paint job is held to the jobs its model can show, and a model always comes from the client's own catalog. - The host's police are shown at most 32 at a time; each model is loaded once per car shown at a time (a car that leaves keeps its parts for the next of its model), a model that fails is not tried again, and a new generation or paint job reuses the car. - The fuzzer feeds AmbientState through every decoder and a live client, and a hostile host's bit-flipped messages never give the client a car out of range. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/app/RaceScreen.cpp | 114 +++++++++++++++++++++---------- src/game/net/TrafficSync.cpp | 7 +- tests/game/test_traffic_sync.cpp | 42 ++++++++++++ tests/net/test_fuzz.cpp | 65 ++++++++++++++++++ 4 files changed, 189 insertions(+), 39 deletions(-) diff --git a/src/app/RaceScreen.cpp b/src/app/RaceScreen.cpp index 63f1927..98df5bb 100644 --- a/src/app/RaceScreen.cpp +++ b/src/app/RaceScreen.cpp @@ -136,6 +136,9 @@ class RaceScreen final : public Screen { for (auto& [id, c] : m_netCops) if (c.audio) c.audio->stop(); + for (auto& [model, c] : m_spareNetCops) + if (c.audio) + c.audio->stop(); if (m_cityLevel && m_bangers) m_cityLevel->removeSource(m_bangers.get()); if (m_cityLevel && m_gizmos) @@ -3624,42 +3627,90 @@ class RaceScreen final : public Screen { // The host's police on a client: kinematic cars (as the network // players', updateRemoteCars) at their interpolated places, drawn and // heard as the host's cops. + // The host's police car shown on a client. + struct NetCop { + std::string model; + int paint = -1; + int generation = -1; + std::unique_ptr sim; // kinematic body + std::unique_ptr renderer; + std::unique_ptr audio; + game::TrafficClient::Car state; + std::array spin{}; + float sirenAngle = 0.0f; + }; + + // A police car's parts out of the world, kept for another one of its + // model: each model is loaded once per car shown at a time. + void releaseNetCop(NetCop& cop) { + if (cop.sim && m_world) + cop.sim->removeFrom(*m_world); + if (cop.audio) + cop.audio->stop(); + if (cop.sim) { + const std::string model = cop.model; + m_spareNetCops.emplace(model, std::move(cop)); + } + cop = {}; + } + void updateNetCops(Context& ctx, float dt) { std::unordered_set seen; for (const auto& c : m_trafficClient->cars()) { const std::string* model = m_trafficCatalog.name(c.model); if (c.kind != net::AmbientKind::Police || !model || !m_world) continue; + // As many as MM2's posts could give (a hostile host gets no more). + if (!m_netCops.contains(c.id) && m_netCops.size() >= kMaxNetCops) + continue; seen.insert(c.id); NetCop& cop = m_netCops[c.id]; - if (cop.model != *model || cop.paint != c.paint || cop.generation != c.generation) { - if (cop.sim) - cop.sim->removeFrom(*m_world); - if (cop.audio) - cop.audio->stop(); - cop = {}; + if (cop.model != *model) { + releaseNetCop(cop); cop.model = *model; - cop.paint = c.paint; - cop.generation = c.generation; - std::string error; - cop.sim = game::SimVehicle::load(ctx.game->vfs, *model, &error, {}, false, false); - if (!cop.sim) { - log::warn("race: shared police car {}: {}", *model, error); - continue; + if (const auto spare = m_spareNetCops.find(*model); spare != m_spareNetCops.end()) { + cop = std::move(spare->second); + m_spareNetCops.erase(spare); + } else if (!m_badNetCopModels.contains(*model)) { + std::string error; + cop.sim = game::SimVehicle::load(ctx.game->vfs, *model, &error, {}, false, false); + if (!cop.sim) { + // Tried once, not again every frame. + log::warn("race: shared police car {}: {}", *model, error); + m_badNetCopModels.insert(*model); + continue; + } + auto& sim = cop.sim->sim(); + sim.options.weatherFriction = weatherFriction(); + sim.body.kinematic = true; + sim.body.resetCollider(); + cop.renderer = std::make_unique(ctx.device(), *m_textures, *m_models, + cop.sim->model(), c.paint); + setupVehicleRenderer(ctx, *cop.renderer); + cop.paint = c.paint; + cop.audio = loadAiCarAudio(ctx, *model, true); } - auto& sim = cop.sim->sim(); - sim.options.weatherFriction = weatherFriction(); - sim.body.kinematic = true; - sim.body.resetCollider(); + if (!cop.sim) + continue; cop.sim->addTo(*m_world); - cop.sim->reset(c.transform); - cop.renderer = std::make_unique(ctx.device(), *m_textures, *m_models, - cop.sim->model(), c.paint); - setupVehicleRenderer(ctx, *cop.renderer); - cop.audio = loadAiCarAudio(ctx, *model, true); + cop.generation = -1; // placed below } if (!cop.sim) continue; + if (cop.paint != c.paint) { + cop.renderer->setPaintjob(c.paint); + cop.paint = c.paint; + } + if (cop.generation != c.generation) { + // A new car in the slot (or the host's cop put back): it + // starts afresh where it is. + cop.generation = c.generation; + cop.sim->reset(c.transform); + if (cop.audio) + cop.audio->reset(); + cop.spin = {}; + cop.sirenAngle = 0.0f; + } auto& sim = cop.sim->sim(); Mat34 ics = c.transform; ics.m3 = c.transform.m3 - c.transform.transformDir(sim.centerOfGravity); @@ -3680,10 +3731,7 @@ class RaceScreen final : public Screen { ++it; continue; } - if (it->second.sim) - it->second.sim->removeFrom(*m_world); - if (it->second.audio) - it->second.audio->stop(); + releaseNetCop(it->second); it = m_netCops.erase(it); } } @@ -4471,18 +4519,10 @@ class RaceScreen final : public Screen { std::unique_ptr m_trafficProxies; // client: their physics std::unordered_set m_netAccidentNodes, m_netAccidentPaths; // client: off-rail cars' components std::unordered_map m_netAccidentRooms; - struct NetCop { - std::string model; - int paint = -1; - int generation = -1; - std::unique_ptr sim; // kinematic body - std::unique_ptr renderer; - std::unique_ptr audio; - game::TrafficClient::Car state; - std::array spin{}; - float sirenAngle = 0.0f; - }; std::map m_netCops; // client: the host's police + std::multimap m_spareNetCops; // client: police cars no longer shown, by model + std::set m_badNetCopModels; // client: police models that failed to load + static constexpr std::size_t kMaxNetCops = 32; bool m_trafficCatalogWarned = false; // Development aid: OPENMM2_DEBUG_NETTRAFFIC logs the shared cars near // each client twice a second, on the host and on the client. diff --git a/src/game/net/TrafficSync.cpp b/src/game/net/TrafficSync.cpp index df9b374..2ff0c27 100644 --- a/src/game/net/TrafficSync.cpp +++ b/src/game/net/TrafficSync.cpp @@ -332,8 +332,11 @@ ai::AmbientCar ambientCarOf(const TrafficClient::Car& car, const std::string& mo a.data = data; a.model = model; // AiRenderer's paint job is trunc(paint x (jobs - 1)): the middle of the - // job's range gives it back exactly. - a.paint = paintJobs > 1 ? (static_cast(car.paint) + 0.5f) / static_cast(paintJobs - 1) : 0.0f; + // job's range gives it back exactly. The job comes from the network: it + // is held to the jobs aiVehicleInstance::SetColor can pick (all but the + // last). + const int job = paintJobs > 1 ? std::clamp(car.paint, 0, paintJobs - 2) : 0; + a.paint = paintJobs > 1 ? (static_cast(job) + 0.5f) / static_cast(paintJobs - 1) : 0.0f; a.transform = car.transform; a.velocity = car.velocity; a.speed = car.speed; diff --git a/tests/game/test_traffic_sync.cpp b/tests/game/test_traffic_sync.cpp index 4baf1cf..d10f3ef 100644 --- a/tests/game/test_traffic_sync.cpp +++ b/tests/game/test_traffic_sync.cpp @@ -289,3 +289,45 @@ TEST(TrafficSettings, TheLobbyOptionCrossesTheWire) { off.netTraffic = false; EXPECT_FALSE(game::fromSessionSettings(game::toSessionSettings(off, "Cruise", 8)).netTraffic); } + +// A hostile host: random messages (decoded from noise, as the session would +// hand them over) never put a car out of range, and the client keeps no more +// cars than the ids allow. +TEST(TrafficClient, NoiseNeverYieldsABadCar) { + TrafficClient client(10, 0x1234); + std::uint32_t seed = 99; + auto next = [&] { + seed = seed * 1664525u + 1013904223u; + return seed; + }; + // A valid message with bits flipped (the length kept, so most decode). + std::vector cars; + for (int i = 0; i < 20; ++i) + cars.push_back(i % 5 == 0 ? cop(300 + i, {static_cast(i), 0, 0}, 1) + : railCar(i, {static_cast(i) * 3.0f, 0, 5})); + TrafficHost host; + const auto valid = net::encodeMessage(host.build({1, {0, 0, 0}}, cars, 0, 0, 0x1234)); + std::size_t decodedCount = 0; + for (int round = 0; round < 4000; ++round) { + std::vector junk = valid; + for (int k = 0, n = 1 + static_cast(next() % 12); k < n; ++k) + junk[1 + next() % (junk.size() - 1)] ^= static_cast(1u << (next() % 8)); + net::AmbientStateMsg m; + if (!net::decodeMessage(junk, m)) + continue; + ++decodedCount; + m.time = static_cast(round * 50); // a believable clock + client.receive(m); + client.update(static_cast(round * 50) - 100.0); + for (const auto& c : client.cars()) { + ASSERT_LT(c.id, static_cast(net::kMaxAmbientIds)); + ASSERT_LT(c.model, 10); + ASSERT_LE(c.paint, static_cast(net::kMaxAmbientPaint)); + ASSERT_TRUE(std::isfinite(c.transform.m3.x) && std::isfinite(c.transform.m3.y) && + std::isfinite(c.transform.m3.z) && std::isfinite(c.speed)); + ASSERT_TRUE(c.target == net::kAmbientNoTarget || c.target < net::kMaxPlayers); + } + ASSERT_LE(client.known(), static_cast(net::kMaxAmbientIds)); + } + EXPECT_GT(decodedCount, 0u); +} diff --git a/tests/net/test_fuzz.cpp b/tests/net/test_fuzz.cpp index 7862887..d17b4b7 100644 --- a/tests/net/test_fuzz.cpp +++ b/tests/net/test_fuzz.cpp @@ -3,6 +3,7 @@ // reproduces. Nothing may crash, read out of bounds (run test_net under // AddressSanitizer/UBSan to check that) or produce a value outside what the // writer could have produced. See docs/review/multiplayer-input.md. +#include "net/AmbientState.h" #include "net/Discovery.h" #include "net/NatPmp.h" #include "net/Session.h" @@ -110,6 +111,37 @@ std::vector corpus() { PlayerPingsMsg pings; pings.pings = {{1, 30}, {2, 60}}; c.push_back(encodeMessage(pings)); + // The shared cruise traffic: a rail car, a knocked car and a police car. + AmbientStateMsg ambient; + ambient.time = 5000; + ambient.lightSteps = 300; + ambient.catalog = 0x45C1; + ambient.setOrigin({-1150.0f, 112.0f, 163.0f}); + AmbientEntity car; + car.id = 12; + car.generation = 3; + car.model = 4; + car.paint = 2; + car.position = {-1140.0f, 111.0f, 150.0f}; + car.orientation = Quat::fromAxisAngle(Vec3{0.0f, 1.0f, 0.0f}, 0.4f); + car.speed = 11.0f; + car.flags = kAmbientBrake | kAmbientSignalRight; + ambient.entities.push_back(car); + car.id = 13; + car.flags = kAmbientOffRail | kAmbientWrecked; + car.velocity = {3.0f, -1.0f, 2.0f}; + car.angularVelocity = {0.5f, 1.0f, -0.5f}; + ambient.entities.push_back(car); + car.id = 401; + car.kind = AmbientKind::Police; + car.flags = kAmbientSiren | kAmbientPursuit; + car.target = 1; + car.damage = 0.3f; + car.rpm = 4500.0f; + car.throttle = 1.0f; + car.gear = 3; + ambient.entities.push_back(car); + c.push_back(encodeMessage(ambient)); LanAdvert advert; advert.sessionName = "Fuzz"; @@ -207,6 +239,31 @@ void checkSnapshot(const VehicleSnapshot& s) { ASSERT_LE(s.damage, 1.0f); } +void checkAmbient(const AmbientStateMsg& m) { + ASSERT_LE(m.entities.size(), kMaxAmbientPerMessage); + const Vec3 origin = m.originVec(); + for (const AmbientEntity& e : m.entities) { + ASSERT_LT(e.id, kMaxAmbientIds); + ASSERT_LT(e.generation, kAmbientGenerations); + ASSERT_LE(e.kind, AmbientKind::Last); + ASSERT_LT(e.model, kMaxAmbientModels); + ASSERT_LE(e.paint, kMaxAmbientPaint); + for (float v : {e.position.x, e.position.y, e.position.z, e.velocity.x, e.velocity.y, e.velocity.z, + e.angularVelocity.x, e.angularVelocity.y, e.angularVelocity.z, e.speed, e.damage, e.rpm, + e.throttle}) + ASSERT_TRUE(std::isfinite(v)); + ASSERT_LE(std::abs(e.position.x - origin.x), kAmbientOffsetRange + 0.01f); + ASSERT_LE(std::abs(e.position.y - origin.y), kAmbientHeightRange + 0.01f); + ASSERT_LE(std::abs(e.velocity.x), kAmbientVelocityRange + 0.01f); + ASSERT_TRUE(e.target == kAmbientNoTarget || e.target < kMaxPlayers); + ASSERT_GE(e.gear, -1); + ASSERT_LE(e.gear, 8); + const float n = std::sqrt(e.orientation.x * e.orientation.x + e.orientation.y * e.orientation.y + + e.orientation.z * e.orientation.z + e.orientation.w * e.orientation.w); + ASSERT_NEAR(n, 1.0f, 1e-3f); + } +} + void checkPlayer(const PlayerInfo& p) { ASSERT_LE(p.name.size(), kMaxNameLength); ASSERT_LE(p.car.size(), kMaxShortStringLength); @@ -302,6 +359,9 @@ void decodeEverything(std::span b) { if (const auto m = decoded(b)) { ASSERT_LE(m->pings.size(), kMaxPlayers); } + if (const auto m = decoded(b)) { + checkAmbient(*m); + } std::uint32_t nonce = 0; (void)decodeLanQuery(b, nonce); @@ -523,4 +583,9 @@ TEST(Fuzz, ClientSurvivesMutatedTraffic) { checkSnapshot(snap); } } + // The shared traffic it kept: a bounded queue of messages within limits. + const auto ambient = client.takeAmbientStates(); + EXPECT_LE(ambient.size(), Session::kMaxQueuedAmbientStates); + for (const auto& m : ambient) + checkAmbient(m); } From 419589df38f258684d7b7d85b8d4d24a130406cc Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Thu, 8 Oct 2026 22:41:20 -0700 Subject: [PATCH 28/54] Say in the lobby why a network race could not load A network race whose city or car fails to load goes back to the lobby (the host taking everyone with it), which showed no reason. The race screen now leaves a notice with NetGame (addNotice) and the lobby shows it over itself ("Cannot load the city 'nowhere': city/nowhere.psdl not found"), as it shows the session's own notices when it ends. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/app/RaceScreen.cpp | 6 +++++- src/app/frontend/PagesMulti.cpp | 3 +++ src/game/net/NetGame.h | 3 +++ 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/app/RaceScreen.cpp b/src/app/RaceScreen.cpp index 3e6b022..a2c0e0a 100644 --- a/src/app/RaceScreen.cpp +++ b/src/app/RaceScreen.cpp @@ -754,6 +754,8 @@ class RaceScreen final : public Screen { log::error("race: cannot load city '{}': {}", m_result.config.city, error); // Back to the menus; the host of a network race takes everyone // back to the lobby, which could otherwise never start another. + if (multiplayer(ctx)) + ctx.netGame->addNotice(std::format("Cannot load the city '{}': {}", m_result.config.city, error)); leaveRace(ctx, m_result); return; } @@ -909,8 +911,10 @@ class RaceScreen final : public Screen { log::error("race: vehicle '{}': {}", m_result.config.vehicle, error); // A network race without the player's car cannot be driven or // seen by the others: back to the lobby (the host takes everyone). - if (multiplayer(ctx)) + if (multiplayer(ctx)) { + ctx.netGame->addNotice(std::format("Cannot load the car '{}': {}", m_result.config.vehicle, error)); leaveRace(ctx, m_result); + } return; } m_player->sim().options.player = true; // mmPlayer::Update's input overrides diff --git a/src/app/frontend/PagesMulti.cpp b/src/app/frontend/PagesMulti.cpp index 8e2690c..f28f16c 100644 --- a/src/app/frontend/PagesMulti.cpp +++ b/src/app/frontend/PagesMulti.cpp @@ -718,6 +718,9 @@ class LobbyPage final : public Page { m_go->sheet.path = net.localReady() ? "texture/lobb_rdy.tga" : "texture/lobb_nr.tga"; m_mapPath = mapPicture(fe, cfg); m_map->visible = !m_mapPath.empty(); + // A notice while the session goes on: the race could not load. + if (auto notice = net.takeNotice()) + fe.message(std::move(*notice)); } void drawAbove(Frontend& fe, ui::UiFrame& f) override { diff --git a/src/game/net/NetGame.h b/src/game/net/NetGame.h index f3723ae..89620e6 100644 --- a/src/game/net/NetGame.h +++ b/src/game/net/NetGame.h @@ -162,6 +162,9 @@ class NetGame { bool isHost() const; // Notices for the user ("The Host has quit", "has been ejected", join errors). std::optional takeNotice(); + // A notice of the game's own (a race that could not load): the lobby + // shows it. + void addNotice(std::string text) { m_notices.push_back(std::move(text)); } // Why the last join failed (None while joining or after a successful // join): the lobby asks for a password on BadPassword. net::DisconnectReason joinFailure() const { return m_joinFailure; } From d6520b89bf9a956a18ba6b974c4e8a15cb823b7d Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Thu, 8 Oct 2026 22:43:27 -0700 Subject: [PATCH 29/54] Send far traffic cars' state every other message A client's cars beyond 80 m on their rails now get their state in every other message; in the others they come as 13 bits (id, generation, "still there"), so a missing car still means it has gone and nothing comes back from a lost message. Near cars, cars off their rails, the police and cars new to the client keep their state in every message. In a two-player San Francisco cruise at traffic density 0.5 with 63 cars round the client, the host's stream to it fell from 17.5 to 11.0 KB/s. The client's cars within 120 m of it were 2 cm from the host's (median; 90 % within 10 cm, 27 cm at worst, for far cars between messages). Co-Authored-By: Claude Opus 5.5 (1M context) --- src/game/net/TrafficSync.cpp | 23 +++++++++++++ src/game/net/TrafficSync.h | 11 +++++- src/net/AmbientState.h | 15 +++++++-- tests/game/test_traffic_sync.cpp | 57 ++++++++++++++++++++++++++++++++ tests/net/test_ambient_state.cpp | 22 +++++++++--- tests/net/test_fuzz.cpp | 7 ++++ 6 files changed, 126 insertions(+), 9 deletions(-) diff --git a/src/game/net/TrafficSync.cpp b/src/game/net/TrafficSync.cpp index 2ff0c27..bd16072 100644 --- a/src/game/net/TrafficSync.cpp +++ b/src/game/net/TrafficSync.cpp @@ -88,6 +88,8 @@ net::AmbientStateMsg TrafficHost::build(const TrafficViewer& viewer, std::span& set = m_sets[viewer.player]; + const std::uint32_t sequence = m_sequence[viewer.player]++; + const float near2 = m_options.fullRateRadius * m_options.fullRateRadius; struct Candidate { const SharedCar* car; @@ -128,6 +130,13 @@ net::AmbientStateMsg TrafficHost::build(const TrafficViewer& viewer, std::span= net::kMaxAmbientPerMessage) break; net::AmbientEntity e = toEntity(*c.car); + // A far car on its rail, already known to the client: its state + // every other message, the ids alternating between messages. + const bool full = c.chasing || c.distance2 < near2 || !set.contains(c.car->id) || + c.car->kind != net::AmbientKind::Traffic || (c.car->flags & net::kAmbientOffRail) != 0 || + ((sequence + static_cast(c.car->id)) & 1u) == 0; + if (!full) + e.hasState = false; const std::size_t bits = net::ambientEntityBits(e); if (used + bits > budget) break; @@ -176,6 +185,20 @@ void TrafficClient::receive(const net::AmbientStateMsg& msg) { for (const net::AmbientEntity& e : msg.entities) { ++m_stats.entities; const int id = e.id; + if (!e.hasState) { + // Still there: the same car keeps going on its last state (an + // unknown one waits for a message with its state). + if (id >= static_cast(net::kMaxAmbientIds) || !listed.insert(id).second) { + ++m_stats.refused; + continue; + } + const auto it = m_entries.find(id); + if (newest && it != m_entries.end() && it->second.generation == e.generation) + it->second.goneAt.reset(); + else if (it != m_entries.end()) + listed.erase(id); // another car now: the old one has gone + continue; + } if (id >= static_cast(net::kMaxAmbientIds) || e.model >= m_catalogSize || e.paint > net::kMaxAmbientPaint || e.kind > net::AmbientKind::Last || !finite(e.position) || !finite(e.velocity) || !finite(e.angularVelocity) || !std::isfinite(e.speed) || diff --git a/src/game/net/TrafficSync.h b/src/game/net/TrafficSync.h index 7ef696e..b33a1ee 100644 --- a/src/game/net/TrafficSync.h +++ b/src/game/net/TrafficSync.h @@ -88,6 +88,11 @@ class TrafficHost { // edge does not come and go with every message. float enterRadius = 200.0f; float leaveRadius = 230.0f; + // Beyond this a traffic car on its rail gets its state every other + // message (the others say only that it is still there); nearer + // ones, cars off their rails, the police and cars new to the + // client get it in every message. + float fullRateRadius = 80.0f; // The message's size, bytes (the header included): one unfragmented // UDP datagram. std::size_t maxBytes = 1100; @@ -101,13 +106,17 @@ class TrafficHost { net::AmbientStateMsg build(const TrafficViewer& viewer, std::span cars, std::uint32_t time, std::uint32_t lightSteps, std::uint16_t catalog); // A player left: its set is forgotten. - void forget(std::uint8_t player) { m_sets.erase(player); } + void forget(std::uint8_t player) { + m_sets.erase(player); + m_sequence.erase(player); + } const Options& options() const { return m_options; } private: Options m_options; std::map> m_sets; // the ids each client got last + std::map m_sequence; // messages built for each client }; class TrafficClient { diff --git a/src/net/AmbientState.h b/src/net/AmbientState.h index 932f7c8..53aee59 100644 --- a/src/net/AmbientState.h +++ b/src/net/AmbientState.h @@ -17,7 +17,10 @@ // receiving car), which keeps them at 3 cm resolution in 44 bits. A car on // its rail moves along its heading (ai::Traffic: velocity = -m2 x speed), // so only its speed is sent; a car off its rail (knocked loose, a wreck) -// and every police car carry their full linear and angular velocity. +// and every police car carry their full linear and angular velocity. A car +// far from the client may come without its state (13 bits: it is still +// there, the same car); the host sends those cars' state every other +// message. #include "net/Protocol.h" @@ -57,6 +60,9 @@ enum AmbientFlags : std::uint8_t { struct AmbientEntity { std::uint16_t id = 0; std::uint8_t generation = 0; + // False: only the id and generation travel (the car is still there; its + // state comes with a later message). The other fields are then unset. + bool hasState = true; AmbientKind kind = AmbientKind::Traffic; std::uint8_t model = 0; // index into the session's model catalog std::uint8_t paint = 0; // paint job @@ -97,11 +103,14 @@ bool serializeAmbientEntity(S& s, AmbientEntity& e, const Vec3& origin) { std::int32_t id = e.id, generation = e.generation, model = e.model, paint = e.paint; s.ranged(id, 0, static_cast(kMaxAmbientIds) - 1); s.ranged(generation, 0, static_cast(kAmbientGenerations) - 1); + e.id = static_cast(id); + e.generation = static_cast(generation); + s.boolean(e.hasState); + if (!e.hasState) + return s.ok(); s.enumeration(e.kind, AmbientKind::Last); s.ranged(model, 0, static_cast(kMaxAmbientModels) - 1); s.ranged(paint, 0, static_cast(kMaxAmbientPaint)); - e.id = static_cast(id); - e.generation = static_cast(generation); e.model = static_cast(model); e.paint = static_cast(paint); Vec3 offset = e.position - origin; diff --git a/tests/game/test_traffic_sync.cpp b/tests/game/test_traffic_sync.cpp index d10f3ef..cdc8a9d 100644 --- a/tests/game/test_traffic_sync.cpp +++ b/tests/game/test_traffic_sync.cpp @@ -331,3 +331,60 @@ TEST(TrafficClient, NoiseNeverYieldsABadCar) { } EXPECT_GT(decodedCount, 0u); } + +TEST(TrafficHost, FarCarsComeWithTheirStateEveryOtherMessage) { + TrafficHost host; + std::vector cars = {railCar(1, {10, 0, 0}), railCar(10, {150, 0, 0}), railCar(11, {0, 0, 150}), + cop(300, {0, 0, 160}, 2)}; + auto states = [&](const net::AmbientStateMsg& m) { + std::vector> v; + for (const auto& e : m.entities) + v.emplace_back(e.id, e.hasState); + std::ranges::sort(v); + return v; + }; + const game::TrafficViewer viewer{1, {0, 0, 0}}; + // New to the client: everything with its state. + auto m = wire(host.build(viewer, cars, 0, 0, 0)); + EXPECT_EQ(states(m), (std::vector>{{1, true}, {10, true}, {11, true}, {300, true}})); + // Then the far rail cars alternate; the near one and the police always. + m = wire(host.build(viewer, cars, 50, 0, 0)); + EXPECT_EQ(states(m), (std::vector>{{1, true}, {10, false}, {11, true}, {300, true}})); + m = wire(host.build(viewer, cars, 100, 0, 0)); + EXPECT_EQ(states(m), (std::vector>{{1, true}, {10, true}, {11, false}, {300, true}})); + // A far car knocked off its rail: every message. + cars[1].flags |= net::kAmbientOffRail; + m = wire(host.build(viewer, cars, 150, 0, 0)); + EXPECT_EQ(states(m), (std::vector>{{1, true}, {10, true}, {11, true}, {300, true}})); +} + +TEST(TrafficClient, ACarWithoutItsStateIsStillThere) { + TrafficClient client(8, 0x1234); + client.receive(message(0, {railCar(10, {150, 0, 0}), railCar(11, {0, 0, 150})})); + auto held = [](std::uint32_t time, std::initializer_list> ids) { + net::AmbientStateMsg m; + m.time = time; + m.catalog = 0x1234; + for (const auto& [id, gen] : ids) { + net::AmbientEntity e; + e.id = static_cast(id); + e.generation = static_cast(gen); + e.hasState = false; + m.entities.push_back(e); + } + return wire(m); + }; + // 10 and 11 only said to be there (and 12, unknown, waits for its state). + client.receive(held(50, {{10, 0}, {11, 0}, {12, 0}})); + client.update(50.0); + ASSERT_NE(find(client, 10), nullptr); + ASSERT_NE(find(client, 11), nullptr); + EXPECT_EQ(find(client, 12), nullptr); + EXPECT_TRUE(find(client, 10)->extrapolated); + EXPECT_NEAR(find(client, 10)->transform.m3.z, -0.5f, 0.05f); // on along its velocity + // 11's slot now holds another car (another generation): the old one has gone. + client.receive(held(100, {{10, 0}, {11, 1}})); + client.update(100.0); + EXPECT_NE(find(client, 10), nullptr); + EXPECT_EQ(find(client, 11), nullptr); +} diff --git a/tests/net/test_ambient_state.cpp b/tests/net/test_ambient_state.cpp index 7a8b23e..4fa095e 100644 --- a/tests/net/test_ambient_state.cpp +++ b/tests/net/test_ambient_state.cpp @@ -63,6 +63,11 @@ AmbientStateMsg sampleMessage() { knocked.velocity = {3.0f, 4.0f, -5.0f}; knocked.angularVelocity = {1.0f, 2.0f, 3.0f}; m.entities.push_back(knocked); + AmbientEntity held; + held.id = 44; + held.generation = 2; + held.hasState = false; + m.entities.push_back(held); return m; } @@ -80,7 +85,10 @@ TEST(AmbientState, RoundTripKeepsEveryField) { EXPECT_EQ(out.origin[0], -1501); EXPECT_EQ(out.origin[1], 35); EXPECT_EQ(out.origin[2], 558); - ASSERT_EQ(out.entities.size(), 3u); + ASSERT_EQ(out.entities.size(), 4u); + EXPECT_FALSE(out.entities[3].hasState); + EXPECT_EQ(out.entities[3].id, 44); + EXPECT_EQ(out.entities[3].generation, 2); const AmbientEntity& rail = out.entities[0]; EXPECT_EQ(rail.id, 7); @@ -175,6 +183,7 @@ TEST(AmbientState, MalformedMessagesAreRejected) { p.writeVarU32(1); p.writeBits(4, 9); // id p.writeBits(0, 3); // generation + p.writeBits(1, 1); // with its state p.writeBits(1, 1); // kind: police p.writeBits(0, 6); // model p.writeBits(0, 4); // paint @@ -222,9 +231,12 @@ TEST(AmbientState, SizesFitTheBudget) { AmbientEntity offRail = rail; offRail.flags |= kAmbientOffRail; const AmbientEntity cop = copCar(2, {}); - EXPECT_EQ(ambientEntityBits(rail), 118u); - EXPECT_EQ(ambientEntityBits(offRail), 176u); - EXPECT_EQ(ambientEntityBits(cop), 203u); + EXPECT_EQ(ambientEntityBits(rail), 119u); + EXPECT_EQ(ambientEntityBits(offRail), 177u); + EXPECT_EQ(ambientEntityBits(cop), 204u); + AmbientEntity held = rail; + held.hasState = false; + EXPECT_EQ(ambientEntityBits(held), 13u); // 64 rail cars stay under one MTU. AmbientStateMsg m; for (std::uint16_t i = 0; i < 64; ++i) @@ -299,7 +311,7 @@ TEST(AmbientState, HostSendsEachClientItsOwnState) { ASSERT_EQ(gotA.size(), 1u); ASSERT_EQ(gotB.size(), 1u); EXPECT_EQ(gotA[0].time, 1u); - EXPECT_EQ(gotA[0].entities.size(), 3u); + EXPECT_EQ(gotA[0].entities.size(), 4u); EXPECT_EQ(gotB[0].time, 2u); EXPECT_EQ(gotB[0].entities.size(), 1u); diff --git a/tests/net/test_fuzz.cpp b/tests/net/test_fuzz.cpp index d17b4b7..870cc4d 100644 --- a/tests/net/test_fuzz.cpp +++ b/tests/net/test_fuzz.cpp @@ -127,6 +127,11 @@ std::vector corpus() { car.speed = 11.0f; car.flags = kAmbientBrake | kAmbientSignalRight; ambient.entities.push_back(car); + AmbientEntity held; + held.id = 40; + held.generation = 6; + held.hasState = false; // a far car, still there + ambient.entities.push_back(held); car.id = 13; car.flags = kAmbientOffRail | kAmbientWrecked; car.velocity = {3.0f, -1.0f, 2.0f}; @@ -245,6 +250,8 @@ void checkAmbient(const AmbientStateMsg& m) { for (const AmbientEntity& e : m.entities) { ASSERT_LT(e.id, kMaxAmbientIds); ASSERT_LT(e.generation, kAmbientGenerations); + if (!e.hasState) + continue; ASSERT_LE(e.kind, AmbientKind::Last); ASSERT_LT(e.model, kMaxAmbientModels); ASSERT_LE(e.paint, kMaxAmbientPaint); From 455767fa0a938cc6f1cf044510c9665220f024ea Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Thu, 8 Oct 2026 22:45:04 -0700 Subject: [PATCH 30/54] Keep the single race menu's event whole after the host settings The host settings page remembered the host's mode and city for the menus but not the race or its setup, so the single race menu could hold a race mode with cruise's race and setup. Hosting, choosing a circuit in the host settings and leaving the session, the race menu's GO then started the circuit with no opponents (4 from the menu directly) and could record nothing. The page now also keeps the race and applies its defaults, as the race menu does (RaceMenuBase::SetStateRace); MM2's HostRaceMenu edits the one state pack that the race menu shows. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/app/frontend/PagesMulti.cpp | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/app/frontend/PagesMulti.cpp b/src/app/frontend/PagesMulti.cpp index f28f16c..8dbe637 100644 --- a/src/app/frontend/PagesMulti.cpp +++ b/src/app/frontend/PagesMulti.cpp @@ -1235,9 +1235,14 @@ class HostSettingsPage final : public Page { fe.ctx.netGame->setGoldMass(m_goldMass); fe.ctx.netGame->setRaceConfig(m_cfg); } - // Remember the host's choices for the next session. + // Remember the host's choices for the next session, as the event the + // single race menu shows (MM2's HostRaceMenu edits the one state + // pack): the mode with its race and that race's defaults, so the + // menus never hold a race mode with another mode's race or setup. fe.config.mode = m_cfg.mode; fe.config.city = m_cfg.city; + fe.config.raceIndex = m_cfg.raceIndex; + fe.applyRaceDefaults(fe.config); fe.pop(); } From 793a736a3f860c1211d89f1e2d17ed3f5a2d8359 Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Thu, 8 Oct 2026 22:45:30 -0700 Subject: [PATCH 31/54] Drop vehicle states stamped far from the receiver's clock The host relayed a joiner's snapshot times unchanged, so a broken or hostile sender could stamp its car minutes ahead: every receiver would drag the car toward that state and hold its playout delay on it. A receiver now ignores states more than 1 s ahead of its session clock or 5 s behind it (more than any clock error or playout delay), and the host relays only the states it took in. Tests for the replication timing: the playout delay's measurement, the slewed client clock, states carrying their simulation time end to end, nothing replicated in the lobby, World::remainderAfter. The multiplayer document describes the time stamps, the playout delay, the clock and OPENMM2_NET_TRACE. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/multiplayer.md | 87 +++++++++-- src/net/Session.cpp | 17 ++- src/net/Session.h | 7 +- tests/game/test_netgame_sync.cpp | 90 ++++++++++++ tests/net/test_sync.cpp | 238 +++++++++++++++++++++++++++++++ tests/phys/test_kinematic.cpp | 15 ++ 6 files changed, 434 insertions(+), 20 deletions(-) create mode 100644 tests/game/test_netgame_sync.cpp create mode 100644 tests/net/test_sync.cpp diff --git a/docs/multiplayer.md b/docs/multiplayer.md index 39a1856..a898378 100644 --- a/docs/multiplayer.md +++ b/docs/multiplayer.md @@ -42,11 +42,14 @@ Sessions are client/server. The host is a player too, and it is authoritative for the lobby: settings, the player list, ready flags, kicks and the countdown. Clients send requests and the host validates and broadcasts the result. -In game, each machine simulates its own car and sends its state about 20 times -a second. The host bundles the latest state of every car into one packet per -tick for each client. Remote cars are drawn from an interpolation buffer about -100 ms in the past. When packets stop coming, a remote car is extrapolated for -up to 250 ms and then held still. +In game, each machine simulates its own car and sends its state 20 times a +second, stamped with the session time the simulation was at. The host bundles +the latest state of every car into one packet per tick for each client. +Remote cars are drawn from an interpolation buffer a playout delay in the past: +for each car, what its snapshots needed to arrive in time over the last 3 s +(50-500 ms; about 70 ms on a LAN, 150 ms over a 100 ms round trip, 250 ms from +one client to another through the host). When packets stop coming, a remote +car is extrapolated for up to 250 ms and then held still. There is no host migration. If the host leaves, every client gets `Disconnected{HostShutdown}`. @@ -280,6 +283,13 @@ last 8 samples, the one with the lowest RTT is used, since it has the least asymmetry error. Countdown start, snapshot times and game event times are all in session time. +The clock a client shows (`Session::time()` / `timeMs()`, sub-millisecond) +takes a new estimate at once in the lobby. While a race is loading or running +it slews toward it instead, at most 5% faster or slower than real time +(`SlewedClock`; errors over 250 ms are still stepped): the remote cars are +drawn at session times, and a stepped clock moves every car by its speed times +the step. + ### Vehicle snapshots `VehicleSnapshot` is 274 bits (about 35 bytes): @@ -301,6 +311,28 @@ test). Receivers interpolate position with a cubic Hermite spline using the replicated velocities, and orientation with slerp. Extrapolation integrates linear and angular velocity. +**Time stamps.** A snapshot's time is the session time its state belongs to. +The game's simulation runs in fixed 1/60 s steps, so the car it sends is up to +a step older than the frame; the race passes that age +(`NetGame::submitLocalState(..., stateAgeMs)`), measured from the session time +`NetGame::update()` saw at the start of the frame. Stamping the frame's time +instead made 50 ms of stamps carry 3 or 4 steps of motion, and the car surged +back and forth by up to a third of a metre at speed. A receiver drops states +stamped more than 1 s ahead of its clock or 5 s behind it (a broken sender's +times would drag the car), and the host does not relay them. Nothing is +replicated in the lobby. + +**Playout delay.** `SnapshotBuffer::push(snapshot, arrival)` records, for each +snapshot newer than all before it, its lateness (arrival minus stamp: transit, +send interval, the host's relay tick, the sender's clock error) plus the gap +since the previous one. `requiredDelay()` is the largest of these over the +last 3 s, a gap left by a lost snapshot counting as at most 1.5 times the +median gap (a single loss is bridged by a short extrapolation). The session +moves each car's delay toward it plus 4 ms, within +`SessionConfig::interpolationDelayMs` (50) and `maxInterpolationDelayMs` +(500): up at 25% of real time, down at 2%, so the shown time never jumps or +runs backwards. + ### Game events `Session::sendGameEvent(type, payload, target)` delivers reliably and in order. @@ -424,17 +456,26 @@ When `config.multiplayer && ctx.netGame`: Go are the last 3 s). `raceStarted()` becomes true at the start time. 3. **Local car:** after stepping the simulation, `submitLocalState(car.modelMatrix(), linearVelocity, angularVelocity, - controls, damage01, flags)` where `controls` are the pedal/steering inputs + controls, damage01, flags, stateAgeMs)` where `stateAgeMs` is how far the + simulation is behind the frame (the fixed step's unstepped remainder), + `controls` are the pedal/steering inputs and gear, and `flags` combine `net::kVehicleBrakeLights`, `kVehicleHeadlights`, `kVehicleHorn`, `kVehicleSiren`, `kVehicleWrecked`. Sending is rate limited inside (20 Hz). -4. **Remote cars:** `for (const auto& car : ctx.netGame->remoteCars())` gives - each other player's `transform` (car model matrix, ~100 ms in the past, - extrapolated up to 250 ms when packets are late), velocities, controls, - damage and flags. Draw cars with `hasState`; spawn one `VehicleRenderer` - per `car.car.vehicle` / `car.car.color`. For collisions, use them as - kinematic bodies driven by the transform/velocity (the original also showed - remote cars from received positions). +4. **Remote cars:** once a frame, `ctx.netGame->remoteCars(simLagMs)` gives + each other player's `transform` (car model matrix, a playout delay in the + past, extrapolated up to 250 ms when packets are late), velocities, + controls, damage and flags, sampled at the frame's session time less + `simLagMs` (the remainder the frame's fixed steps will leave, + `phys::World::remainderAfter`). Use that one sample for everything in the + frame: the local car, the camera and every simulated object move in whole + steps, and a car sampled at the frame's own time shakes against them by up + to a step's travel (29 cm rms at 35 m/s and 144 fps; at 60 fps, a whole + step on 4-10% of frames). Draw cars with `hasState`; spawn one + `VehicleRenderer` per `car.car.vehicle` / `car.car.color`. For collisions, + use them as kinematic bodies driven by the transform/velocity: their + colliders take the body's velocity, so a car touching one meets it at + their relative speed (the original simulated them as cars). 5. **Events:** report what the race rules decide: `sendCheckpoint(index, raceTimeMs)`, `sendLap(lap, lapTimeMs)`, `sendFinish(raceTimeMs, position)`, Cops & Robbers `sendGold(GoldPickedUp|GoldDropped|GoldDelivered, position, @@ -471,3 +512,23 @@ off the lobby says which UDP port must be opened by hand. discovery, password refusal, chat, car and ready changes, settings propagation, countdown, vehicle state replication, game events, return to lobby, host shutdown and eject. Port forwarding is disabled in these tests. + +### Diagnosing replication + +`OPENMM2_NET_TRACE=` (off unless set) makes each session write, one line +each: + +* `S wall id stamp arrival delay x y z vx vy vz`: every remote snapshot taken + in (`wall` is the local monotonic clock, `stamp` and `arrival` session ms, + `delay` the car's playout delay); +* `F wall frameTime stateAge x y z vx vy vz`: the local car once a frame, as + submitted (`frameTime - stateAge` is the session time its state belongs + to); +* `R wall id stale x y z vx vy vz sampleTime`: each remote car as sampled for + the frame. + +With one trace per machine, a remote car as drawn (`R`) can be compared with +where the other machine's car really was at `sampleTime` (its `F` lines). +Latency, jitter and loss can be added between machines on one computer with a +UDP relay in front of the host (any proxy that delays datagrams; tc/netem +needs root). diff --git a/src/net/Session.cpp b/src/net/Session.cpp index 763b8a1..6b131f6 100644 --- a/src/net/Session.cpp +++ b/src/net/Session.cpp @@ -465,13 +465,23 @@ void Session::updatePlayout() { } } -void Session::receiveState(std::uint8_t id, const VehicleSnapshot& state) { +bool Session::receiveState(std::uint8_t id, const VehicleSnapshot& state) { + // A state is stamped with the sender's session time, which can be off + // by its clock's error; one far ahead of this clock (a broken or hostile + // sender: it would drag the car toward it and hold the playout delay) or + // too old to be shown is dropped, and the host does not relay it. + constexpr double kMaxAheadMs = 1000.0; + constexpr double kMaxBehindMs = 5000.0; if (!replicating()) - return; + return false; const double now = timeMs(); + const auto t = static_cast(state.time); + if (t > now + kMaxAheadMs || t < now - kMaxBehindMs) + return false; m_remoteStates[id].buffer.push(state, now); if (m_stateObserver) m_stateObserver(id, state, now); + return true; } void Session::tickCountdown() { @@ -671,12 +681,11 @@ void Session::hostHandle(Remote& r, MsgType type, std::span dat } case MsgType::VehicleState: { VehicleStateMsg msg; - if (!decodeMessage(data, msg) || !replicating()) + if (!decodeMessage(data, msg) || !receiveState(id, msg.state)) return; // The newest state is relayed with the next WorldState. if (const auto it = m_pendingStates.find(id); it == m_pendingStates.end() || it->second.time < msg.state.time) m_pendingStates[id] = msg.state; - receiveState(id, msg.state); return; } case MsgType::GameEvent: { diff --git a/src/net/Session.h b/src/net/Session.h index 58810be..db980f4 100644 --- a/src/net/Session.h +++ b/src/net/Session.h @@ -228,9 +228,10 @@ class Session { void close(DisconnectReason reason, std::string message, bool failedJoin); void tickCountdown(); void updateShownClock(); - // A remote vehicle's snapshot (ignored in the lobby: late packets of the - // last race). - void receiveState(std::uint8_t id, const VehicleSnapshot& state); + // Takes in a remote vehicle's snapshot; false when it is ignored (in the + // lobby, late packets of the last race; a time stamp far from this + // clock's). + bool receiveState(std::uint8_t id, const VehicleSnapshot& state); // Moves each remote vehicle's playout delay toward what it needs. void updatePlayout(); bool replicating() const { return m_phase != SessionPhase::Lobby; } diff --git a/tests/game/test_netgame_sync.cpp b/tests/game/test_netgame_sync.cpp new file mode 100644 index 0000000..c68383c --- /dev/null +++ b/tests/game/test_netgame_sync.cpp @@ -0,0 +1,90 @@ +// NetGame's replication timing: a car's state carries the time the +// simulation was at, and remote cars are sampled with the simulation's lag. +#include "game/net/NetGame.h" + +#include + +#include +#include +#include +#include + +using namespace mm2; +using game::NetGame; + +namespace { + +std::uint16_t syncPort() { + static const std::uint16_t port = static_cast( + 33000 + (std::chrono::steady_clock::now().time_since_epoch().count() / 1000) % 4000 * 2); + return port; +} + +game::NetOptions options(const std::string& name) { + game::NetOptions o; + o.playerName = name; + o.port = syncPort(); + o.portMapping = false; + o.discoveryPort = static_cast(syncPort() + 1); + return o; +} + +bool pump(std::initializer_list games, const std::function& done, int timeoutMs = 4000) { + const auto end = std::chrono::steady_clock::now() + std::chrono::milliseconds(timeoutMs); + while (std::chrono::steady_clock::now() < end) { + for (NetGame* g : games) + g->update(); + if (done()) + return true; + std::this_thread::sleep_for(std::chrono::milliseconds(2)); + } + return false; +} + +} // namespace + +TEST(NetGameSync, StatesCarryTheSimulationTimeAndCarsAreSampledWithItsLag) { + NetGame host(options("Host")); + NetGame client(options("Client")); + game::RaceConfig cruise; + std::string err; + ASSERT_TRUE(host.host(cruise, {"Sync", "", 2, false}, {"vpbug", 0, 0}, &err)) << err; + ASSERT_TRUE(client.join(std::format("127.0.0.1:{}", syncPort()), "", {"vpbug", 0, 0}, &err)) << err; + ASSERT_TRUE(pump({&host, &client}, [&] { return host.players().size() == 2; })); + pump({&host, &client}, [] { return false; }, 400); // clock sync + host.startRace(200); + ASSERT_TRUE(pump({&host, &client}, [&] { return host.raceStarted() && client.raceStarted(); })); + + // The client's simulation is 10 ms behind each frame; its car runs along + // +X at 10 m/s, at x = time / 100 of the session time the state belongs + // to. Stamped with the frame's own time instead, it would be drawn 10 cm + // off. + constexpr double kLag = 10.0; + auto drive = [&] { + const double simulated = client.frameTime() - kLag; + Mat34 m = Mat34::identity(); + m.m3 = {static_cast(simulated / 100.0), 0, 0}; + client.submitLocalState(m, {10, 0, 0}, {}, {}, 0.0f, 0, kLag); + }; + ASSERT_TRUE(pump({&host, &client}, [&] { + drive(); + const auto cars = host.remoteCars(); + return cars.size() == 1 && cars[0].hasState && !cars[0].stale && cars[0].transform.m3.x > 1.0f; + })); + for (int i = 0; i < 20; ++i) { + pump({&host, &client}, [&] { + drive(); + return true; + }); + const auto cars = host.remoteCars(); + ASSERT_EQ(cars.size(), 1u); + if (cars[0].stale) + continue; + EXPECT_NEAR(cars[0].transform.m3.x, cars[0].time / 100.0, 0.01); + // A simulation 5 ms behind the frame samples the car 5 ms earlier. + const auto lagged = host.remoteCars(5.0); + EXPECT_DOUBLE_EQ(lagged[0].time, cars[0].time - 5.0); + // Shown at least the least playout delay in the past. + EXPECT_GE(host.frameTime() - cars[0].time, 50.0 - 1e-9); + } +} diff --git a/tests/net/test_sync.cpp b/tests/net/test_sync.cpp new file mode 100644 index 0000000..7c71366 --- /dev/null +++ b/tests/net/test_sync.cpp @@ -0,0 +1,238 @@ +// In-race synchronisation: snapshot time stamps, the playout delay, the +// client's session clock, and what a session takes in during a race. +#include "net/ClockSync.h" +#include "net/Session.h" +#include "net/Snapshot.h" + +#include + +#include +#include +#include + +using namespace mm2; +using namespace mm2::net; + +namespace { + +VehicleSnapshot at(std::uint32_t timeMs, float x = 0.0f) { + VehicleSnapshot s; + s.time = timeMs; + s.position = {x, 0, 0}; + return s; +} + +// Snapshots every 50 ms from t = 1000, each arriving `late` ms after its stamp. +void feed(SnapshotBuffer& b, int count, double late, std::uint32_t start = 1000, std::uint32_t every = 50) { + for (int i = 0; i < count; ++i) { + const auto t = start + static_cast(i) * every; + b.push(at(t), t + late); + } +} + +} // namespace + +TEST(PlayoutDelay, CoversLatenessPlusTheGap) { + SnapshotBuffer b; + EXPECT_LT(b.requiredDelay(), 0.0); // nothing measured yet + feed(b, 20, 80.0); + // Until the next snapshot arrives the newest one is 50 + 80 ms old. + EXPECT_DOUBLE_EQ(b.requiredDelay(), 130.0); +} + +TEST(PlayoutDelay, FollowsTheLatestSnapshotAndForgetsOldOnes) { + SnapshotBuffer b; + feed(b, 20, 80.0); + // One snapshot 150 ms late raises what is needed... + b.push(at(2000), 2150.0); + EXPECT_DOUBLE_EQ(b.requiredDelay(), 150.0 + 50.0); + // ...until it is older than the window. + for (std::uint32_t t = 2050; t <= 2200 + SnapshotBuffer::kDelayWindowMs; t += 50) + b.push(at(t), t + 30.0); + EXPECT_DOUBLE_EQ(b.requiredDelay(), 80.0); +} + +TEST(PlayoutDelay, ALostSnapshotIsBridgedNotWaitedFor) { + SnapshotBuffer b; + feed(b, 19, 40.0); // up to 1900 + // 1950 never arrives: the 100 ms gap counts as 1.5 times the usual 50. + b.push(at(2000), 2040.0); + EXPECT_DOUBLE_EQ(b.requiredDelay(), 40.0 + 75.0); +} + +TEST(PlayoutDelay, OutOfOrderSnapshotsDoNotCount) { + SnapshotBuffer b; + feed(b, 10, 20.0); // up to 1450 + b.push(at(1425), 1900.0); // an old one, very late: nothing waited for it + EXPECT_DOUBLE_EQ(b.requiredDelay(), 70.0); + EXPECT_EQ(b.size(), 11u); +} + +TEST(SlewedClock, StepsOutsideARaceAndSlewsInOne) { + SlewedClock c; + EXPECT_FALSE(c.valid()); + c.update(0.0, 1000.0, true); // the first estimate is taken at once + EXPECT_DOUBLE_EQ(c.offset(), 1000.0); + c.update(100.0, 1040.0, false); // lobby: at once + EXPECT_DOUBLE_EQ(c.offset(), 1040.0); + // Racing: 20 ms of correction at 5% takes 400 ms; from update to update + // the clock never runs backwards and never jumps. + c.update(200.0, 1020.0, true); + EXPECT_DOUBLE_EQ(c.offset(), 1035.0); // 100 ms at 5% + double shownBefore = 200.0 + c.offset(); + for (double t = 210.0; t <= 700.0; t += 10.0) { + c.update(t, 1020.0, true); + const double shown = t + c.offset(); + EXPECT_GT(shown, shownBefore); + EXPECT_LE(shownBefore + 10.0 - shown, 0.5 + 1e-9); + shownBefore = shown; + } + EXPECT_DOUBLE_EQ(c.offset(), 1020.0); + // A huge error is stepped even in a race. + c.update(710.0, 5000.0, true); + EXPECT_DOUBLE_EQ(c.offset(), 5000.0); +} + +TEST(ClockSync, KeepsSubMillisecondPrecision) { + ClockSync c; + c.addSample(10.25, 1000.5, 10.75); + EXPECT_DOUBLE_EQ(c.rtt(), 0.5); + EXPECT_DOUBLE_EQ(c.offset(), 1000.5 + 0.25 - 10.75); +} + +// --- Sessions on loopback ----------------------------------------------------------------- + +namespace { + +SessionConfig syncConfig() { + SessionConfig c; + c.connectTimeoutMs = 3000; + c.joinTimeoutMs = 3000; + c.snapshotRateHz = 50; + return c; +} + +struct Peer { + std::unique_ptr session = std::make_unique(syncConfig()); + std::vector events; + void pump() { + session->update(); + for (auto& e : session->takeEvents()) + events.push_back(std::move(e)); + } + template + bool has() const { + for (const auto& e : events) + if (std::holds_alternative(e)) + return true; + return false; + } +}; + +bool pumpUntil(std::vector peers, const std::function& done, int timeoutMs = 3000) { + const auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(timeoutMs); + while (std::chrono::steady_clock::now() < deadline) { + for (Peer* p : peers) + p->pump(); + if (done()) + return true; + std::this_thread::sleep_for(std::chrono::milliseconds(2)); + } + return false; +} + +void pumpFor(std::vector peers, int ms) { pumpUntil(std::move(peers), [] { return false; }, ms); } + +// A host and one client, joined and clock-synced. +void connect(Peer& host, Peer& client) { + HostParams h; + h.bind = Address::loopback(0); + h.advertiseOnLan = false; + h.player.name = "Host"; + ASSERT_TRUE(host.session->host(h)); + JoinParams j; + j.host = Address::loopback(host.session->port()); + j.player.name = "Client"; + ASSERT_TRUE(client.session->join(j)); + ASSERT_TRUE(pumpUntil({&host, &client}, [&] { return client.has(); })); + pumpFor({&host, &client}, 400); // the clock sync burst +} + +void startRace(Peer& host, Peer& client) { + host.session->startCountdown(100); + ASSERT_TRUE(pumpUntil({&host, &client}, [&] { return client.has(); })); +} + +} // namespace + +TEST(SessionSync, StatesCarryTheTimeTheyWereSimulatedAt) { + Peer host, client; + connect(host, client); + startRace(host, client); + // The host's car as it stood 12.5 ms before now. + const double simulated = host.session->timeMs() - 12.5; + VehicleSnapshot s; + s.position = {3, 0, 0}; + host.session->submitLocalState(s, simulated); + VehicleSnapshot seen; + ASSERT_TRUE(pumpUntil({&host, &client}, [&] { + return client.session->sampleRemoteAt(0, simulated, seen) != SnapshotBuffer::Result::Empty; + })); + EXPECT_EQ(seen.time, static_cast(std::llround(simulated))); +} + +TEST(SessionSync, NothingIsReplicatedInTheLobby) { + Peer host, client; + connect(host, client); + const std::uint8_t id = client.session->localId(); + VehicleSnapshot s; + client.session->submitLocalState(s); + host.session->submitLocalState(s); + pumpFor({&host, &client}, 200); + VehicleSnapshot out; + EXPECT_EQ(host.session->sampleRemote(id, out), SnapshotBuffer::Result::Empty); + EXPECT_EQ(client.session->sampleRemote(0, out), SnapshotBuffer::Result::Empty); +} + +TEST(SessionSync, StatesStampedFarFromTheClockAreDropped) { + Peer host, client; + connect(host, client); + startRace(host, client); + const std::uint8_t id = client.session->localId(); + VehicleSnapshot s; + // Ten seconds ahead: neither taken in nor relayed. + client.session->submitLocalState(s, client.session->timeMs() + 10000.0); + pumpFor({&host, &client}, 200); + VehicleSnapshot out; + EXPECT_EQ(host.session->sampleRemote(id, out), SnapshotBuffer::Result::Empty); + // A sane one is. + client.session->submitLocalState(s); + ASSERT_TRUE(pumpUntil({&host, &client}, [&] { + return host.session->sampleRemote(id, out) != SnapshotBuffer::Result::Empty; + })); +} + +TEST(SessionSync, PlayoutDelayFollowsTheArrivals) { + Peer host, client; + connect(host, client); + startRace(host, client); + const std::uint8_t id = client.session->localId(); + EXPECT_DOUBLE_EQ(host.session->playoutDelay(id), syncConfig().interpolationDelayMs); + // On loopback snapshots arrive within a frame: the delay stays at its + // least, and the car is drawn from between two snapshots. + VehicleSnapshot out; + const auto start = std::chrono::steady_clock::now(); + ASSERT_TRUE(pumpUntil( + {&host, &client}, + [&] { + VehicleSnapshot s; + s.position = {10.0f * std::chrono::duration(std::chrono::steady_clock::now() - start).count(), 0, 0}; + s.linearVelocity = {10, 0, 0}; + client.session->submitLocalState(s); + return std::chrono::steady_clock::now() - start > std::chrono::milliseconds(600) && + host.session->sampleRemote(id, out) == SnapshotBuffer::Result::Interpolated; + }, + 3000)); + EXPECT_GE(host.session->playoutDelay(id), syncConfig().interpolationDelayMs); + EXPECT_LT(host.session->playoutDelay(id), 200.0); +} diff --git a/tests/phys/test_kinematic.cpp b/tests/phys/test_kinematic.cpp index 78c2904..4f4ed40 100644 --- a/tests/phys/test_kinematic.cpp +++ b/tests/phys/test_kinematic.cpp @@ -64,6 +64,21 @@ TEST(Kinematic, TouchingAMovingKinematicBodyTakesTheRelativeSpeed) { EXPECT_LE(v, 30.0f); } +TEST(World, RemainderAfterIsWhatAdvanceFixedLeaves) { + // RaceScreen samples the network cars at the time the frame's fixed + // steps will reach, before stepping. + World world; + const float frames[] = {0.0069f, 0.0071f, 0.0166f, 0.0167f, 0.0334f, 0.002f, 0.1f, 0.0f, 0.6f}; + for (int k = 0; k < 3; ++k) { + for (float dt : frames) { + const float expected = world.remainderAfter(dt); + world.advanceFixed(dt); + EXPECT_EQ(world.remainder(), expected) << dt; + EXPECT_LT(world.remainder(), kFixedSampleStep); + } + } +} + TEST(Kinematic, AStillKinematicBodyIsAWall) { // At rest, a kinematic body is hit like a wall: the 5 m/s chaser stops. const float v = speedAfterCatchingUp(0.0f, 5.0f); From 9d4b39e0d9b31deb162b28790cdee8bb04815d08 Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Thu, 8 Oct 2026 22:46:41 -0700 Subject: [PATCH 32/54] Show the results to the last finisher when the host ends a network race A multiplayer checkpoint race or circuit ends on the host once every player is counted, and the host then takes everyone back to the lobby. The last player to finish is still in its 3 s post-race wait when that return arrives, and its race screen left with the race's bare configuration: no results page for the player whose finish completed the race. MM2's host sends 0x211 when everyone is counted, and every machine goes to its results (mmMultiRace / mmMultiCircuit::UpdateGame state 5). The same race could happen between a Cops and Robbers limit announced on a joiner and the host's return. When the host ends the race (or the session ends), a race this machine has finished or lost, or a Cops and Robbers game whose limit it has announced, now leaves with its results as at its own ending. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/app/RaceScreen.cpp | 47 +++++++++++++++++++++++++++++++----------- 1 file changed, 35 insertions(+), 12 deletions(-) diff --git a/src/app/RaceScreen.cpp b/src/app/RaceScreen.cpp index a2c0e0a..ba92ed6 100644 --- a/src/app/RaceScreen.cpp +++ b/src/app/RaceScreen.cpp @@ -250,7 +250,7 @@ class RaceScreen final : public Screen { if (ctx.netGame->backToLobby(m_netRace) || !ctx.netGame->inSession()) { log::info("race: the network race is over ({})", ctx.netGame->inSession() ? "back to the lobby" : "the session ended"); - leaveRace(ctx, m_result); + leaveRace(ctx, netRaceResult(ctx)); return; } } @@ -2130,6 +2130,21 @@ class RaceScreen final : public Screen { m_throttleCap = kg > 0.0f ? m_cr->carrierThrottleCap() : 1.0f; } + // mmMultiCR::FillResults: the game's scores for the results page. + game::RaceResult crResult(Context& ctx) const { + game::RaceResult r = m_session->result(); + r.ended = true; + std::vector players; + for (const auto& p : ctx.netGame->players()) + players.push_back({p.name, m_cr->playerScore(p.id), p.id == m_crSelf}); + const auto& strings = ctx.game->strings; + r.standings = frontend::crResultRows( + m_result.config.copsAndRobbers, m_cr->score(game::session::CrTeam::Cop), + m_cr->score(game::session::CrTeam::Robber), players, + [&strings](std::uint32_t id, const char* fallback) { return strings.get(id, fallback); }); + return r; + } + void sendCr(Context& ctx, const std::vector& messages) { using Type = game::session::CopsAndRobbers::Message::Type; for (const auto& m : messages) { @@ -2284,17 +2299,7 @@ class RaceScreen final : public Screen { if (m_crEnd < 0.0f) { // FillResults; mmPlayer +0x2258; ShowResults. m_crFinished = true; - game::RaceResult r = m_session->result(); - r.ended = true; - std::vector players; - for (const auto& p : ctx.netGame->players()) - players.push_back({p.name, m_cr->playerScore(p.id), p.id == m_crSelf}); - const auto& strings = ctx.game->strings; - r.standings = frontend::crResultRows( - m_result.config.copsAndRobbers, m_cr->score(game::session::CrTeam::Cop), - m_cr->score(game::session::CrTeam::Robber), players, - [&strings](std::uint32_t id, const char* fallback) { return strings.get(id, fallback); }); - leaveRace(ctx, r); + leaveRace(ctx, crResult(ctx)); return; } } @@ -2494,6 +2499,24 @@ class RaceScreen final : public Screen { leaveRace(ctx, r); } + // What a network race leaves with when the host ends it (or the session + // ends): a race this machine has already finished or lost, or a Cops and + // Robbers game whose limit it has announced, shows its results as at its + // own ending. The host ends the race once every player is counted + // (mmMultiRace / mmMultiCircuit 0x211, which takes every machine to its + // results); the last finisher, still in its post-race wait, used to go + // back without them. + game::RaceResult netRaceResult(Context& ctx) const { + if (m_resultsShown || !m_session) + return m_result; + if (m_cr && m_crEnd >= 0.0f) + return crResult(ctx); + const auto phase = m_session->phase(); + if (phase == game::session::Phase::PostRace || phase == game::session::Phase::Done) + return m_session->result(); + return m_result; + } + // Back to the menus, the view settings stored before the frontend reads // the driver again. The host of a network game takes everyone back to // the lobby (mmGameMulti::BeDone(1): Quit2Lobby, 0x20c). From 450795f25fe3529d2b8bd3f6450f5f5663886920 Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Thu, 8 Oct 2026 22:47:13 -0700 Subject: [PATCH 33/54] Trim a joiner's password as the host's is The host options dialog trims the password it hosts with; the password dialog sent what was typed, so a stray space before or after the right password was refused as wrong. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/app/frontend/PagesMulti.cpp | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/app/frontend/PagesMulti.cpp b/src/app/frontend/PagesMulti.cpp index 8dbe637..d978bff 100644 --- a/src/app/frontend/PagesMulti.cpp +++ b/src/app/frontend/PagesMulti.cpp @@ -458,7 +458,8 @@ class PasswordDialog final : public Page { menu.add(fe.layout.widget(kPasswordDialog, 0, {72, 91, 203, 22}, origin), &m_password, 24); auto join = [this, &fe] { const auto target = m_address; - const auto password = m_password; + // Trimmed as the host's is (HostOptionsDialog::host). + const auto password = std::string(str::trim(m_password)); fe.pop(); joinSession(fe, target, false, password); }; From e2ef716b4fa2e66f03f394d8ef0b5f0708bf8ab5 Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Thu, 8 Oct 2026 22:48:06 -0700 Subject: [PATCH 34/54] Start a new network session without the last one's notices The lobby now shows notices while the session goes on (a race that could not load), so a notice left from an earlier session must not appear in the next one: hosting and joining start with none. The multiplayer notes said the host settings add Snowing to the weather; the page offers Clear to Raining (RaceMenuBase::IncWeather stops there). Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/multiplayer.md | 2 +- src/game/net/NetGame.cpp | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/multiplayer.md b/docs/multiplayer.md index cd22863..366acc7 100644 --- a/docs/multiplayer.md +++ b/docs/multiplayer.md @@ -404,7 +404,7 @@ from the player's own state. | Enter an address | `tcp_dlg` | IP, `ip:port` or host name; blank = search the LAN again; Enter is DONE (`Dialog_TCPIP`); it starts with the driver's last address, which is saved with the driver when a race starts (`Dialog_TCPIP::SetIPAddress`, `mmInterface::BeDone`) | | Password | `pass_dlg` | asked before joining a listed session that has one, and when a session joined by address wants one; a wrong password shows `badp_dlg` and asks again (`Dialog_Password`, `mmInterface::Update`) | | Lobby | `lobbh_bk` / `lobbj_bk` | `NetArena`: host settings (mode, race, weather, time, laps or gold weight, limit), the race map and the city's name, players (`mmCompRoster` rows: the `ready` icon, which the host always shows, the team dot `blue_dot` / `red_dot` in team games, the name cut to six characters and "..." when wider than 0.09 of the screen, the car), YOU, team lamps for team games, chat line ("Type message here. Press ENTER to send."), the last three chat lines (" Name> text") of this visit, port forwarding status (host, where a race without a map would show it); host: EJECT PLAYER, HOST SETTINGS, SELECT VEHICLE in the row above GO DRIVE (which starts once everyone else is ready); joiner: SELECT VEHICLE, READY. A joiner's READY is cleared when the host changes the settings and when the joiner opens SELECT VEHICLE. BACK and Escape leave the session at once. When the race starts, the lobby's car and the session's event become the driver's last car and event, as for a single-player race (`MultiStartGame` calls `BeDone`). | -| Host settings | `host_bk` | game type lamps, race name + laps panels (`host_rnm`, `host_lap`) or the Cops & Robbers panel (`host_cr`: game types, limits, gold mass), location, time of day, weather (adds Snowing), pedestrian density | +| Host settings | `host_bk` | game type lamps, race name + laps panels (`host_rnm`, `host_lap`) or the Cops & Robbers panel (`host_cr`: game types, limits, gold mass), location, time of day, weather (Clear, Cloudy, Foggy, Raining: `RaceMenuBase::IncWeather` stops at Raining; a snowing session shows "Weather: Snowing" in the lobby), pedestrian density | | Eject | `ejct_dlg` | pick a player to remove | Positions of the provider/race/Cops & Robbers/team lamps, the HOST/JOIN and diff --git a/src/game/net/NetGame.cpp b/src/game/net/NetGame.cpp index 287dd85..38f23ec 100644 --- a/src/game/net/NetGame.cpp +++ b/src/game/net/NetGame.cpp @@ -202,6 +202,7 @@ bool NetGame::host(const RaceConfig& config, const NetHostOptions& hostOptions, } log::info("netgame: hosting '{}' on port {}", impl.sessionName, impl.session->port()); m_chat.clear(); + m_notices.clear(); // a new session: nothing left over from the last addSystemLine("**You are now the host**"); // string 70 startPortMapping(); return true; @@ -236,6 +237,7 @@ bool NetGame::join(const net::Address& address, const std::string& password, con } m_impl->joining = true; m_chat.clear(); + m_notices.clear(); // a new session: nothing left over from the last log::info("netgame: joining {}", address.toString()); return true; } From 77fc12740d36c8686aece708807b41e1a8efecbe Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Thu, 8 Oct 2026 22:51:08 -0700 Subject: [PATCH 35/54] Record the multiplayer lobby and session flow review The root cause of the lobby bounce (a return to the lobby left pending in the menus ended the next race), the fixes made, what was checked and found correct, and the findings left open, among them MM2's wait for every player to load before the start. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/review/multiplayer-lobby.md | 159 +++++++++++++++++++++++++++++++ 1 file changed, 159 insertions(+) create mode 100644 docs/review/multiplayer-lobby.md diff --git a/docs/review/multiplayer-lobby.md b/docs/review/multiplayer-lobby.md new file mode 100644 index 0000000..ff31589 --- /dev/null +++ b/docs/review/multiplayer-lobby.md @@ -0,0 +1,159 @@ +# Multiplayer review: lobby and session flow + +Reviewed on 2026-10-08 from `integration` at 2734bea (line numbers below are +at that commit), then merged with the network input review (84fda68). + +The maintainer's reports for this area: "Whenever I try to start a +multiplayer match, loading screen completes then boots everyone back to the +multiplayer lobby", which "happens sometimes. Usually it breaks after I enter +a lobby and try to change my car or options etc." + +## Root cause of the lobby bounce + +It is the **second and every later race of a session**, whatever was changed +in the lobby (finding 1). When a race ended on the host (a finish, the +results, Cops and Robbers' limit, or Quit to Lobby), `RaceScreen::leaveRace` +called `NetGame::returnToLobby` and switched to the menus. The session's +`ReturnedToLobby` event reached `NetGame::handleEvents` on the menus' next +update and set `m_returnPending`, which only `RaceScreen` ever took. The next +race's first frame after loading took it as its own (`RaceScreen.cpp:238`), +left, and the host, still in that race's countdown, sent everyone back to the +lobby, which set the flag again: from then on every race of the session +bounced until the host left the session. The first race of a session always +worked, which is why the earlier loopback tests (one race per process) never +saw it, and why it looked tied to "changing the car or options": that is +what players do between races. + +Reproduced with a host and a client on loopback (UDP 2310): race 1 in cruise, +the host quits to the lobby (popup script), both change cars, the host opens +and closes HOST SETTINGS, the client gets ready, GO. Before the fix the host's +log had `race: loaded sf in 0.72 s` followed 20 ms later by `the network race +is over (back to the lobby)` and `the host leaves the race: everyone back to +the lobby`, and the client followed. After it, four races in a row (cruise, +checkpoint, Cops and Robbers, circuit) each ran until the host quit them, with +the client on the OPTIONS page or in the garage when one started. + +The other leads were checked and are not the cause: + +* A mode change in HOST SETTINGS cannot leave a race index that does not fit: + `HostSettingsPage::selectMode` and the city change go to the first open + race (`clampRace`), cruise and Cops and Robbers to -1. A race mode with an + index the city lacks would load with no race rules (`Session::create` + fails) and never end; it would not bounce. +* Host and joiners build the race from the same `SessionSettings` with the + same `fromSessionSettings`; nothing is carried differently (the city is + cleared on a joiner only when it is not a base name, which the host's menus + cannot produce). +* A paint job out of range is taken modulo (`GpuModel::materials`); a car + the machine lacks is now the default vehicle (`netVehicle`, network input + review). Neither ends a race. +* A race that starts while a player is on the OPTIONS page or in the garage + works (both tried): the frontend switches to the race from any page. +* A load failure did not bounce anyone; it stranded the host instead + (finding 7). + +## What I read + +* `src/net/Session.{h,cpp}`, `src/net/Protocol.h` (lobby messages), + `src/net/Transport.h` (timeouts), completely. +* `src/game/net/NetGame.{h,cpp}`, completely. +* `src/app/frontend/PagesMulti.cpp`, `FrontendScreen.cpp`, `Frontend.h`, + completely; `PagesRace.cpp` (the garage's lobby mode, the race menu's + setup), `PagesOptions.cpp` (when options apply), `PagesMain.cpp`. +* `src/app/RaceScreen.cpp`: construction, `update`, the load steps + (`loadCityPart`, `loadVehicle`, `createSession`, `loadFinish`), + `updateSession`, `updateNetRace`, the chat and player-list code, Cops and + Robbers setup and ending, `leaveRace`, `quitToMenu`, the popup and its + quit page, remote cars; `game::session::Session`'s multiplayer race rules + (`updateRules`, `updateRace`, `updateNetRace`, `endRace`, `result`). +* MM2Recomp: `mmInterface::MultiStartGame`, `GetSessionData`, + `ChangePlayerData`, `MultiAllReady`, the Cops vs. Robbers car assignment + (the `cnr_team` test in the function the map calls `RequestProverb`), + `mmGame::Init` (the transmission, asm), `mmGameMulti::PlayerFinishedLoading`, + `SendRaceReady`, `SendGameSet`, `GetCarCRC`, `mmMultiRace::UpdateGame`, + `mmNetObject::Init`. + +## How it was tested + +* `tests/game/test_netgame_lobby.cpp`: a host and a client `NetGame` in one + process through races, returns, car and settings changes. +* `tests/net/test_session_lobby.cpp`: three sessions on loopback through two + races (fails on the old code at each check). +* Host and client processes on loopback, UDP 2310, driven by + `OPENMM2_FRONTEND_SCRIPT` and `OPENMM2_POPUP_SCRIPT`. To drive more than one + race, the frontend script now survives a race (`wait:race`, the commands + after the one that started the race run when the menus are back) and + `mp:car:[:]` does what SELECT VEHICLE, a pick and PREV do + (docs/multiplayer.md, Automation). +* A temporary test (not kept) measured how long a host keeps a silent + joiner on loopback: 6.6 s. + +## Findings + +Severity: crash / gameplay-breaking / visible / minor / code quality. + +### Fixed + +| # | Severity | Location | Scenario | Confirmed | Fix | +| --- | --- | --- | --- | --- | --- | +| 1 | gameplay-breaking | `NetGame.cpp:301`, `:559`; `RaceScreen.cpp:238` | The lobby bounce (above): a return to the lobby that reached the menus stayed pending and ended the next race on its first frame; the host then sent everyone back. Also hit a joiner who had quit a race early (alone). | repro (two processes); tests `NetGameLobby.ReturnToLobbyEndsOnlyItsOwnRace`, `ReturnSeenInTheMenusDoesNotEndTheNextRace`, `ReturnAndNewCountdownInOneUpdate` | ed3d80e: races are numbered (`raceNumber`), a return ends the race it arrives in (`backToLobby(number)`) | +| 2 | visible | `NetGame.cpp:296` | Game events still queued from a race (a joiner's finish sent while the host was already going back, every event of a cruise, which reads none) reached the next race: a stale finish showed "X finished in", counted X as finished and armed the 60 s finish timeout. | test `StaleGameEventsAreDroppedAtTheNextCountdown` (fails without the fix) | ed3d80e: dropped when a countdown starts | +| 3 | gameplay-breaking | `FrontendScreen.cpp:917` | Back from a network race the menus took the race's whole configuration as the driver's: multiplayer on, no traffic, police or opponents, the host's difficulty. Leaving the session, the next single-player cruise had 0 police cars instead of 19, the multiplayer rules and starts, and recorded nothing. | repro (log) | b11efc7: the menus keep the driver's state from the profile, which `saveNetEvent` already gave the network event and car (MM2's `MultiStartGame` -> `BeDone`) | +| 4 | minor | `PagesMulti.cpp:165` | The sessions page rebuilt under the lobby after a race restarted the LAN browser, which then queried every second through the following races. | by reading | b11efc7 | +| 5 | visible (parity) | `NetGame.cpp:415`; `FrontendScreen.cpp:328`, `:971` | Every network race was driven with the automatic gearbox (`RaceConfig::automatic`'s default), and the menus then saved that as the driver's choice. MM2's session data has no transmission (`GetSessionData`) and `mmGame::Init` sets `vehTransmission::Automatic` from the player's own state. | test `RaceKeepsTheDriversTransmission` | 97f2fb4, a69c5d7: `NetCar::automatic` (never sent), from the garage and the control options | +| 6 | gameplay-breaking (parity) | `NetGame.cpp:599`; `RaceScreen.cpp:2092`; `PagesMulti.cpp:782` | Cops vs. Robbers put each machine's own player in the team's car but drew and counted the others with their lobby car. A robber who had picked a police car was a police car on every other machine, and Cops and Robbers took him for a cop there (`crTeam`), so the gold rules disagreed between machines. MM2's lobby sets the player's car to the team's and sends it (`cnr_team`, `ChangePlayerData`). | test `CopsVsRobbersCarsAreTheSameOnEveryMachine`; run (robber with vpcop drove vpmustang99) | 6f77448: `game::raceCar`, `NetGame::playerCar`, used by `remoteCars`, the race's teams and the roster | +| 7 | gameplay-breaking | `RaceScreen.cpp:741`, `:893` | A city that failed to load sent the race screen straight to the menus. On the host the session stayed in the race: GO DRIVE could never start another race (`startRace` needs the lobby) and the joiners waited in a race that would not end. A missing player car left a race nobody could drive. | repro (`city:nowhere`): before, the second GO did nothing; after, both back in the lobby and the next GO starts | def868b, 419589d: through `leaveRace` (the host takes everyone back), with a notice in the lobby ("Cannot load the city ...", also on a joiner, as the input review asked) | +| 8 | gameplay-breaking (plausible on slow machines) | `RaceScreen.cpp:177` | Nothing serviced the session while a race loaded. A host keeps a silent joiner 6.6 s (measured), so a machine loading that much more slowly than the others was dropped. | measured timeout; slow load not reproduced | def868b: the session is serviced between the load steps | +| 9 | visible | `NetGame.cpp:314`, `:659`; `PagesMulti.cpp:602`; `RaceScreen.cpp:1980-1992` | The lobby and the race remembered chat lines as indices into the last 64 lines; once the list was full they stopped moving and no new line was shown again in that session. The race also began at index 0 and posted every line typed in the lobby on the HUD at the start (seen), with a network alert for each of the others'. | test `ChatLinesAreFoundBySerialAfterTheListIsFull`; screenshot | 5ac76a6: line serials | +| 10 | visible | `Session.cpp:336`, `:534`, `:733`, `:900` | A client kept sending its last race state through the lobby at 20 Hz; the host stored and relayed it. The next race began with that car drawn, and collided with as a kinematic body, where it had stopped in the last race until new states arrived. | test `SessionLobby.NoVehicleStatesBetweenRaces` (fails at each check on the old code) | d49c2e9: cars are replicated during a race only; the countdown and the return clear every buffer | +| 11 | visible | `RaceScreen.cpp:238-239` | The host ends a multiplayer race or circuit once every player is counted; the last finisher was still in its 3 s post-race wait when the return arrived and left without a results page. MM2's 0x211 takes every machine to its results (`mmMultiRace::UpdateGame` state 5). The same race exists between a Cops and Robbers limit announced on a joiner and the host's return. | by reading (needs a finished network race) | 9d4b39e: a race this machine finished or lost leaves with its results | +| 12 | minor | `PagesMulti.cpp:1228` | HOST SETTINGS gave the menus the host's mode and city but not the race or its setup. Choosing a circuit there and leaving the session, the race menu's GO started the circuit with 0 opponents (4 from the menu) and nothing could be recorded. | repro (log, before and after) | 455767f | +| 13 | minor | `PagesMulti.cpp:458` | The host's password is trimmed, a joiner's was not: a stray space was "wrong password". | by reading | 450795f | +| 14 | minor | `NetGame.cpp` `host` / `join` | Notices left from an earlier session could show in the next lobby (now that the lobby shows notices during a session). | by reading | e2ef716 | +| 15 | code quality | docs/multiplayer.md | Said HOST SETTINGS adds Snowing; the page stops at Raining (`RaceMenuBase::IncWeather`). | by reading | e2ef716 | + +### Checked and correct + +* Session phases Lobby -> Countdown -> InGame -> Lobby on host and joiners; + `startRace` only from the lobby; GO DRIVE twice starts one race; + `MultiAllReady` (a host alone may start, as MM2 outside the Zone). +* Kick (lobby and in-race roster), host quit (in the lobby, during a + countdown, during a load, during a race), a joiner quitting early, a + joiner leaving mid-countdown: each machine ends where it should with its + notice ("You have been ejected", "The Host has quit"). +* Password refusal and the password dialog (run), version mismatch, full + session, joining a running race ("The race has already started."), + rejoining after leaving or being ejected, three players (session test). +* The input review's relay budget delays the relay of a joiner's car and + ready changes to the others, not the host's own copy: the host's + `everyoneReady` sees a change at once, and a delayed update always carries + the latest state, so it cannot add to the bounce. + +### Open + +| # | Severity | Location | Scenario | Status / what it needs | +| --- | --- | --- | --- | --- | +| O1 | gameplay (parity) | `NetGame::startRace`, `Session::startCountdown` | The race starts a fixed 6 s after GO DRIVE whatever the loading. MM2 waits: every machine sends RaceReady (0x1f6, `SendRaceReady`) 5 s into its state 0, the host shows "waiting for " until all have, then sends 0x20f and every machine counts Ready / Set / Go (`mmMultiRace::UpdateGame`). A machine that needs more than 3.5 s to load misses part of the countdown (Ready / Set / Go are its last 2.5 s), and one that needs more than 6 s starts behind the others (here a race loads in 0.6 s). | plausible; needs a "loaded" message from each machine and a start time the host sends when all have loaded (a protocol bump), then `RaceScreen`'s start signal from it | +| O2 | minor | `PagesMulti.cpp` `LobbyPage::update` | A joiner's READY is cleared on a settings change only while the lobby page is on top (MM2's `MessageCallback` clears it on any page); the host can also press GO before a joiner's clear arrives. Both machines still load the same race. | plausible; the host could clear the joiners' ready flags itself when it changes the settings | +| O3 | minor | `PagesOptions.cpp` | A ready joiner may open OPTIONS from the lobby; if the race starts there, changes made on an option page are in effect but neither saved nor cancelled. | by reading; leave as is or clear ready on OPTIONS as on SELECT VEHICLE | +| O4 | minor | `Frontend::cityIndex` | A session city the data lacks is shown as San Francisco (with its map) in the lobby; loading it now fails with a notice. | by reading; show the name as given and no map | +| O5 | minor | `SessionsPage::leave`, `NetGame::~NetGame` | Leaving the sessions page destroys the NetGame, which joins the threads removing the port mapping: the menus can stall until the router answers. | plausible; detach or hand the threads to the app | +| O6 | code quality | `Session::clientHandle` Welcome | A Welcome in the game phase (join in progress, off in the game) emits GameStarted without a countdown, so `NetGame` never starts the race for that joiner. | not reachable from the game | +| O7 | minor | the lobby's garage | A car picked in the garage without PREV is not the race's when the race starts there (only possible when the race starts before the joiner's ready-clear reaches the host). | as MM2 (LobbySwitch applies on PREV) | + +## For the other reviews + +* In-race sync: finding 10 removed one source of a remote car appearing at + the wrong place and then moving at a race's start. Snapshots and events + now also arrive while a race loads (finding 8); the buffers are cleared at + the countdown, so nothing older than the race reaches them. +* Network input: nothing further; see "Checked and correct" for the relay + budget. + +## Changes outside this area's files + +* `docs/frontend.md` (the script's `wait:race`), `docs/parity/frontend-ui.md` + (`scriptOnce` is now `processScript`). +* `RaceScreen.cpp` beyond its entry and exit paths, all small: the chat + posting (finding 9), the Cops and Robbers teams (finding 6) and + `crResult`, factored out of the Cops and Robbers ending for finding 11. From efa4c5518d3cc5bbc7f585384bd86ff7f1674792 Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Thu, 8 Oct 2026 22:57:17 -0700 Subject: [PATCH 36/54] Knock the host's traffic car a client's car hit on the client The host's view of a client's car lags the client's own by the interpolation delay and the trip, so a client that hits a moving traffic car as it shows it often misses it on the host, where the collision then never happens. The client now reports such a hit (a reliable game event: the car's id and generation and its own velocity before the step, at most once a second per car). If that car is still on its rail on the host and the client's car is near it there, the host knocks it off as the hit would have (aiVehicleInstance::AttachEntity) with the impulse of a car of the client's mass meeting it at that velocity (elasticity 0.25), and the result comes back with the next messages. A hit the host saw itself has already taken the car off its rail and the report is dropped. Also two automation aids: OPENMM2_DEBUG_NETTRAFFIC logs both sides' cars, hits and knocks for comparison, and OPENMM2_DEBUG_NET_SHOT_MS ends a network race at a session time, so every machine's --screenshot shows the same moment. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/app/App.cpp | 2 +- src/app/Context.h | 3 ++ src/app/RaceScreen.cpp | 100 +++++++++++++++++++++++++++++++++++-- src/game/TrafficBodies.cpp | 16 ++++++ src/game/TrafficBodies.h | 9 ++++ src/net/AmbientState.h | 24 +++++++++ 6 files changed, 150 insertions(+), 4 deletions(-) diff --git a/src/app/App.cpp b/src/app/App.cpp index 054c11d..45f6d1f 100644 --- a/src/app/App.cpp +++ b/src/app/App.cpp @@ -219,7 +219,7 @@ int run(const CommandLine& cl) { ctx.imgui->render(ImGui::GetDrawData()); dev.endOverlay(); - const bool lastFrame = cl.frames && frame + 1 >= *cl.frames; + const bool lastFrame = (cl.frames && frame + 1 >= *cl.frames) || ctx.lastFrameRequested; if (lastFrame && cl.screenshot) dev.requestCapture(); limiter.wait(); diff --git a/src/app/Context.h b/src/app/Context.h index 4b4994f..b700bb4 100644 --- a/src/app/Context.h +++ b/src/app/Context.h @@ -66,6 +66,9 @@ struct Context { // frame; set `quit` to leave the game. std::unique_ptr nextScreen; bool quit = false; + // Automation: this frame is the last (as --frames' last, with its + // --screenshot); set by OPENMM2_DEBUG_NET_SHOT_MS in a network race. + bool lastFrameRequested = false; // Writes settings (including [Display]) to disk. void saveSettings(); diff --git a/src/app/RaceScreen.cpp b/src/app/RaceScreen.cpp index 98df5bb..80631b0 100644 --- a/src/app/RaceScreen.cpp +++ b/src/app/RaceScreen.cpp @@ -252,6 +252,12 @@ class RaceScreen final : public Screen { updateNetPlayers(ctx); if (m_hud) m_hud->updateChat(static_cast(dt)); + // Development aid: OPENMM2_DEBUG_NET_SHOT_MS= ends a + // network race at that session time (with --screenshot: every + // machine's picture of the same moment). + if (const char* shot = std::getenv("OPENMM2_DEBUG_NET_SHOT_MS"); shot && multiplayer(ctx) && + ctx.netGame->raceStarted() && ctx.netGame->sessionTime() >= str::parseInt(shot).value_or(0)) + ctx.lastFrameRequested = true; if (multiplayer(ctx)) { ctx.netGame->update(); if (ctx.netGame->takeReturnToLobby() || !ctx.netGame->inSession()) { @@ -318,6 +324,8 @@ class RaceScreen final : public Screen { } updateRemoteCars(ctx); updateNetTraffic(ctx, static_cast(dt)); // OpenMM2: a client's shared traffic + if (netTrafficHost(ctx) && m_ai) + applyNetTrafficHits(ctx); // OpenMM2: the clients' hits on the host's traffic // aiVehicleManager::Update and the rail cars' rooms, before the // collision manager runs. if (m_trafficBodies) @@ -3422,6 +3430,46 @@ class RaceScreen final : public Screen { // Host: every 50 ms, each client its cars (the police chasing it, then // the nearest) with the light sets' steps. + // Host: the clients' reports of their cars hitting traffic cars. A car + // still on its rail here, near the client's car (as this machine shows + // it, allowing for the lag), leaves its rail with the impulse of a car of + // the client's mass at the reported velocity meeting it (elasticity 0.25: + // two lvlMaterial defaults of 0.5); anything else is dropped. + void applyNetTrafficHits(Context& ctx) { + for (const auto& ev : ctx.netGame->takeGameEvents()) { + if (static_cast(ev.type) != net::kTrafficHitEvent || !m_trafficBodies) + continue; + const auto hit = ev.as(); + const auto rv = m_remotes.find(ev.from); + if (!hit || rv == m_remotes.end() || !rv->second.sim) + continue; + const auto car = std::ranges::find_if(m_ai->cars(), [&](const ai::AmbientCar& c) { + return c.id == hit->id && static_cast(c.spawns) % net::kAmbientGenerations == hit->generation; + }); + if (car == m_ai->cars().end() || car->physical) + continue; + const phys::Body& other = rv->second.sim->sim().body; + const Vec3 rel = hit->velocity - car->velocity; + const float reach = std::min(25.0f, 8.0f + rel.mag() * 0.5f); + if (other.ics.matrix.m3.dist(car->transform.m3) > reach) + continue; + const float mc = other.ics.mass, mt = m_trafficBodies->massOf(car->id); + const float speed = rel.mag(); + if (mt <= 0.0f || mc <= 0.0f || speed < 0.5f) + continue; + const Vec3 n = rel * (1.0f / speed); + const float reduced = mc * mt / (mc + mt); + const Vec3 impulse = n * (reduced * speed * 1.25f); + // At the side facing the client's car, a metre up. + Vec3 toward = other.ics.matrix.m3 - car->transform.m3; + toward.y = 0.0f; + const float d = toward.mag(); + const Vec3 point = car->transform.m3 + Vec3{0.0f, 1.0f, 0.0f} + (d > 0.01f ? toward * (1.0f / d) : Vec3{}); + if (m_trafficBodies->knock(car->id, impulse, point) && m_debugNetTraffic) + log::info("nettraffic: host knocks traffic car {} for player {} ({:.1f} m/s)", car->id, ev.from, speed); + } + } + void sendNetTraffic(Context& ctx) { if (!m_trafficHost || !m_ai || !netTrafficHost(ctx)) return; @@ -3454,6 +3502,17 @@ class RaceScreen final : public Screen { cars.push_back(game::shareTrafficCar(c, model, paint, body ? &*body : nullptr, m_hornLatch.contains(c.id))); } m_hornLatch.clear(); + if (m_debugNetTraffic) { + std::unordered_set knocked; + for (const ai::AmbientCar& c : m_ai->cars()) + if (c.physical) { + knocked.insert(c.id); + if (!m_debugKnocked.contains(c.id)) + log::info("nettraffic: host traffic car {} knocked off its rail at session t {}", c.id, + ctx.netGame->sessionTime()); + } + m_debugKnocked = std::move(knocked); + } for (std::size_t i = 0; i < m_cops.size(); ++i) { const Cop& cop = m_cops[i]; const int model = m_trafficCatalog.find(cop.vehicle); @@ -3548,6 +3607,34 @@ class RaceScreen final : public Screen { return; for (const auto& m : ctx.netGame->takeAmbientStates()) m_trafficClient->receive(m); + // Game events are the host's hit reports' channel; a client has no + // use for any in cruise. + (void)ctx.netGame->takeGameEvents(); + // The traffic cars the local car hit in the last physics step: the + // host's view of this car lags behind, so it is told (at most once + // a second per car), with the car's velocity before that step. + if (m_trafficProxies) { + const std::uint32_t now = ctx.netGame->sessionTime(); + for (int id : m_trafficProxies->takeHits()) { + const auto it = std::ranges::find_if(m_netCars, [id](const ai::AmbientCar& c) { return c.id == id; }); + if (it == m_netCars.end()) + continue; + const auto [entry, fresh] = m_netHitReported.try_emplace(id, now); + if (!fresh && now - entry->second < 1000) + continue; + entry->second = now; + net::TrafficHitEvent hit; + hit.id = static_cast(id); + hit.generation = static_cast(it->spawns); + hit.velocity = m_netPreStepVelocity; + ctx.netGame->sendEvent(net::kTrafficHitEvent, net::encodePayload(hit), net::kHostPlayerId); + if (m_debugNetTraffic) + log::info("nettraffic: client hit traffic car {} at session t {}", id, now); + } + std::erase_if(m_netHitReported, [now](const auto& e) { return now - e.second > 5000; }); + } + if (m_player) + m_netPreStepVelocity = m_player->sim().body.ics.linearVelocity; if (m_trafficClient->catalogMismatch() && !m_trafficCatalogWarned) { log::warn("race: the host's traffic models differ from this game's data; some cars may look wrong"); m_trafficCatalogWarned = true; @@ -3599,9 +3686,13 @@ class RaceScreen final : public Screen { const Vec3 me = m_player ? m_player->sim().modelMatrix().m3 : m_camera.position(); for (const auto& c : m_trafficClient->cars()) if (c.transform.m3.dist(me) < 120.0f) - log::info("nettraffic: client t {:.0f} id {} gen {} at {:.2f} {:.2f} {:.2f}{}", renderTime, - c.id, c.generation, c.transform.m3.x, c.transform.m3.y, c.transform.m3.z, - c.extrapolated ? " (extrapolated)" : ""); + log::info("nettraffic: client t {:.0f} id {} gen {} at {:.2f} {:.2f} {:.2f} flags {:#x}{}{}", + renderTime, c.id, c.generation, c.transform.m3.x, c.transform.m3.y, + c.transform.m3.z, c.flags, c.extrapolated ? " (extrapolated)" : "", + c.kind == net::AmbientKind::Police ? std::format(" police target {}", c.target) + : std::string()); + log::info("nettraffic: client cops pursuing this player: {}", + audio::game::SirenPlayer::copsPursuingPlayer()); } } const std::uint64_t now = net::monotonicMs(); @@ -4519,6 +4610,8 @@ class RaceScreen final : public Screen { std::unique_ptr m_trafficProxies; // client: their physics std::unordered_set m_netAccidentNodes, m_netAccidentPaths; // client: off-rail cars' components std::unordered_map m_netAccidentRooms; + std::unordered_map m_netHitReported; // client: hits reported, by car (session ms) + Vec3 m_netPreStepVelocity; // client: the car's velocity before the step std::map m_netCops; // client: the host's police std::multimap m_spareNetCops; // client: police cars no longer shown, by model std::set m_badNetCopModels; // client: police models that failed to load @@ -4527,6 +4620,7 @@ class RaceScreen final : public Screen { // Development aid: OPENMM2_DEBUG_NETTRAFFIC logs the shared cars near // each client twice a second, on the host and on the client. bool m_debugNetTraffic = std::getenv("OPENMM2_DEBUG_NETTRAFFIC") != nullptr; + std::unordered_set m_debugKnocked; // Sound: the player's car, city ambience and rain (src/audio/game). std::unique_ptr m_bank; diff --git a/src/game/TrafficBodies.cpp b/src/game/TrafficBodies.cpp index 306c6b0..0fddde3 100644 --- a/src/game/TrafficBodies.cpp +++ b/src/game/TrafficBodies.cpp @@ -691,6 +691,22 @@ void TrafficBodies::instancesIn(int room, std::vector& out) con out.push_back(car); } +bool TrafficBodies::knock(int carId, const Vec3& impulse, const Vec3& point) { + const auto index = static_cast(carId); + RailCar* r = carId >= 0 && index < m_railCars.size() ? m_railCars[index].get() : nullptr; + if (!r || r->active || !r->data || !r->box || !r->collidable || !r->present || r->held) + return false; + phys::Body* body = r->attachEntity(); + m_world.addNewMover(body); + body->ics.applyImpulse(impulse, point); + return true; +} + +float TrafficBodies::massOf(int carId) const { + const RailCar* r = findRailCar(carId); + return r && r->data ? r->data->mass : 0.0f; +} + bool TrafficBodies::motionOf(int carId, Vec3& velocity, Vec3& spin) const { const RailCar* r = findRailCar(carId); if (!r || !r->active) diff --git a/src/game/TrafficBodies.h b/src/game/TrafficBodies.h index a5e4716..95350e2 100644 --- a/src/game/TrafficBodies.h +++ b/src/game/TrafficBodies.h @@ -98,6 +98,15 @@ class TrafficBodies final : public InstanceSource { // without one (OpenMM2: the shared traffic of a network cruise sends // them). bool motionOf(int carId, Vec3& velocity, Vec3& spin) const; + // OpenMM2 (the shared traffic of a network cruise): a network client's + // car hit rail car `carId` on the client. The car leaves its rail as a + // hit would make it (aiVehicleInstance::AttachEntity), joins the movers + // and takes `impulse` at `point` in the next step. False when the car + // is not on its rail as an instance (it has a body already, or the AI + // holds it). + bool knock(int carId, const Vec3& impulse, const Vec3& point); + // The mass of a car's aiVehicleData (0 when the car is unknown). + float massOf(int carId) const; // Cars with a body (at most 32). std::size_t activeCount() const { return static_cast(m_count); } diff --git a/src/net/AmbientState.h b/src/net/AmbientState.h index 53aee59..63bbd61 100644 --- a/src/net/AmbientState.h +++ b/src/net/AmbientState.h @@ -180,4 +180,28 @@ bool serialize(S& s, AmbientStateMsg& m) { // Bits one entity takes on the wire (for the host's packet budget). std::size_t ambientEntityBits(const AmbientEntity& e); +// Client -> host, a game event (reliable): the client's car hit a shared +// traffic car as the client was showing it. The host's view of the client's +// car lags its own by the interpolation delay and the trip, so the host may +// not see that collision itself; it knocks the car off its rail if it is +// still on it there and the client's car is near it (see +// docs/multiplayer.md). Sent at most once a second per car. +inline constexpr std::uint16_t kTrafficHitEvent = static_cast(GameEventType::Custom) + 16; +struct TrafficHitEvent { + std::uint16_t id = 0; + std::uint8_t generation = 0; + Vec3 velocity; // the client's car's just before the hit, m/s +}; + +template +bool serialize(S& s, TrafficHitEvent& e) { + std::int32_t id = e.id, generation = e.generation; + s.ranged(id, 0, static_cast(kMaxAmbientIds) - 1); + s.ranged(generation, 0, static_cast(kAmbientGenerations) - 1); + e.id = static_cast(id); + e.generation = static_cast(generation); + s.vec3Quantized(e.velocity, kAmbientVelocityRange, 12); + return s.ok(); +} + } // namespace mm2::net From d86626cf8d47817cabba759d589a7167d9b92934 Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Thu, 8 Oct 2026 23:00:54 -0700 Subject: [PATCH 37/54] Pass every vehicle state on, late or not Measured with a host and two clients, each behind a relay adding 75 ms +-30 ms per hop: - The host held a joiner's state for its own 20 Hz tick and sent only the newest one per player: of two that arrived within one tick the older was lost, a quarter of the client-to-client snapshots at 30 ms of jitter, and every one waited up to a tick longer. Joiners' states now go on to the others as soon as they arrive. - The state channel was unreliable but sequenced: ENet drops a packet that arrives after a newer one, which with packets reordered by the network cost 15-20% of the snapshots. A snapshot that arrives late still fills a hole in the receiver's time-ordered buffer, so the channel is now unsequenced; with reordering every snapshot arrived and no view extrapolated. - ENet's packet throttle drops unreliable packets at the sender after round-trip spikes (0.94 for seconds in these tests). The game's few small packets are not what congests a link; each side's peer now never throttles down. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/multiplayer.md | 10 ++++++---- src/net/Session.cpp | 43 ++++++++++++++++++----------------------- src/net/Session.h | 2 +- src/net/Transport.cpp | 15 +++++++++++++- tests/net/test_sync.cpp | 34 ++++++++++++++++++++++++++++++++ 5 files changed, 74 insertions(+), 30 deletions(-) diff --git a/docs/multiplayer.md b/docs/multiplayer.md index a898378..fcd242b 100644 --- a/docs/multiplayer.md +++ b/docs/multiplayer.md @@ -43,8 +43,10 @@ for the lobby: settings, the player list, ready flags, kicks and the countdown. Clients send requests and the host validates and broadcasts the result. In game, each machine simulates its own car and sends its state 20 times a -second, stamped with the session time the simulation was at. The host bundles -the latest state of every car into one packet per tick for each client. +second, stamped with the session time the simulation was at. The host sends +its own car on that cadence and passes each joiner's state on to the others +as soon as it arrives (bundling them per tick added up to a tick of latency +and dropped one of two states that arrived within a tick). Remote cars are drawn from an interpolation buffer a playout delay in the past: for each car, what its snapshots needed to arrive in time over the last 3 s (50-500 ms; about 70 ms on a LAN, 150 ms over a 100 ms round trip, 250 ms from @@ -170,7 +172,7 @@ Transport is ENet 1.3 with range-coder compression and three channels: | Channel | Delivery | Traffic | | --- | --- | --- | | 0 Control | reliable, ordered | handshake, lobby, chat, clock sync, pings | -| 1 State | unreliable, sequenced | `VehicleState` (client→host), `WorldState` (host→clients) | +| 1 State | unreliable, unsequenced (late snapshots still fill the buffer; ENet's throttle never drops them) | `VehicleState` (client→host), `WorldState` (host→clients) | | 2 Events | reliable, ordered | `GameEvent` | Every packet is one message: a `MsgType` byte, then the body, bit-packed with @@ -228,7 +230,7 @@ attempt within `connectTimeoutMs` (8 s). | Kick | H→C | reason text (then disconnect `Kicked`) | | TimeRequest / TimeResponse | C↔H | client send time / + host time | | VehicleState | C→H | own `VehicleSnapshot` | -| WorldState | H→C | `(id, VehicleSnapshot)` for every other car | +| WorldState | H→C | `(id, VehicleSnapshot)` pairs: the host's car each tick, a joiner's as it arrives | | GameEvent | both | from, target, type, session time, payload ≤ 1 KiB | | PlayerPings | H→C | measured RTT per player, every 2 s | diff --git a/src/net/Session.cpp b/src/net/Session.cpp index 6b131f6..050fa12 100644 --- a/src/net/Session.cpp +++ b/src/net/Session.cpp @@ -242,7 +242,6 @@ void Session::leave() { m_state = State::Closed; m_remotes.clear(); m_remoteStates.clear(); - m_pendingStates.clear(); } void Session::close(DisconnectReason reason, std::string message, bool failedJoin) { @@ -494,23 +493,24 @@ void Session::tickCountdown() { } void Session::hostSendWorldState() { - if (m_localState && m_localState->time != m_lastSentStateTime) { - m_lastSentStateTime = m_localState->time; - m_pendingStates[kHostPlayerId] = *m_localState; - } - if (m_pendingStates.empty()) + // The host's own car, on its snapshot cadence. + if (!m_localState || m_localState->time == m_lastSentStateTime) return; - for (const auto& [peer, r] : m_remotes) { - if (r.playerId == kInvalidPlayerId) - continue; - WorldStateMsg msg; - for (const auto& [id, state] : m_pendingStates) - if (id != r.playerId) - msg.vehicles.emplace_back(id, state); - if (!msg.vehicles.empty()) - sendTo(peer, Channel::State, std::move(msg)); - } - m_pendingStates.clear(); + m_lastSentStateTime = m_localState->time; + WorldStateMsg msg; + msg.vehicles.emplace_back(kHostPlayerId, *m_localState); + sendToPlayers(Channel::State, std::move(msg)); +} + +void Session::hostRelayState(std::uint8_t from, const VehicleSnapshot& state) { + // A joiner's car goes on to the others as soon as it arrives. Held for + // the host's next tick, it waited up to a tick longer, and of two that + // arrived within one tick (network jitter) only the newer went on: the + // other players lost a quarter of each other's snapshots at 30 ms of + // jitter. + WorldStateMsg msg; + msg.vehicles.emplace_back(from, state); + sendToPlayers(Channel::State, std::move(msg), from); } void Session::hostAdvertise() { @@ -681,11 +681,8 @@ void Session::hostHandle(Remote& r, MsgType type, std::span dat } case MsgType::VehicleState: { VehicleStateMsg msg; - if (!decodeMessage(data, msg) || !receiveState(id, msg.state)) - return; - // The newest state is relayed with the next WorldState. - if (const auto it = m_pendingStates.find(id); it == m_pendingStates.end() || it->second.time < msg.state.time) - m_pendingStates[id] = msg.state; + if (decodeMessage(data, msg) && receiveState(id, msg.state)) + hostRelayState(id, msg.state); return; } case MsgType::GameEvent: { @@ -757,7 +754,6 @@ void Session::hostRemovePlayer(std::uint8_t id, DisconnectReason reason) { PlayerInfo p = *it; m_players.erase(it); m_remoteStates.erase(id); - m_pendingStates.erase(id); log::info("net: {} left ({})", p.name, describe(reason)); sendToPlayers(Channel::Control, PlayerLeftMsg{id, reason}); emit(ev::PlayerLeft{p, reason}); @@ -1072,7 +1068,6 @@ void Session::returnToLobby() { for (auto& p : m_players) p.ready = false; m_remoteStates.clear(); - m_pendingStates.clear(); m_localState.reset(); m_lastSentStateTime = 0; sendToPlayers(Channel::Control, ReturnToLobbyMsg{}); diff --git a/src/net/Session.h b/src/net/Session.h index db980f4..4acdbbf 100644 --- a/src/net/Session.h +++ b/src/net/Session.h @@ -224,6 +224,7 @@ class Session { void hostRelayEvent(std::uint8_t from, GameEventMsg msg); void hostRelayUpdates(); void hostSendWorldState(); + void hostRelayState(std::uint8_t from, const VehicleSnapshot& state); void hostAdvertise(); void close(DisconnectReason reason, std::string message, bool failedJoin); void tickCountdown(); @@ -262,7 +263,6 @@ class Session { std::map m_remotes; std::uint64_t m_hostEpoch = 0; // monotonicMs() at session start std::uint64_t m_lastPingBroadcast = 0; - std::map m_pendingStates; // newest unsent state per player std::function m_eventFilter; std::unique_ptr m_beacon; // Wrong passwords per address: count and the time of the first one. diff --git a/src/net/Transport.cpp b/src/net/Transport.cpp index da0e782..9d93be4 100644 --- a/src/net/Transport.cpp +++ b/src/net/Transport.cpp @@ -27,7 +27,10 @@ std::uint32_t packetFlags(Channel channel) { switch (channel) { case Channel::Control: case Channel::Events: return ENET_PACKET_FLAG_RELIABLE; - case Channel::State: return 0; // unreliable, sequenced + // Unreliable and unsequenced: a snapshot that arrives after a newer one + // still fills a gap in the receiver's time-ordered buffer (ENet's + // sequenced delivery would drop it). + case Channel::State: return ENET_PACKET_FLAG_UNSEQUENCED; } return ENET_PACKET_FLAG_RELIABLE; } @@ -142,6 +145,16 @@ void Transport::service(std::vector& out, std::uint32_t timeoutM configurePeer(ev.peer); id = registerPeer(ev.peer); } + // ENet's packet throttle drops unreliable packets (the vehicle + // snapshots) at the sender after round trips spike above their + // recent average (it stood at 0.94 for seconds after spikes in + // tests with 30 ms of jitter), and a lost snapshot is a hole the + // receiver must extrapolate across. The game sends a few small + // packets and needs every one more than ENet's congestion guess: + // our side never throttles down (set on the peer here, after the + // handshake, which takes the connecting side's settings). + ev.peer->packetThrottleDeceleration = 0; + ev.peer->packetThrottle = ev.peer->packetThrottleLimit; te.type = TransportEvent::Type::Connected; te.peer = id; te.data = ev.data; diff --git a/tests/net/test_sync.cpp b/tests/net/test_sync.cpp index 7c71366..de10bfd 100644 --- a/tests/net/test_sync.cpp +++ b/tests/net/test_sync.cpp @@ -236,3 +236,37 @@ TEST(SessionSync, PlayoutDelayFollowsTheArrivals) { EXPECT_GE(host.session->playoutDelay(id), syncConfig().interpolationDelayMs); EXPECT_LT(host.session->playoutDelay(id), 200.0); } + +TEST(SessionSync, TheHostPassesEveryJoinersStateOnAtOnce) { + // The host's own tick is once a second here: a joiner's states must not + // wait for it (nor be thinned to one a tick) on their way to the others. + SessionConfig slow = syncConfig(); + slow.snapshotRateHz = 1; + Peer host, a, b; + host.session = std::make_unique(slow); + connect(host, a); + JoinParams j; + j.host = Address::loopback(host.session->port()); + j.player.name = "B"; + ASSERT_TRUE(b.session->join(j)); + ASSERT_TRUE(pumpUntil({&host, &a, &b}, [&] { return b.has(); })); + pumpFor({&host, &a, &b}, 400); + host.session->startCountdown(100); + ASSERT_TRUE(pumpUntil({&host, &a, &b}, [&] { return a.has() && b.has(); })); + const std::uint8_t aId = a.session->localId(); + std::vector seen; + b.session->setStateObserver([&](std::uint8_t id, const VehicleSnapshot& s, double) { + if (id == aId) + seen.push_back(s.time); + }); + const auto start = std::chrono::steady_clock::now(); + pumpUntil( + {&host, &a, &b}, + [&] { + a.session->submitLocalState(VehicleSnapshot{}); + return std::chrono::steady_clock::now() - start > std::chrono::milliseconds(400); + }, + 1000); + // 50 Hz for 0.4 s: about 20 states, not the one or two of a 1 Hz relay. + EXPECT_GE(seen.size(), 10u); +} From 98ae97e17413498058a9df956d0370d264bd77da Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Thu, 8 Oct 2026 23:01:13 -0700 Subject: [PATCH 38/54] Check a network cruise's police with the shared traffic on and off MM2's network games have neither racers nor police (mmGameMulti::Init); OpenMM2's shared cruise traffic, when the host has it on, brings the cruise's police posts back for the host to drive, and the races still have none. Co-Authored-By: Claude Opus 5.5 (1M context) --- tests/game/test_parity_game_flow.cpp | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/tests/game/test_parity_game_flow.cpp b/tests/game/test_parity_game_flow.cpp index abda676..f7f2b46 100644 --- a/tests/game/test_parity_game_flow.cpp +++ b/tests/game/test_parity_game_flow.cpp @@ -58,13 +58,15 @@ FlowRetail* flowRetail() { return r.get(); } -std::unique_ptr flowSession(GameMode mode, int index, bool multiplayer = false, int opponents = 0) { +std::unique_ptr flowSession(GameMode mode, int index, bool multiplayer = false, int opponents = 0, + bool netTraffic = false) { RaceConfig cfg; cfg.mode = mode; cfg.city = "london"; cfg.raceIndex = index; cfg.multiplayer = multiplayer; cfg.opponents = opponents; + cfg.netTraffic = netTraffic; // OpenMM2's shared cruise traffic (off: MM2's network game) SessionOptions options; options.seed = 11; std::string error; @@ -269,6 +271,14 @@ TEST(GameFlowParity, MultiplayerHasNoRacersOrPolice) { EXPECT_TRUE(s->opponents().empty()); EXPECT_TRUE(s->police().empty()); } + // OpenMM2 extra: with the host's shared traffic on, a network cruise has + // its police posts (the host drives them); the races still have none. + for (const GameMode mode : {GameMode::Checkpoint, GameMode::Circuit, GameMode::Cruise}) { + auto s = flowSession(mode, mode == GameMode::Cruise ? -1 : 0, true, 0, true); + ASSERT_TRUE(s); + EXPECT_TRUE(s->opponents().empty()); + EXPECT_EQ(s->police().empty(), mode != GameMode::Cruise); + } } // mmGameMulti::UpdateScore, mmMultiRace::GameMessage / SetTimeoutOn and From 86086aeed5b29f305d95a6fe887cd8e1a6587619 Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Thu, 8 Oct 2026 23:03:33 -0700 Subject: [PATCH 39/54] Describe the shared traffic of a network cruise docs/multiplayer.md: the Ambient channel, the AmbientState message and its encoding, interest management, the far cars' alternation, the complete-message rule, the client's hit reports, the untrusted-input checks, the lobby panel, the automation hooks, the measured bandwidth and accuracy, and the tests. docs/ai.md: MM2's player list and the traffic's 16 slots. docs/gamemodes.md: a network cruise's traffic with the option on and off. docs/parity/openmm2-only.md records the extra and its deviations from mmGameMulti::Init (the maintainer's decision of 2026-10-09: shared, host-authoritative, a lobby option on by default); the new files get their manifest rows. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/ai.md | 14 ++++- docs/gamemodes.md | 5 +- docs/multiplayer.md | 116 +++++++++++++++++++++++++++++++++--- docs/parity/README.md | 6 ++ docs/parity/openmm2-only.md | 24 ++++++++ 5 files changed, 155 insertions(+), 10 deletions(-) diff --git a/docs/ai.md b/docs/ai.md index a10e094..f458e35 100644 --- a/docs/ai.md +++ b/docs/ai.md @@ -346,7 +346,19 @@ MM2's pedestrians (build 3393, **MM2** unless marked): * Pedestrians are not collidable: cars drive through them (no ragdoll in MM2). They ignore each other and other cars. -**Deviations** (marked in the code): a single player; the ground and wall +**Players.** MM2's traffic is written for a list of players (aiMap +0x170, +`aiMap::AddPlayer` / `RemovePlayer`, at most four): each player's room +populates its roads with that player's bit in each road's mask +(`aiPath::AddAmbPlayer` / `RemAmbPlayer`), a road no player keeps is emptied, +and the goals walk the list for the first player in a car's way, keeping its +index for `aiGoalAvoidPlayer` (aiVehicleSpline +0xe6). A single-player game +has one; `ai::Traffic` keeps 16 slots for the shared traffic of a network +cruise (an OpenMM2 extra), whose other players `World::setOtherPlayers` +adds. The pedestrians stay the local player's (each machine its own, as in +MM2's network games). + +**Deviations** (marked in the code): a single player (but the traffic's +list, above); the ground and wall probes need the game's collision (`World::setProbe`), otherwise heights come from the sidewalk lines and there are no walls; the props come from the game (`Pedestrians::setObstacles`; inferred: the race's own props are placed diff --git a/docs/gamemodes.md b/docs/gamemodes.md index e60fc3f..2f0c2f4 100644 --- a/docs/gamemodes.md +++ b/docs/gamemodes.md @@ -274,7 +274,10 @@ player's target. When the host leaves the race everyone returns to the lobby (`BeDone(1)`, Quit2Lobby). A multiplayer race has no traffic, pedestrians, police or racers (the race modes load no AI map); multiplayer cruise and Cops and Robbers keep only the pedestrians -(`mmGameMulti::Init` zeroes the traffic, cop and opponent densities). +(`mmGameMulti::Init` zeroes the traffic, cop and opponent densities). As an +OpenMM2 extra, a multiplayer cruise with the host's SHARED TRAFFIC option (on +by default) has the host's traffic and police for everyone +(docs/multiplayer.md, "Shared traffic"); off, it is MM2's. **Winning** (`mmSingleCircuit::ProgressCheck`, `mmSingleRace::ProgressCheck`): places 1-3 for amateurs, 1st for professionals, hard-coded; MM2 does not diff --git a/docs/multiplayer.md b/docs/multiplayer.md index 366acc7..6eb0664 100644 --- a/docs/multiplayer.md +++ b/docs/multiplayer.md @@ -16,6 +16,7 @@ decides what they mean. | `net/Session.h` | Lobby + in-game session (host or client), event queue | | `net/Protocol.h` | Wire messages, settings/player structs, game event payloads | | `net/Snapshot.h` | `VehicleSnapshot` and the receive-side `SnapshotBuffer` | +| `net/AmbientState.h` | The shared cruise traffic: `AmbientStateMsg`, `TrafficHitEvent` | | `net/ClockSync.h` | Session clock estimation on clients | | `net/Discovery.h` | LAN beacon/scanner, broadcast addresses, small UDP socket | | `net/PortMapper.h` | Automatic port forwarding (UPnP, PCP, NAT-PMP) | @@ -165,13 +166,14 @@ Adverts whose protocol version differs are still listed, but flagged ## Wire protocol -Transport is ENet 1.3 with range-coder compression and three channels: +Transport is ENet 1.3 with range-coder compression and four channels: | Channel | Delivery | Traffic | | --- | --- | --- | | 0 Control | reliable, ordered | handshake, lobby, chat, clock sync, pings | | 1 State | unreliable, sequenced | `VehicleState` (client→host), `WorldState` (host→clients) | | 2 Events | reliable, ordered | `GameEvent` | +| 3 Ambient | unreliable, sequenced | `AmbientState` (host→clients): the shared cruise traffic | Every packet is one message: a `MsgType` byte, then the body, bit-packed with `BitStream`. Readers check every length, count and enum range. A malformed @@ -231,12 +233,15 @@ attempt within `connectTimeoutMs` (8 s). | WorldState | H→C | `(id, VehicleSnapshot)` for every other car | | GameEvent | both | from, target, type, session time, payload ≤ 1 KiB | | PlayerPings | H→C | measured RTT per player, every 2 s | +| AmbientState | H→C | the shared traffic and police near the client (see "Shared traffic") | `SessionSettings` holds the session name, city, mode (Cruise, Checkpoint, Circuit, Blitz, Cops & Robbers, Crash Course), race id, laps, time of day, weather, traffic and pedestrian density (percent), cops on/off, max players (protocol limit 16; the original allowed 8), a password flag, the -join-in-progress policy, and up to 32 extra key/value pairs. The extra pairs let +join-in-progress policy, whether a cruise shares the host's traffic and police +(`sharedTraffic`, on by default; protocol version 2), and up to 32 extra +key/value pairs. The extra pairs let the game add options without a protocol bump. ### Untrusted input @@ -273,6 +278,16 @@ packet to the game or discovery port, and whatever answers as the router. * **Floats.** Snapshot fields are quantized to fixed ranges and event floats must be finite (`ReadStream::f32`), so no NaN or infinity reaches physics or rendering. +* **Shared traffic.** An `AmbientState` holds at most 96 cars, ids 0-511, + generations 0-7, catalog indices 0-63 and paint jobs 0-15 (ranged fields: + nothing else can be read), quantized positions, velocities and spin, and a + police target that is a player id or none. A client takes it only during a + race, keeps at most 16 unread, and the race refuses a car whose model is not + in its own catalog, a repeated id, or a non-finite value, holds a paint job + to the jobs the model can show, shows at most 32 police cars and loads each + model once per car shown (a model that fails is not tried again). The host + takes a client's `TrafficHit` only for a car still on its rail near that + client's car, with its velocity quantized to 96 m/s. ### Session clock @@ -315,7 +330,69 @@ types, with payload structs in `Protocol.h`: `RaceFinished{raceTime, position}`, `GoldPickedUp/GoldDropped/GoldDelivered {position, team}`, `Collision{other, position, impulse}`, `Damage{damage, source}`, `Wrecked`. Ids from `GameEventType::Custom` (0x8000) -up are free for the game. +up are free for the game: Cops and Robbers uses 0x8001 and 0x8002, the shared +cruise traffic's hit report (`TrafficHitEvent`, client to host) 0x8010. + +### Shared traffic + +An OpenMM2 extra (MM2's network cruise has no traffic and no police, +`mmGameMulti::Init`; docs/parity/openmm2-only.md): in a multiplayer cruise +with the host's `sharedTraffic` on, the host runs the ambient traffic and the +police and sends each client, every 50 ms, an `AmbientState` with the cars near +it (`game::TrafficHost`, `src/game/net/TrafficSync.h`): + +| Field | Encoding | +| --- | --- | +| header | u32 session time, u32 the host's light-set steps (1/30 s) since its AI reset, u16 catalog checksum, 3 × i16 origin (whole metres at the client's car), count | +| id, generation | 9 + 3 bits: the traffic pool slot (0-299) or 400 + the police car's place; the generation changes when the slot is reused | +| has state | 1 bit; without it only the id and generation travel (13 bits) | +| kind, model, paint | 1 + 6 + 4 bits: traffic or police, the catalog index (the traffic's vehicle types in the AI map's order, then the police posts' cars: both machines build it from their data), the paint job | +| position | 15 + 14 + 15 bits: ±512 m across, ±256 m up from the origin (3 cm) | +| orientation | smallest-three quaternion, 32 bits | +| flags | brake, horn, indicators left / right (both: hazards), off its rail, wreck (police: out of action), siren, pursuit | +| motion | a car on its rail: its speed along its heading (11 bits, ±64 m/s; the AI's velocity is exactly that); off its rail and police: velocity 3 × 12 bits (±96 m/s) and spin 3 × 11 bits (±32 rad/s) | +| police | target player (5 bits, 16 = none), damage, rpm, throttle, gear (27 bits) | + +A car on its rail takes 119 bits, a police car 204. Each client gets the +police chasing it, then the cars within 200 m of its car (kept until 230 m, +so a car on the edge does not come and go), nearest first, as many as fit in +1100 bytes. Cars within 80 m, off their rails, police and cars new to the +client carry their state in every message; the others in every other one +(alternating by id), and say only "still there" in between. + +Each message is complete for its client: a car the client knows that a newer +message leaves out has gone (out of range, or back in the pool) from that +message's time; an older message (reordered) adds its states to the cars +still known but never brings back or replaces one; a new generation is a new +car, shown where it is rather than blended from its old place, and so is a +car that moved 25 m (plus 30 m/s of velocity change) further than its +velocity explains; a car not heard of for 1.5 s is dropped. The client +(`game::TrafficClient`) interpolates the cars with the remote players' +`SnapshotBuffer` (Hermite on the velocities, 100 ms behind, extrapolated at +most 250 ms) and runs its light sets to the host's steps at that time (they +are deterministic from a reset). + +A client's car meets the received cars as kinematic instances moving at their +interpolated velocities (`game::TrafficProxies` for the traffic, kinematic +bodies for the police). The host's view of the client's car lags the client's +own, so a client that hits a moving traffic car often misses it on the host; +it therefore reports the hit (`TrafficHitEvent`: the car's id and generation +and its own velocity before the step, reliable, at most once a second per +car). If the car is still on its rail on the host and the client's car is near +it there (8 m plus half a second of the relative speed, at most 25 m), the +host knocks it off as the hit would have, with the impulse of a car of the +client's mass meeting it at that velocity (elasticity 0.25); the next messages +bring the result back. A hit the host saw itself has already taken the car off +its rail, and the report is dropped. + +Bandwidth, two players in San Francisco at traffic density 0.5 (the +single-player cruise default) and cop density 1, measured by the host's +`nettraffic` log: 18.5 messages a second to the client, 63 cars a message, +11.0 KB/s (17.5 KB/s before the far cars' alternation). The 1100-byte budget +bounds it at about 20 KB/s per client, 140 KB/s for seven clients. The +client's cars within 120 m were 2 cm from the host's at the same session time +(median; 90 % within 10 cm, 27 cm at worst for far cars between their +messages). ### Disconnect reasons @@ -393,7 +470,12 @@ from the car, 0 for a police car (flag 0x08) and 1 for any other (`game::freeForAllTeam`, MM2's `mmMultiCR::InitMyPlayer`). The transmission is each driver's own (`NetCar::automatic`, the garage's TRANSMISSION) and never travels: MM2's session data has none, and `mmGame::Init` sets the car's -from the player's own state. +from the player's own state. `RaceConfig::netTraffic` travels as +`sharedTraffic`, its traffic density as `trafficDensity` and its cop density +in the extras (both held to 0-100 %); they matter only in a multiplayer +cruise. A new session starts a cruise with the option on and the +single-player cruise's densities (`frontend::applyNetTrafficDefaults`), the +other modes with neither. ### Menus @@ -404,7 +486,7 @@ from the player's own state. | Enter an address | `tcp_dlg` | IP, `ip:port` or host name; blank = search the LAN again; Enter is DONE (`Dialog_TCPIP`); it starts with the driver's last address, which is saved with the driver when a race starts (`Dialog_TCPIP::SetIPAddress`, `mmInterface::BeDone`) | | Password | `pass_dlg` | asked before joining a listed session that has one, and when a session joined by address wants one; a wrong password shows `badp_dlg` and asks again (`Dialog_Password`, `mmInterface::Update`) | | Lobby | `lobbh_bk` / `lobbj_bk` | `NetArena`: host settings (mode, race, weather, time, laps or gold weight, limit), the race map and the city's name, players (`mmCompRoster` rows: the `ready` icon, which the host always shows, the team dot `blue_dot` / `red_dot` in team games, the name cut to six characters and "..." when wider than 0.09 of the screen, the car), YOU, team lamps for team games, chat line ("Type message here. Press ENTER to send."), the last three chat lines (" Name> text") of this visit, port forwarding status (host, where a race without a map would show it); host: EJECT PLAYER, HOST SETTINGS, SELECT VEHICLE in the row above GO DRIVE (which starts once everyone else is ready); joiner: SELECT VEHICLE, READY. A joiner's READY is cleared when the host changes the settings and when the joiner opens SELECT VEHICLE. BACK and Escape leave the session at once. When the race starts, the lobby's car and the session's event become the driver's last car and event, as for a single-player race (`MultiStartGame` calls `BeDone`). | -| Host settings | `host_bk` | game type lamps, race name + laps panels (`host_rnm`, `host_lap`) or the Cops & Robbers panel (`host_cr`: game types, limits, gold mass), location, time of day, weather (Clear, Cloudy, Foggy, Raining: `RaceMenuBase::IncWeather` stops at Raining; a snowing session shows "Weather: Snowing" in the lobby), pedestrian density | +| Host settings | `host_bk` | game type lamps, race name + laps panels (`host_rnm`, `host_lap`) or the Cops & Robbers panel (`host_cr`: game types, limits, gold mass) or, in cruise, OpenMM2's SHARED TRAFFIC panel (on / off, traffic density and cop density sliders with the single-player cruise's defaults 0.5 and 1; drawn by OpenMM2 in the space cruise leaves empty), location, time of day, weather (Clear, Cloudy, Foggy, Raining: `RaceMenuBase::IncWeather` stops at Raining; a snowing session shows "Weather: Snowing" in the lobby), pedestrian density | | Eject | `ejct_dlg` | pick a player to remove | Positions of the provider/race/Cops & Robbers/team lamps, the HOST/JOIN and @@ -456,7 +538,15 @@ When `config.multiplayer && ctx.netGame`: Cops & Robbers `sendGold(GoldPickedUp|GoldDropped|GoldDelivered, position, team)`, `sendCollision`, `sendDamage`. Read others' with `takeGameEvents()` (`event.as()` etc.) to rank players and show messages. -6. **End:** the host calls `ctx.netGame->returnToLobby()` when the race is +6. **Shared traffic** (cruise with `sharedTraffic`): the host runs + `ai::World` with the other players (`World::setOtherPlayers`: the traffic + populates the roads round them and avoids them; the police chase them as + players), applies the clients' `TrafficHitEvent`s before the physics step + and after it sends each client its cars (`sendAmbientState`, every 50 ms). + A client runs no traffic or police: before its physics step it takes + `takeAmbientStates()` and places the received cars, and its light sets + follow the host's (`World::advanceLightsTo`). +7. **End:** the host calls `ctx.netGame->returnToLobby()` when the race is over (everyone finished, or the time/point limit); every machine sees `backToLobby()` and returns with `makeFrontendScreen(ctx, result)`. A player who quits early just returns to the frontend (it shows the lobby; @@ -479,8 +569,13 @@ off the lobby says which UDP port must be opened by hand. `mp:chat:`, `mp:ready`, `mp:start`, `mp:team:<0|1>`, `mp:mode:`, `mp:car:[:]` (what SELECT VEHICLE, a pick in the garage and -its PREV do), and the pages `hostoptions`, `address`, `hostsettings`, -`eject`. The script survives a race: after it the menus carry on with the +its PREV do), `mp:traffic:[::]` (the +shared cruise traffic), and the pages `hostoptions`, `address`, `hostsettings`, +`eject`. `OPENMM2_DEBUG_NETTRAFFIC` logs the shared cars near each client on +the host (every message) and on the client (twice a second), with the hits +and knocks, for comparison; `OPENMM2_DEBUG_NET_SHOT_MS=` ends a +network race at that session time, so each machine's `--screenshot` shows the +same moment. The script survives a race: after it the menus carry on with the commands after the one that started it, and `wait:race` waits until a race has been driven. Example: one process hosts (`profile:A;page:sessions;mp:host;wait:900`), another joins @@ -495,3 +590,8 @@ Lobby) and `profile:B;mp:join:127.0.0.1;wait:100;mp:ready;wait:race;wait:100;mp: discovery, password refusal, chat, car and ready changes, settings propagation, countdown, vehicle state replication, game events, return to lobby, host shutdown and eject. Port forwarding is disabled in these tests. +The shared traffic is tested by `tests/net/test_ambient_state.cpp` (the +message, malformed input, delivery per client), `tests/game/test_traffic_sync.cpp` +(interest, budget, spawn, update, despawn, loss, reordering, recycled slots, +hostile messages), `tests/ai/test_shared_traffic.cpp` (several players' +roads, avoidance, the light steps) and `tests/phys/test_kinematic_motion.cpp`. diff --git a/docs/parity/README.md b/docs/parity/README.md index 10a4f3a..dcd7791 100644 --- a/docs/parity/README.md +++ b/docs/parity/README.md @@ -410,6 +410,10 @@ audited in [openmm2-only.md](openmm2-only.md). | `src/game/fx/Weather.h` | 44 | P | rendering-fx | | `src/game/net/NetGame.cpp` | 659 | M | session | | `src/game/net/NetGame.h` | 229 | M | session | +| `src/game/net/TrafficProxies.cpp` | 101 | O | - | +| `src/game/net/TrafficProxies.h` | 60 | O | - | +| `src/game/net/TrafficSync.cpp` | 394 | O | - | +| `src/game/net/TrafficSync.h` | 237 | O | - | | `src/game/session/CopsAndRobbers.cpp` | 252 | P | session | | `src/game/session/CopsAndRobbers.h` | 130 | P | session | | `src/game/session/Gate.cpp` | 92 | P | session | @@ -427,6 +431,8 @@ audited in [openmm2-only.md](openmm2-only.md). | `src/game/world/Gizmos.h` | 289 | P | mm2-world-objects | | `src/game/world/PathSpline.cpp` | 146 | P | mm2-world-objects | | `src/game/world/PathSpline.h` | 72 | P | mm2-world-objects | +| `src/net/AmbientState.cpp` | 23 | O | - | +| `src/net/AmbientState.h` | 207 | O | - | | `src/net/BitStream.cpp` | 243 | O | - | | `src/net/BitStream.h` | 200 | O | - | | `src/net/ClockSync.cpp` | 21 | O | - | diff --git a/docs/parity/openmm2-only.md b/docs/parity/openmm2-only.md index 83b3666..1235a57 100644 --- a/docs/parity/openmm2-only.md +++ b/docs/parity/openmm2-only.md @@ -109,6 +109,28 @@ openmm2 10. | --- | --- | --- | --- | | `SessionSettings` defaults, `SnapshotBuffer` interpolation | DirectPlay transport, `mmNetObject` | openmm2 | The transport carries no rules: `game::NetGame::toSessionSettings` (session area) fills every setting from the race configuration, and remote cars' rules live in `game/net`. Notes for the session area: the protocol allows 16 players and `NetGame` clamps to 2..16 where MM2 allowed 8; traffic and pedestrian densities cross the wire as whole percent. | +## Shared traffic of a network cruise + +Added 2026-10-09 on the maintainer's decision (2026-10-09: the traffic and +police are shared, host-authoritative, a host option in the lobby, on by +default). MM2's network cruise has neither: `mmGameMulti::Init` zeroes the +traffic, cop and opponent densities and the cable cars, and each machine runs +its own pedestrians. OpenMM2 keeps that exactly with the option off. With it +on, the host runs the cruise's traffic and police for every player and the +clients show them (docs/multiplayer.md, "Shared traffic"). These rows are not +counted in the summary above. + +| OpenMM2 | MM2 | Verdict | Notes | +| --- | --- | --- | --- | +| `RaceScreen::loadAi`, `spawnPolice` in a network cruise | `mmGameMulti::Init` (no traffic, police, racers or rail cars) | deviation | The host keeps its traffic density and the cruise's police posts (`RaceSetup`: the posts of the cruise's `.aimap` at the cop density, as single-player cruise); a client runs neither. Cops and Robbers and the network races are unchanged; the cable cars stay off. | +| `ai::Traffic::step` with several players, `World::setOtherPlayers` | `aiMap::Update`, `aiMap::AddPlayer`, `aiMap::RemovePlayer`, `aiPath::AddAmbPlayer` / `RemAmbPlayer`, `aiGoalRandomDrive::Update`, `aiGoalRegainRail::Update`, `aiGoalAvoidPlayer` (+0xe6), the pose solver's player loop | deviation | MM2's own player list, which a single-player game fills with one player and its network games with only the local one. OpenMM2 lists the other players' cars on the host, in 16 slots where aiMap has four. A player gone from the list gives up its roads from the room they were populated for (`RemovePlayer` asks the room of its car's place; inferred equivalent). The pedestrians keep the local player only. The single-player step is unchanged (the opponent sweep's 516/517 and 508/517 hold). | +| `ai::PoliceCar` chasing the other players | `aiPoliceOfficer::DetectPerpetrator` (walks the player list), `aiMap::Player(0)` | deviation | The other players are players to the police (detected, chased, apprehended by MM2's rules); the rules that read player 0 (the reversing check) read the local player, and only the local car's hits mark what it hits. | +| `ai::World::advanceLightsTo` | `aiTrafficLightSet::Update` | openmm2 | A client's light sets run to the host's step count (they are deterministic from `aiMap::Reset`), so the received traffic stops at the lights it sees. | +| `game::TrafficHost`, `TrafficClient`, `TrafficCatalog`, `TrafficProxies`, `net::AmbientStateMsg`, `TrafficHitEvent` | | openmm2 | Replication: interest by distance with hysteresis, a packet budget, complete messages, generations for recycled slots, interpolation with the remote players' buffer, the client's car meeting the received cars as moving kinematic instances, and the client's hit reports. | +| `phys::Instance::kinematicMotion`, `Body::kinematicMoves` | | openmm2 | A kinematic instance (network car) that reports its motion strikes as a car moving at it (MM2 has no kinematic instances); off by default, on for the network cars of a shared-traffic cruise and the received traffic and police. | +| The host settings' SHARED TRAFFIC panel (`HostSettingsPage`) | `HostRaceMenu` (no traffic or cop settings) | deviation | On / off and the single-player cruise's traffic and cop density sliders (defaults 0.5 and 1, `RaceMenuBase::SetStateRace`), drawn by OpenMM2 in the upper right panel cruise leaves empty. | +| A client's pedestrians and the received cars | `aiPedestrian::UpcomingAccident` / `Accident` (`aiObstacle::InAccident` in the road's section lists) | inferred | The host's section lists do not travel: a received car off its rail counts for its whole road or intersection (`MapView::mapComponent` of its place). | + ## Files | File | Class | Reason | @@ -136,6 +158,8 @@ openmm2 10. | `src/core/Paths.h` | confirmed O | | | `src/core/StringUtil.cpp` | reclassified M | `parseDouble`, `parseInt` and the case folding stand in for MM2's atof, atoi and _stricmp in the data loaders (above). | | `src/core/StringUtil.h` | reclassified M | | +| `src/net/AmbientState.cpp` | O | The shared cruise traffic's message (added 2026-10-09). | +| `src/net/AmbientState.h` | O | | | `src/net/BitStream.cpp` | confirmed O | Wire encoding. | | `src/net/BitStream.h` | confirmed O | | | `src/net/ClockSync.cpp` | confirmed O | Host clock estimate. | From 54ac28adcde1754616b9ab819f66e30942843e3f Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Thu, 8 Oct 2026 23:04:34 -0700 Subject: [PATCH 40/54] Show a cruise's shared traffic option in the lobby The lobby's host settings box leaves its fourth line empty in cruise; it now says whether the host shares its traffic and police (OpenMM2 extra), so the joiners know before the race. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/app/frontend/PagesMulti.cpp | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/app/frontend/PagesMulti.cpp b/src/app/frontend/PagesMulti.cpp index ef416b1..8e0bb9c 100644 --- a/src/app/frontend/PagesMulti.cpp +++ b/src/app/frontend/PagesMulti.cpp @@ -933,6 +933,9 @@ class LobbyPage final : public Page { // (427) and "%d Points" (424), "%d minutes" (425) or None (426). if (c.mode == GameMode::Circuit) { lines[4] = std::format("{}: {}", s.get(418, "Laps"), c.laps); + } else if (c.mode == GameMode::Cruise) { + // OpenMM2 extra: the host's shared traffic and police (or none). + lines[4] = c.netTraffic ? "Traffic: Shared" : "Traffic: None"; } else if (c.mode == GameMode::CopsAndRobbers) { lines[4] = std::format("{}: {}", s.get(423, "Gold Weight"), s.get(420 + static_cast(std::clamp(goldMass, 0, 2)))); From db18f068292433b7c00b1999e45fd11db970d3e7 Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Thu, 8 Oct 2026 23:05:11 -0700 Subject: [PATCH 41/54] Wait for the racers still in a multiplayer race A race or circuit that waits for every finisher compared the results it had (the player's own and the others') with the players listed now. A player who finished and then left the session still counted, so with three players one could finish, leave, and end the race (and send everyone back to the lobby) while the third was still driving. MM2's host compares its finish count with the players in the session as each finish arrives (mmMultiRace::GameMessage 0x206). OpenMM2 now waits until every player still in the race has finished, or the finish timeout runs out. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/game/session/Session.cpp | 9 +++++++- tests/game/test_session.cpp | 43 ++++++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/src/game/session/Session.cpp b/src/game/session/Session.cpp index 4cc1801..5508166 100644 --- a/src/game/session/Session.cpp +++ b/src/game/session/Session.cpp @@ -1719,7 +1719,14 @@ void Session::updateRules(float dt, const PlayerState& player, std::span= m_netRacers.size() + 1 || m_netTimedOut; + // MM2's host compares the finishes it counted with the players in + // the session as each finish arrives (mmMultiRace::GameMessage + // 0x206); OpenMM2 asks whether every player still in the race has + // finished, so a player who finished and then left does not end the + // race of one still driving (deviation: MM2 counts the leaver's + // finish against the smaller session at the next finish). + const bool counted = + m_netTimedOut || std::ranges::all_of(m_netRacers, [](const NetRacer& r) { return r.finished; }); if (m_postWait <= 0.0f && (!netWaitsForAll() || counted)) { m_phase = Phase::Done; push(EventType::SessionOver); diff --git a/tests/game/test_session.cpp b/tests/game/test_session.cpp index b1667cc..72d2977 100644 --- a/tests/game/test_session.cpp +++ b/tests/game/test_session.cpp @@ -959,3 +959,46 @@ TEST(SessionHudMap, MapModelMatchesTheCity) { std::printf("water on the map: %.2f (mirrored x %.2f, mirrored z %.2f)\n", straight, fraction(-1, 1), fraction(1, -1)); } + +// mmMultiRace::GameMessage 0x206: the host compares the finishes it counted +// with the players in the session as each finish arrives. A player who +// finished and then left must not end the race of one still driving. +TEST(Session, MultiplayerRaceWaitsForThePlayersStillInIt) { + MM2_REQUIRE_GAME_DATA(); + ASSERT_TRUE(retail()); + RaceConfig cfg; + cfg.mode = GameMode::Checkpoint; + cfg.city = "london"; + cfg.raceIndex = 0; + cfg.multiplayer = true; + SessionOptions options; + options.seed = 7; + std::string error; + auto s = Session::create(cfg, retail()->london, *test::gameData(), retail()->strings, &error, options); + ASSERT_TRUE(s) << error; + s->setStartSignal(true); + Driver d(*s); + d.countdown(); + ASSERT_EQ(s->phase(), Phase::Racing); + Session::NetRacer leaver, driver; + leaver.name = "Leaver"; + driver.name = "Driver"; + driver.position = leaver.position = s->playerSpawn().m3; + s->setNetRacers({leaver, driver}); + d.tick(); + // The leaver finishes first, then leaves the session. + s->remoteFinished("Leaver", 60.0f); + s->setNetRacers({driver}); + for (std::size_t i = 1; i < s->checkpoints().size() && s->phase() == Phase::Racing; ++i) + d.driveTo(s->checkpoints()[i].position, 40.0f); + ASSERT_EQ(s->phase(), Phase::PostRace); + // Two results (the leaver's and the player's) for two racers left: the + // race still waits for the driver. + d.step(5.0f); + EXPECT_EQ(s->phase(), Phase::PostRace); + s->remoteFinished("Driver", 300.0f); + driver.finished = true; + s->setNetRacers({driver}); + d.step(1.0f); + EXPECT_EQ(s->phase(), Phase::Done); +} From aad848eff3cbec181c49d0ecbd19ea29888b5ae4 Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Thu, 8 Oct 2026 23:08:16 -0700 Subject: [PATCH 42/54] Test the shared traffic through two NetGames in one process A host and a client NetGame on loopback start a cruise; the host's cars (two traffic cars and a cop chasing the client), chosen by TrafficHost, go out on the Ambient channel and the client follows them with TrafficClient at the interpolation delay within 35 cm of where they were at that session time; the client's hit report reaches the host; nothing is sent in the lobby. Also keeps the shared traffic's new lines within 110 columns. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/ai/Traffic.cpp | 10 +- src/app/RaceScreen.cpp | 60 ++++++----- src/app/frontend/FrontendScreen.cpp | 7 +- src/app/frontend/PagesMulti.cpp | 7 +- src/game/net/TrafficSync.cpp | 13 ++- src/game/net/TrafficSync.h | 10 +- tests/game/test_traffic_net.cpp | 149 ++++++++++++++++++++++++++++ 7 files changed, 213 insertions(+), 43 deletions(-) create mode 100644 tests/game/test_traffic_net.cpp diff --git a/src/ai/Traffic.cpp b/src/ai/Traffic.cpp index 25e644b..1d1fff2 100644 --- a/src/ai/Traffic.cpp +++ b/src/ai/Traffic.cpp @@ -1681,10 +1681,9 @@ void Traffic::updateRandomDrive(int idx, float dt) { // behind the car ahead: the car avoids that player. for (std::size_t k = 0; k < m_players.size(); ++k) { const PlayerCar& player = m_players[k]; - if (player.valid && - Vec2{c.transform.m3.x - player.transform.m3.x, c.transform.m3.z - player.transform.m3.z}.mag2() < - sq(kPlayerZoneDistance) && - detectPlayerCollision(c, player) && !ambientBlockingPlayer(idx, player)) { + const Vec2 d{c.transform.m3.x - player.transform.m3.x, c.transform.m3.z - player.transform.m3.z}; + if (player.valid && d.mag2() < sq(kPlayerZoneDistance) && detectPlayerCollision(c, player) && + !ambientBlockingPlayer(idx, player)) { c.avoidSlot = m_playerSlots[k]; c.goal = AmbientGoal::AvoidPlayer; c.goalTicks = 0; @@ -1781,7 +1780,8 @@ void Traffic::fitOffRail(Car& c) { void Traffic::updateAvoidPlayer(int idx, float dt) { Car& c = m_cars[static_cast(idx)]; // aiMap::Player(+0xe6): the player it avoids. - const PlayerCar& p = m_slotCars[static_cast(std::clamp(c.avoidSlot, 0, kMaxTrafficPlayers - 1))]; + const auto slot = static_cast(std::clamp(c.avoidSlot, 0, kMaxTrafficPlayers - 1)); + const PlayerCar& p = m_slotCars[slot]; const PathInfo& info = m_net.paths()[static_cast(c.path)]; const float cap = info.speedLimit + c.exceedLimit; const float R = p.radius; diff --git a/src/app/RaceScreen.cpp b/src/app/RaceScreen.cpp index 29c84da..5525f0a 100644 --- a/src/app/RaceScreen.cpp +++ b/src/app/RaceScreen.cpp @@ -326,7 +326,7 @@ class RaceScreen final : public Screen { m_cableCars->update(static_cast(dt), m_player ? &player : nullptr, *m_world); } if (m_ai && !netTrafficClient(ctx)) - m_ai->updateLights(); // the light sets last (aiMap::Update); a shared-traffic client follows the host's + m_ai->updateLights(); // the light sets last (aiMap::Update); a shared-traffic client: the host's // The gizmo managers, nodes of mmGame (bridges, trains, ferries, // sailboats); the player's car is the bridges' proximity trigger. @@ -3409,7 +3409,8 @@ class RaceScreen final : public Screen { // collide with them, and their pedestrians stay their own. bool netTraffic(Context& ctx) const { - return multiplayer(ctx) && m_result.config.mode == game::GameMode::Cruise && m_result.config.netTraffic; + return multiplayer(ctx) && m_result.config.mode == game::GameMode::Cruise && + m_result.config.netTraffic; } bool netTrafficHost(Context& ctx) const { return netTraffic(ctx) && ctx.netGame->isHost(); } bool netTrafficClient(Context& ctx) const { return netTraffic(ctx) && !ctx.netGame->isHost(); } @@ -3439,8 +3440,9 @@ class RaceScreen final : public Screen { m_ai->pedestrians().setAccidentQuery( [this](int node, int path, int) { return netAccidentAt(node, path); }); } - log::info("race: shared traffic, {} ({} models, catalog {:04x})", ctx.netGame->isHost() ? "host" : "client", - m_trafficCatalog.size(), m_trafficCatalog.checksum()); + const char* role = ctx.netGame->isHost() ? "host" : "client"; + log::info("race: shared traffic, {} ({} models, catalog {:04x})", role, m_trafficCatalog.size(), + m_trafficCatalog.checksum()); } // The other players as the host's traffic and police see them: their @@ -3491,7 +3493,8 @@ class RaceScreen final : public Screen { if (!hit || rv == m_remotes.end() || !rv->second.sim) continue; const auto car = std::ranges::find_if(m_ai->cars(), [&](const ai::AmbientCar& c) { - return c.id == hit->id && static_cast(c.spawns) % net::kAmbientGenerations == hit->generation; + const auto generation = static_cast(c.spawns) % net::kAmbientGenerations; + return c.id == hit->id && generation == hit->generation; }); if (car == m_ai->cars().end() || car->physical) continue; @@ -3511,9 +3514,11 @@ class RaceScreen final : public Screen { Vec3 toward = other.ics.matrix.m3 - car->transform.m3; toward.y = 0.0f; const float d = toward.mag(); - const Vec3 point = car->transform.m3 + Vec3{0.0f, 1.0f, 0.0f} + (d > 0.01f ? toward * (1.0f / d) : Vec3{}); + const Vec3 side = d > 0.01f ? toward * (1.0f / d) : Vec3{}; + const Vec3 point = car->transform.m3 + Vec3{0.0f, 1.0f, 0.0f} + side; if (m_trafficBodies->knock(car->id, impulse, point) && m_debugNetTraffic) - log::info("nettraffic: host knocks traffic car {} for player {} ({:.1f} m/s)", car->id, ev.from, speed); + log::info("nettraffic: host knocks traffic car {} for player {} ({:.1f} m/s)", car->id, + ev.from, speed); } } @@ -3546,7 +3551,8 @@ class RaceScreen final : public Screen { body->transform = *m; m_trafficBodies->motionOf(c.id, body->velocity, body->angularVelocity); } - cars.push_back(game::shareTrafficCar(c, model, paint, body ? &*body : nullptr, m_hornLatch.contains(c.id))); + const bool horn = m_hornLatch.contains(c.id); + cars.push_back(game::shareTrafficCar(c, model, paint, body ? &*body : nullptr, horn)); } m_hornLatch.clear(); if (m_debugNetTraffic) { @@ -3555,8 +3561,8 @@ class RaceScreen final : public Screen { if (c.physical) { knocked.insert(c.id); if (!m_debugKnocked.contains(c.id)) - log::info("nettraffic: host traffic car {} knocked off its rail at session t {}", c.id, - ctx.netGame->sessionTime()); + log::info("nettraffic: host traffic car {} knocked off its rail at session t {}", + c.id, ctx.netGame->sessionTime()); } m_debugKnocked = std::move(knocked); } @@ -3612,8 +3618,8 @@ class RaceScreen final : public Screen { if (m_debugNetTraffic) // every message: the clients' logs are compared with these for (const auto& e : msg.entities) if (e.position.dist(rc.transform.m3) < 120.0f) - log::info("nettraffic: host t {} to {} id {} gen {} at {:.2f} {:.2f} {:.2f}", time, rc.id, e.id, - e.generation, e.position.x, e.position.y, e.position.z); + log::info("nettraffic: host t {} to {} id {} gen {} at {:.2f} {:.2f} {:.2f}", time, + rc.id, e.id, e.generation, e.position.x, e.position.y, e.position.z); } for (auto it = m_trafficSent.begin(); it != m_trafficSent.end();) { if (viewers.contains(it->first)) { @@ -3629,9 +3635,11 @@ class RaceScreen final : public Screen { if (now - m_trafficStatsAt >= 10000) { const double seconds = static_cast(now - m_trafficStatsAt) / 1000.0; for (auto& [id, st] : m_trafficSent) { + const double perMessage = + st.messages ? static_cast(st.cars) / static_cast(st.messages) : 0.0; log::info("nettraffic: to player {}: {:.1f} msg/s, {:.0f} B/s, {:.1f} cars a message", id, static_cast(st.messages) / seconds, static_cast(st.bytes) / seconds, - st.messages ? static_cast(st.cars) / static_cast(st.messages) : 0.0); + perMessage); st = {}; } m_trafficStatsAt = now; @@ -3663,7 +3671,8 @@ class RaceScreen final : public Screen { if (m_trafficProxies) { const std::uint32_t now = ctx.netGame->sessionTime(); for (int id : m_trafficProxies->takeHits()) { - const auto it = std::ranges::find_if(m_netCars, [id](const ai::AmbientCar& c) { return c.id == id; }); + const auto it = + std::ranges::find_if(m_netCars, [id](const ai::AmbientCar& c) { return c.id == id; }); if (it == m_netCars.end()) continue; const auto [entry, fresh] = m_netHitReported.try_emplace(id, now); @@ -3683,7 +3692,8 @@ class RaceScreen final : public Screen { if (m_player) m_netPreStepVelocity = m_player->sim().body.ics.linearVelocity; if (m_trafficClient->catalogMismatch() && !m_trafficCatalogWarned) { - log::warn("race: the host's traffic models differ from this game's data; some cars may look wrong"); + log::warn("race: the host's traffic models differ from this game's data; some cars may look " + "wrong"); m_trafficCatalogWarned = true; } const double renderTime = @@ -3733,11 +3743,13 @@ class RaceScreen final : public Screen { const Vec3 me = m_player ? m_player->sim().modelMatrix().m3 : m_camera.position(); for (const auto& c : m_trafficClient->cars()) if (c.transform.m3.dist(me) < 120.0f) - log::info("nettraffic: client t {:.0f} id {} gen {} at {:.2f} {:.2f} {:.2f} flags {:#x}{}{}", - renderTime, c.id, c.generation, c.transform.m3.x, c.transform.m3.y, - c.transform.m3.z, c.flags, c.extrapolated ? " (extrapolated)" : "", - c.kind == net::AmbientKind::Police ? std::format(" police target {}", c.target) - : std::string()); + log::info( + "nettraffic: client t {:.0f} id {} gen {} at {:.2f} {:.2f} {:.2f} flags {:#x}" + "{}{}", + renderTime, c.id, c.generation, c.transform.m3.x, c.transform.m3.y, c.transform.m3.z, + c.flags, c.extrapolated ? " (extrapolated)" : "", + c.kind == net::AmbientKind::Police ? std::format(" police target {}", c.target) + : std::string()); log::info("nettraffic: client cops pursuing this player: {}", audio::game::SirenPlayer::copsPursuingPlayer()); } @@ -3747,8 +3759,8 @@ class RaceScreen final : public Screen { m_trafficStatsAt = now; if (now - m_trafficStatsAt >= 10000) { const auto& st = m_trafficClient->stats(); - log::info("nettraffic: received {} messages ({} cars), {} refused, {} late, {} moved; showing {} cars, " - "{} police", + log::info("nettraffic: received {} messages ({} cars), {} refused, {} late, {} moved; showing {} " + "cars, {} police", st.messages, st.entities, st.refused, st.outdated, st.teleports, m_netCars.size(), m_netCops.size()); m_trafficStatsAt = now; @@ -3822,8 +3834,8 @@ class RaceScreen final : public Screen { sim.options.weatherFriction = weatherFriction(); sim.body.kinematic = true; sim.body.resetCollider(); - cop.renderer = std::make_unique(ctx.device(), *m_textures, *m_models, - cop.sim->model(), c.paint); + cop.renderer = std::make_unique( + ctx.device(), *m_textures, *m_models, cop.sim->model(), c.paint); setupVehicleRenderer(ctx, *cop.renderer); cop.paint = c.paint; cop.audio = loadAiCarAudio(ctx, *model, true); diff --git a/src/app/frontend/FrontendScreen.cpp b/src/app/frontend/FrontendScreen.cpp index 7cbb659..25c3d79 100644 --- a/src/app/frontend/FrontendScreen.cpp +++ b/src/app/frontend/FrontendScreen.cpp @@ -819,8 +819,11 @@ void Script::run(Frontend& fe, const std::string& cmd, const std::string& arg) { const auto parts = str::split(rest, ':'); c.netTraffic = parts.empty() || parts[0] != "off"; if (parts.size() >= 3) { - c.trafficDensity = std::clamp(static_cast(str::parseDouble(parts[1]).value_or(0.5)), 0.0f, 1.0f); - c.copDensity = std::clamp(static_cast(str::parseDouble(parts[2]).value_or(1.0)), 0.0f, 1.0f); + auto density = [&](std::size_t i, double fallback) { + return std::clamp(static_cast(str::parseDouble(parts[i]).value_or(fallback)), 0.0f, 1.0f); + }; + c.trafficDensity = density(1, 0.5); + c.copDensity = density(2, 1.0); } fe.ctx.netGame->setRaceConfig(c); } diff --git a/src/app/frontend/PagesMulti.cpp b/src/app/frontend/PagesMulti.cpp index 8e0bb9c..4e24a99 100644 --- a/src/app/frontend/PagesMulti.cpp +++ b/src/app/frontend/PagesMulti.cpp @@ -1132,8 +1132,8 @@ class HostSettingsPage final : public Page { [this](float v) { m_cfg.trafficDensity = v; }); // A cop density above 1 (none from this menu) shows as full. m_trafficSliders[1] = &menu.add( - Box{kTrafficValueX, kTrafficRowY[2], 139, 31}, [this] { return std::min(m_cfg.copDensity, 1.0f); }, - [this](float v) { m_cfg.copDensity = v; }); + Box{kTrafficValueX, kTrafficRowY[2], 139, 31}, + [this] { return std::min(m_cfg.copDensity, 1.0f); }, [this](float v) { m_cfg.copDensity = v; }); // 22-24: RACE LOCALE; 25-27: TIME (629-632); 28-30: WEATHER (625-628: // the menu offers no snow, RaceMenuBase::IncWeather stops at 3); all @@ -1253,7 +1253,8 @@ class HostSettingsPage final : public Page { const float ty = y + (heights[i] - lh) * 0.5f; f.text.draw(f.overlay, font, labels[i], 287, ty + 1, render::packColor(0, 0, 0)); f.text.draw(f.overlay, font, labels[i], 286, ty, render::packColor(255, 255, 255)); - f.overlay.rect(kTrafficValueX - 2, y - 2, widths[i] + 4, heights[i] + 4, render::packColor(70, 70, 170)); + f.overlay.rect(kTrafficValueX - 2, y - 2, widths[i] + 4, heights[i] + 4, + render::packColor(70, 70, 170)); f.overlay.rect(kTrafficValueX, y, widths[i], heights[i], render::packColor(8, 8, 40)); if (i > 0) f.overlay.rect(278, y - 13, 352, 2, render::packColor(20, 20, 60, 200)); diff --git a/src/game/net/TrafficSync.cpp b/src/game/net/TrafficSync.cpp index bd16072..e8f076e 100644 --- a/src/game/net/TrafficSync.cpp +++ b/src/game/net/TrafficSync.cpp @@ -133,7 +133,8 @@ net::AmbientStateMsg TrafficHost::build(const TrafficViewer& viewer, std::spanid) || - c.car->kind != net::AmbientKind::Traffic || (c.car->flags & net::kAmbientOffRail) != 0 || + c.car->kind != net::AmbientKind::Traffic || + (c.car->flags & net::kAmbientOffRail) != 0 || ((sequence + static_cast(c.car->id)) & 1u) == 0; if (!full) e.hasState = false; @@ -309,7 +310,8 @@ void TrafficClient::update(double renderTime) { } } -TrafficCatalog buildTrafficCatalog(std::span types, std::span police) { +TrafficCatalog buildTrafficCatalog(std::span types, + std::span police) { TrafficCatalog c; for (const ai::VehicleData& t : types) c.add(t.model); @@ -318,7 +320,8 @@ TrafficCatalog buildTrafficCatalog(std::span types, std:: return c; } -SharedCar shareTrafficCar(const ai::AmbientCar& car, int model, int paint, const TrafficBodyState* body, bool horn) { +SharedCar shareTrafficCar(const ai::AmbientCar& car, int model, int paint, const TrafficBodyState* body, + bool horn) { SharedCar s; s.id = car.id; s.kind = net::AmbientKind::Traffic; @@ -348,8 +351,8 @@ SharedCar shareTrafficCar(const ai::AmbientCar& car, int model, int paint, const return s; } -ai::AmbientCar ambientCarOf(const TrafficClient::Car& car, const std::string& model, const ai::VehicleData* data, - int paintJobs, float tireRotation) { +ai::AmbientCar ambientCarOf(const TrafficClient::Car& car, const std::string& model, + const ai::VehicleData* data, int paintJobs, float tireRotation) { ai::AmbientCar a; a.id = car.id; a.data = data; diff --git a/src/game/net/TrafficSync.h b/src/game/net/TrafficSync.h index b33a1ee..a1b7bf4 100644 --- a/src/game/net/TrafficSync.h +++ b/src/game/net/TrafficSync.h @@ -214,7 +214,8 @@ class TrafficClient { // The catalog both machines build: the ambient traffic's vehicle types in // the AI map's order (ai::Traffic::types()), then the police posts' cars. -TrafficCatalog buildTrafficCatalog(std::span types, std::span police); +TrafficCatalog buildTrafficCatalog(std::span types, + std::span police); // Host: an ambient car as shared. `model` and `paint` are its catalog index // and paint job; `body` is its physics body's pose and motion while it has @@ -225,13 +226,14 @@ struct TrafficBodyState { Vec3 velocity; Vec3 angularVelocity; }; -SharedCar shareTrafficCar(const ai::AmbientCar& car, int model, int paint, const TrafficBodyState* body, bool horn); +SharedCar shareTrafficCar(const ai::AmbientCar& car, int model, int paint, const TrafficBodyState* body, + bool horn); // Client: a received traffic car as the renderer, the audio and the // physics take an ambient car. `data` is the client's vehicle data of its // model (null when it has none), `paintJobs` the model's paint jobs and // `tireRotation` the wheels' turn the client keeps for it. -ai::AmbientCar ambientCarOf(const TrafficClient::Car& car, const std::string& model, const ai::VehicleData* data, - int paintJobs, float tireRotation); +ai::AmbientCar ambientCarOf(const TrafficClient::Car& car, const std::string& model, + const ai::VehicleData* data, int paintJobs, float tireRotation); } // namespace mm2::game diff --git a/tests/game/test_traffic_net.cpp b/tests/game/test_traffic_net.cpp new file mode 100644 index 0000000..86f5986 --- /dev/null +++ b/tests/game/test_traffic_net.cpp @@ -0,0 +1,149 @@ +// The shared cruise traffic over a real session in one process: a host and a +// client NetGame on loopback, the host's cars chosen for the client +// (game::TrafficHost), sent on the Ambient channel and followed by the client +// (game::TrafficClient) at the interpolation delay; then the client's hit +// report back to the host. +#include "game/net/NetGame.h" +#include "game/net/TrafficSync.h" + +#include + +#include +#include +#include +#include +#include + +using namespace mm2; +using game::NetGame; +using game::SharedCar; +using game::TrafficClient; +using game::TrafficHost; + +namespace { + +std::uint16_t trafficPort() { + static const std::uint16_t port = static_cast( + 51000 + (std::chrono::steady_clock::now().time_since_epoch().count() / 1000) % 6000 * 2); + return port; +} + +bool pump(std::initializer_list games, const std::function& done, int timeoutMs = 4000) { + const auto end = std::chrono::steady_clock::now() + std::chrono::milliseconds(timeoutMs); + while (std::chrono::steady_clock::now() < end) { + for (NetGame* g : games) + g->update(); + if (done()) + return true; + std::this_thread::sleep_for(std::chrono::milliseconds(2)); + } + return false; +} + +SharedCar movingCar(int id, net::AmbientKind kind, Vec3 pos) { + SharedCar c; + c.id = id; + c.kind = kind; + c.model = kind == net::AmbientKind::Police ? 3 : 1; + c.transform = Mat34::identity(); + c.transform.m3 = pos; + c.speed = 10.0f; + c.velocity = {0, 0, -10}; // along -Z, the car's heading + if (kind == net::AmbientKind::Police) + c.flags = net::kAmbientSiren | net::kAmbientPursuit; + return c; +} + +} // namespace + +TEST(SharedTrafficNet, HostAndClientInOneProcess) { + game::NetOptions ho, co; + ho.playerName = "Host"; + co.playerName = "Client"; + ho.port = trafficPort(); + ho.portMapping = co.portMapping = false; + ho.discoveryPort = co.discoveryPort = static_cast(trafficPort() + 1); + NetGame host(ho), client(co); + game::RaceConfig cruise; + cruise.mode = game::GameMode::Cruise; + cruise.city = "sf"; + std::string err; + ASSERT_TRUE(host.host(cruise, {"Traffic", "", 4, false}, {"vpbug", 0, 0}, &err)) << err; + ASSERT_TRUE(client.join(std::format("127.0.0.1:{}", trafficPort()), "", {"vpbug", 1, 0}, &err)) << err; + ASSERT_TRUE(pump({&host, &client}, [&] { return client.phase() == NetGame::Phase::Lobby; })); + EXPECT_TRUE(host.sharedTraffic()); // on by default + EXPECT_TRUE(client.sharedTraffic()); + // In the lobby nothing is sent. + EXPECT_EQ(host.sendAmbientState(client.localId(), net::AmbientStateMsg{}), 0u); + host.startRace(200); + ASSERT_TRUE(pump({&host, &client}, [&] { return host.raceStarted() && client.raceStarted(); })); + + // Two cars along -Z at 10 m/s and a cop chasing the client. + TrafficHost th; + TrafficClient tc(8, 0x1234); + const std::uint32_t t0 = host.sessionTime(); + auto zAt = [&](double t) { return static_cast(-10.0 * (t - t0) / 1000.0); }; + float worst = 0.0f; + int compared = 0; + std::uint32_t lastSent = 0; + const auto end = std::chrono::steady_clock::now() + std::chrono::milliseconds(1500); + while (std::chrono::steady_clock::now() < end) { + const std::uint32_t t = host.sessionTime(); + if (t - lastSent >= 50) { + lastSent = t; + SharedCar cop = movingCar(400, net::AmbientKind::Police, {0, 0, zAt(t) + 15.0f}); + cop.target = client.localId(); + const std::vector cars = { + movingCar(3, net::AmbientKind::Traffic, {5, 0, zAt(t)}), + movingCar(4, net::AmbientKind::Traffic, {-5, 0, zAt(t) - 20.0f}), cop}; + const auto msg = th.build({client.localId(), {0, 0, zAt(t)}}, cars, t, t / 33, 0x1234); + EXPECT_GT(host.sendAmbientState(client.localId(), msg), 0u); + } + host.update(); + client.update(); + for (const auto& m : client.takeAmbientStates()) + tc.receive(m); + const double render = static_cast(client.sessionTime()) - tc.options().interpolationDelayMs; + tc.update(render); + for (const auto& c : tc.cars()) { + const float expected = zAt(render) + (c.id == 4 ? -20.0f : c.id == 400 ? 15.0f : 0.0f); + worst = std::max(worst, std::abs(c.transform.m3.z - expected)); + ++compared; + if (c.id == 400) { + EXPECT_EQ(c.target, client.localId()); + EXPECT_TRUE(c.flags & net::kAmbientSiren); + } + } + std::this_thread::sleep_for(std::chrono::milliseconds(5)); + } + EXPECT_GT(compared, 100); + // The client's estimate of the host's clock and the milliseconds the + // times are kept in: a few centimetres at 10 m/s. + EXPECT_LT(worst, 0.35f); + EXPECT_EQ(tc.known(), 3u); + EXPECT_EQ(tc.stats().refused, 0u); + + // The client's hit report reaches the host. + net::TrafficHitEvent hit; + hit.id = 3; + hit.velocity = {0, 0, -20}; + client.sendEvent(net::kTrafficHitEvent, net::encodePayload(hit), net::kHostPlayerId); + std::vector got; + ASSERT_TRUE(pump({&host, &client}, [&] { + for (auto& e : host.takeGameEvents()) + got.push_back(std::move(e)); + return !got.empty(); + })); + ASSERT_EQ(static_cast(got[0].type), net::kTrafficHitEvent); + const auto back = got[0].as(); + ASSERT_TRUE(back); + EXPECT_EQ(back->id, 3); + EXPECT_NEAR(back->velocity.z, -20.0f, 0.05f); + EXPECT_EQ(got[0].from, client.localId()); + + // Back in the lobby nothing more is sent. + const std::uint32_t race = client.raceNumber(); + host.returnToLobby(); + ASSERT_TRUE(pump({&host, &client}, [&] { return client.backToLobby(race); })); + EXPECT_EQ(host.sendAmbientState(client.localId(), net::AmbientStateMsg{}), 0u); +} From e328e17485b396a9c260773b80c0d2246634ce77 Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Thu, 8 Oct 2026 23:08:47 -0700 Subject: [PATCH 43/54] Take a quitting joiner's car out of the race A joiner who quits a running race goes back to the lobby and stays in the session. The others went on drawing its car frozen where it stopped (a solid obstacle, held after 250 ms of extrapolation), and a race or circuit waited for its finish until the 60 or 120 s finish timeout ran out. In MM2 the player left the session (mmGameMulti::QuitNetwork), and the others took its car out and stopped counting it (SystemMessage 0x2d, mmNetObject::Deactivate). The quitter now tells the others (GameEventType::LeftRace): they take its car out of the race (drawing, collisions, HUD, Cops and Robbers, which also drops gold it carried), show "has left the game", and leave it out of the racers they wait for. The race screen takes the frame's game events once and hands them to the race and the Cops and Robbers rules. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/multiplayer.md | 2 +- src/app/RaceScreen.cpp | 34 +++++++++++++++++++++++++++++----- src/game/net/NetGame.cpp | 2 ++ src/game/net/NetGame.h | 3 +++ src/net/Protocol.h | 1 + 5 files changed, 36 insertions(+), 6 deletions(-) diff --git a/docs/multiplayer.md b/docs/multiplayer.md index cbf36ac..1310306 100644 --- a/docs/multiplayer.md +++ b/docs/multiplayer.md @@ -349,7 +349,7 @@ types, with payload structs in `Protocol.h`: `CheckpointReached{index, raceTime}`, `LapCompleted{lap, lapTime}`, `RaceFinished{raceTime, position}`, `GoldPickedUp/GoldDropped/GoldDelivered {position, team}`, `Collision{other, position, impulse}`, -`Damage{damage, source}`, `Wrecked`. Ids from `GameEventType::Custom` (0x8000) +`Damage{damage, source}`, `Wrecked`, `LeftRace` (a joiner quit the race it was driving and stays in the session: the others take its car out and stop waiting for its finish). Ids from `GameEventType::Custom` (0x8000) up are free for the game. ### Disconnect reasons diff --git a/src/app/RaceScreen.cpp b/src/app/RaceScreen.cpp index b5b6b3b..ae8e6eb 100644 --- a/src/app/RaceScreen.cpp +++ b/src/app/RaceScreen.cpp @@ -253,6 +253,7 @@ class RaceScreen final : public Screen { leaveRace(ctx, netRaceResult(ctx)); return; } + takeNetEvents(ctx); } if (ctx.input.keyPressed(platform::Key::F2)) m_flyCamera = !m_flyCamera; @@ -2020,7 +2021,8 @@ class RaceScreen final : public Screen { return; std::map now; for (const auto& p : ctx.netGame->players()) - now[p.id] = p.name; + if (!m_netLeft.contains(p.id)) + now[p.id] = p.name; if (m_netPlayersKnown && m_session->phase() == game::session::Phase::Racing) { for (const auto& [id, name] : m_netPlayers) { if (now.contains(id)) @@ -2192,14 +2194,15 @@ class RaceScreen final : public Screen { { std::set now; for (const auto& p : ctx.netGame->players()) - now.insert(p.id); + if (!m_netLeft.contains(p.id)) + now.insert(p.id); for (const auto id : m_crPlayers) if (!now.contains(id)) sendCr(ctx, m_cr->playerLeft(id, host)); m_crPlayers = std::move(now); } // The others' messages (mmMultiCR::GameMessage). - for (const auto& ev : ctx.netGame->takeGameEvents()) { + for (const auto& ev : m_netEvents) { CopsAndRobbers::Message m; const auto type = static_cast(ev.type); if (type == kCrPickupRequest) { @@ -2496,9 +2499,26 @@ class RaceScreen final : public Screen { void quitToMenu(Context& ctx) { game::RaceResult r = m_session ? m_session->result() : m_result; r.ended = false; + // A joiner who quits leaves the others driving (the host's quit ends + // the race for everyone, leaveRace): they take its car out and stop + // waiting for its finish, as MM2's players did when one left the + // session (mmGameMulti::QuitNetwork, SystemMessage 0x2d). + if (multiplayer(ctx) && !ctx.netGame->isHost()) + ctx.netGame->sendLeftRace(); leaveRace(ctx, r); } + // This frame's game events from the others, for the race and Cops and + // Robbers rules; the players who quit the race are noted here. + void takeNetEvents(Context& ctx) { + m_netEvents = ctx.netGame->takeGameEvents(); + for (const auto& ev : m_netEvents) + if (ev.type == net::GameEventType::LeftRace && ev.from != ctx.netGame->localId()) { + log::info("race: player {} quit the race", ev.from); + m_netLeft.insert(ev.from); + } + } + // What a network race leaves with when the host ends it (or the session // ends): a race this machine has already finished or lost, or a Cops and // Robbers game whose limit it has announced, shows its results as at its @@ -2540,7 +2560,7 @@ class RaceScreen final : public Screen { if (!multiplayer(ctx) || !m_session || !(mode == game::GameMode::Blitz || mode == game::GameMode::Circuit || mode == game::GameMode::Checkpoint)) return; - for (const auto& ev : ctx.netGame->takeGameEvents()) { + for (const auto& ev : m_netEvents) { if (ev.type == net::GameEventType::CheckpointReached) { if (const auto e = ev.as()) m_netWaypoints[ev.from] = e->index; @@ -2556,7 +2576,7 @@ class RaceScreen final : public Screen { } std::vector racers; for (const auto& p : ctx.netGame->players()) { - if (p.id == ctx.netGame->localId()) + if (p.id == ctx.netGame->localId() || m_netLeft.contains(p.id)) continue; game::session::Session::NetRacer r; r.name = p.name; @@ -3384,6 +3404,8 @@ class RaceScreen final : public Screen { if (!multiplayer(ctx) || !m_world) return; m_netCars = ctx.netGame->remoteCars(static_cast(m_world->remainderAfter(dt)) * 1000.0); + // A player who quit the race takes its car out of it. + std::erase_if(m_netCars, [this](const game::NetRemoteCar& c) { return m_netLeft.contains(c.id); }); const auto mode = m_result.config.mode; const bool towing = mode != game::GameMode::Cruise && mode != game::GameMode::CopsAndRobbers; std::vector present; @@ -4054,6 +4076,8 @@ class RaceScreen final : public Screen { }; std::map m_remotes; std::vector m_netCars; // this frame's sample (updateRemoteCars) + std::vector m_netEvents; // this frame's game events (takeNetEvents) + std::set m_netLeft; // players who quit this race std::map m_netWaypoints; // the other players' waypoints passed std::set m_netFinished; // the other players that finished (or did not) std::map m_netPlayers; // the players last frame (who left) diff --git a/src/game/net/NetGame.cpp b/src/game/net/NetGame.cpp index 3f04402..a7e8406 100644 --- a/src/game/net/NetGame.cpp +++ b/src/game/net/NetGame.cpp @@ -724,6 +724,8 @@ void NetGame::sendDamage(float damage, std::uint8_t source) { net::encodePayload(net::DamageEvent{damage, source})); } +void NetGame::sendLeftRace() { sendEvent(static_cast(net::GameEventType::LeftRace), {}); } + std::vector NetGame::takeGameEvents() { return std::exchange(m_gameEvents, {}); } // --- Helpers ----------------------------------------------------------------------------- diff --git a/src/game/net/NetGame.h b/src/game/net/NetGame.h index ee3caa6..8bd9a36 100644 --- a/src/game/net/NetGame.h +++ b/src/game/net/NetGame.h @@ -268,6 +268,9 @@ class NetGame { void sendGold(net::GameEventType type, const Vec3& position, int team); // GoldPickedUp/Dropped/Delivered void sendCollision(std::uint8_t otherPlayer, const Vec3& position, float impulse); void sendDamage(float damage, std::uint8_t source); + // The local player quits the race the others go on driving (MM2's + // player left the session: mmGameMulti::QuitNetwork). + void sendLeftRace(); void sendEvent(std::uint16_t type, std::vector payload, std::uint8_t target = net::kBroadcastTarget); std::vector takeGameEvents(); diff --git a/src/net/Protocol.h b/src/net/Protocol.h index 258805f..d527746 100644 --- a/src/net/Protocol.h +++ b/src/net/Protocol.h @@ -516,6 +516,7 @@ enum class GameEventType : std::uint16_t { Collision = 7, Damage = 8, Wrecked = 9, + LeftRace = 10, // the sender quit the race it was driving (it stays in the session); no payload Custom = 0x8000, // first id for game-specific events }; From f5ca06ebcea92fdd8668e41b29b7a1b72b098af5 Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Thu, 8 Oct 2026 23:10:59 -0700 Subject: [PATCH 44/54] Run Cops and Robbers on the shared race clock Each machine counted the time limit (and its warnings and lockouts) in frames from the moment its own race screen loaded, through the start countdown: the clocks disagreed by the difference in load times, so one machine showed "Time's up" and its results while the others played on. mmMultiCR::UpdateGame starts the clock when it enables the racers (state 2). OpenMM2's cars go at the shared start time, so the rules now run on the session clock from that time: with a host and a client the remaining time agreed to the millisecond. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/app/RaceScreen.cpp | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/app/RaceScreen.cpp b/src/app/RaceScreen.cpp index ae8e6eb..6d07ac5 100644 --- a/src/app/RaceScreen.cpp +++ b/src/app/RaceScreen.cpp @@ -2188,7 +2188,16 @@ class RaceScreen final : public Screen { if (rc.hasState) cars.push_back({rc.id, m_cr->teamOf(rc.id), rc.transform.m3, (rc.flags & net::kVehicleWrecked) != 0, false}); - sendCr(ctx, m_cr->updateNetwork(dt, m_crSelf, host, cars, m_crImpacts)); + // mmMultiCR::UpdateGame state 2 starts the clock (mmTimer::Start) when + // it enables the racers. OpenMM2's cars go at the shared start time, + // and every machine runs its rules on the session clock from there, + // so the time limit runs out on all of them together (each counting + // frames from its own load did not agree by the loads' difference). + const double raceClock = + std::max(0.0, (ctx.netGame->frameTime() - static_cast(ctx.netGame->raceStartTime())) / 1000.0); + const float ruleDt = static_cast(std::max(0.0, raceClock - m_crClock)); + m_crClock = std::max(m_crClock, raceClock); + sendCr(ctx, m_cr->updateNetwork(ruleDt, m_crSelf, host, cars, m_crImpacts)); m_crImpacts.clear(); // mmMultiCR::SystemMessage 0x2d: a player left. { @@ -3990,6 +3999,7 @@ class RaceScreen final : public Screen { game::session::CrTeam m_crMyTeam = game::session::CrTeam::Robber; std::uint32_t m_crRng = 1; // the places' intersection draws float m_crEnd = -1.0f; // UpdateLimit's wait before the results + double m_crClock = 0.0; // seconds of the game since the shared start bool m_crFinished = false; bool m_crWaterHandled = false; // the water / fall handler fired this frame std::set m_crPlayers; // the players last frame (who left) From 8e24712ecdc8401e211f2a075437114c5bc63f0a Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Thu, 8 Oct 2026 23:11:15 -0700 Subject: [PATCH 45/54] Drop a stray blank line in the race loop Co-Authored-By: Claude Opus 5.5 (1M context) --- src/app/RaceScreen.cpp | 1 - 1 file changed, 1 deletion(-) diff --git a/src/app/RaceScreen.cpp b/src/app/RaceScreen.cpp index 5525f0a..5bdd74f 100644 --- a/src/app/RaceScreen.cpp +++ b/src/app/RaceScreen.cpp @@ -327,7 +327,6 @@ class RaceScreen final : public Screen { } if (m_ai && !netTrafficClient(ctx)) m_ai->updateLights(); // the light sets last (aiMap::Update); a shared-traffic client: the host's - // The gizmo managers, nodes of mmGame (bridges, trains, ferries, // sailboats); the player's car is the bridges' proximity trigger. if (m_gizmos) { From be7d2bfa7ec266eae2cfbcc00101c69e0d59669b Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Thu, 8 Oct 2026 23:11:39 -0700 Subject: [PATCH 46/54] Give the shared traffic's bandwidth with two clients Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/multiplayer.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/multiplayer.md b/docs/multiplayer.md index 6eb0664..aa3c9c3 100644 --- a/docs/multiplayer.md +++ b/docs/multiplayer.md @@ -388,8 +388,10 @@ its rail, and the report is dropped. Bandwidth, two players in San Francisco at traffic density 0.5 (the single-player cruise default) and cop density 1, measured by the host's `nettraffic` log: 18.5 messages a second to the client, 63 cars a message, -11.0 KB/s (17.5 KB/s before the far cars' alternation). The 1100-byte budget -bounds it at about 20 KB/s per client, 140 KB/s for seven clients. The +11.0 KB/s (17.5 KB/s before the far cars' alternation); with a second client +600 m away, 10.6 and 10.0 KB/s (60 and 54 cars), each client its own cars and +the roads round each populated. The 1100-byte budget bounds it at about +20 KB/s per client, 140 KB/s for seven clients. The client's cars within 120 m were 2 cm from the host's at the same session time (median; 90 % within 10 cm, 27 cm at worst for far cars between their messages). From ad4357ca32ac62d507a62c4dacc6afd0b88c6d6d Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Thu, 8 Oct 2026 23:13:56 -0700 Subject: [PATCH 47/54] Let the network players' cars be heard The other players' cars were silent. MM2 builds them as vehCars with their vehCarAudioContainer (engine, tyres, horn; a police car's siren) and mmNetObject::PositionUpdate drives it from each packet: the pedals, the horn and siren bits, the gear. Each network car now gets the AI cars' positioned car audio, fed from its kinematic vehCarSim (which turns its engine and gears from the snapshot's speed and pedals: 7000 rpm in first, shifting as the sender did) with the snapshot's throttle, brake, gear, horn, siren and wreck. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/app/RaceScreen.cpp | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/src/app/RaceScreen.cpp b/src/app/RaceScreen.cpp index 6d07ac5..dda6e02 100644 --- a/src/app/RaceScreen.cpp +++ b/src/app/RaceScreen.cpp @@ -1471,6 +1471,27 @@ class RaceScreen final : public Screen { if (c.audio) feed(*c.audio, c.sim->sim(), c.driver->siren(), c.driver->target() == 0, c.driver->driver().wrecked(), *c.impacts); + // The network players' cars (mmNetObject::PositionUpdate: the + // pedals, horn, siren and wreck from the packet; the engine follows + // the car's own vehCarSim, here the kinematic one placed from the + // snapshots). + for (const auto& rc : m_netCars) { + const auto it = m_remotes.find(rc.id); + if (!rc.hasState || it == m_remotes.end() || !it->second.audio || !it->second.sim) + continue; + audio::game::CarAudioInputs in = carAudioInputs(it->second.sim->sim()); + in.throttle = rc.controls.throttle; + in.brake = rc.controls.brake; + in.gear = rc.controls.gear; + in.speed = rc.velocity.mag(); + in.horn = (rc.flags & net::kVehicleHorn) != 0; + in.siren = (rc.flags & net::kVehicleSiren) != 0; + in.sirenPursuingPlayer = false; + in.wrecked = (rc.flags & net::kVehicleWrecked) != 0; + in.transform = rc.transform; + in.velocity = rc.velocity; + it->second.audio->update(in, dt, listener); + } } // The ambient cars' sounds (aiAmbientVehicleAudio, which aiVehicleSpline::Init @@ -3456,6 +3477,11 @@ class RaceScreen final : public Screen { } // mmNetObject::Init -> vehCar::Init -> vehSiren::vehSiren. game::VehicleRenderer::setLightGlowScales(0.2f, 0.6f); + // vehCar::Init gives the network car its vehCarAudioContainer + // (engine, tyres, horn; a police car's siren), which + // mmNetObject::PositionUpdate drives from the packets. + const auto* info = ctx.game->catalog.vehicle(vehicle); + rv.audio = loadAiCarAudio(ctx, vehicle, info && (info->flags & game::VehicleInfo::kFlagCop)); } if (!rv.sim) continue; @@ -3480,6 +3506,8 @@ class RaceScreen final : public Screen { if (std::find(present.begin(), present.end(), it->first) == present.end()) { if (it->second.sim) it->second.sim->removeFrom(*m_world); + if (it->second.audio) + it->second.audio->stop(); it = m_remotes.erase(it); } else { ++it; @@ -4082,6 +4110,7 @@ class RaceScreen final : public Screen { int color = -1; std::unique_ptr sim; // kinematic body (and its trailer) std::unique_ptr renderer, trailer; + std::unique_ptr audio; std::array spin{}; }; std::map m_remotes; From 0b4fe05deb507e5f5f0db3e52b83ae96ecd85b66 Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Thu, 8 Oct 2026 23:15:33 -0700 Subject: [PATCH 48/54] Draw the network cars' wheels from their simulation The other players' wheels were drawn at their rest positions, spun from the snapshot's speed and turned by a fixed half radian at full lock, so they hung in the air or sank into the road over every bump and kerb. vehCarModel::Draw takes a car's wheels from its vehCarSim. The kinematic network car's sim already runs its wheels each sample against the ground under the placed body (suspension, roll, steering from the snapshot's pedals); the drawing now uses those wheel matrices. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/app/RaceScreen.cpp | 30 ++++++++++++------------------ 1 file changed, 12 insertions(+), 18 deletions(-) diff --git a/src/app/RaceScreen.cpp b/src/app/RaceScreen.cpp index dda6e02..8679897 100644 --- a/src/app/RaceScreen.cpp +++ b/src/app/RaceScreen.cpp @@ -436,7 +436,7 @@ class RaceScreen final : public Screen { std::vector flares; const Mat44 viewProj = frame.view * frame.proj; game::VehicleRenderer::setLensFlareTarget(&viewProj, proj.aspect, &flares); - drawLevel(ctx, m_camera, frustum, playerBody, m_frameDt); + drawLevel(ctx, m_camera, frustum, playerBody); game::VehicleRenderer::setLensFlareTarget(nullptr, 1.0f, nullptr); if (!flares.empty()) { game::fx::drawLensFlares(dev, *m_textures, flares); @@ -541,17 +541,15 @@ class RaceScreen final : public Screen { // The level as lvlLevel::Draw draws it for one view: the city, traffic, // props, the cars and their effects (lvlLevel's callbacks) and the rain. - // `playerBody` false hides the player's car; `dt` advances the remote - // cars' wheels (0 for a second view of the same frame). - void drawLevel(Context& ctx, const game::Camera& camera, const game::Frustum& frustum, bool playerBody, - float dt) { + // `playerBody` false hides the player's car. + void drawLevel(Context& ctx, const game::Camera& camera, const game::Frustum& frustum, bool playerBody) { render::Device& dev = ctx.device(); m_cityRenderer->draw(camera, frustum, m_env, m_detail); m_roadDecals.draw(dev, *m_textures); if (m_ai && m_aiRenderer) m_aiRenderer->draw(*m_ai, camera, frustum, m_result.config.timeOfDay, carLights(), m_detail.objects, [this](int id) { return physicalTrafficCar(id); }); - drawRemoteCars(ctx, dt, camera); + drawRemoteCars(ctx, camera); const bool night = m_result.config.timeOfDay == game::TimeOfDay::Night; if (m_bangers) m_bangers->draw(dev, *m_models, *m_textures, m_cards, frustum, camera, @@ -675,7 +673,7 @@ class RaceScreen final : public Screen { dev.clear(clear); dev.setFrameConstants(frame); dev.setFrontFaceFlipped(true); - drawLevel(ctx, camera, game::Frustum(frame.view * frame.proj), false, 0.0f); + drawLevel(ctx, camera, game::Frustum(frame.view * frame.proj), false); dev.setFrontFaceFlipped(false); dev.setScissor(nullptr); dev.setViewport({0.0f, 0.0f, static_cast(extent.width), static_cast(extent.height)}); @@ -3515,7 +3513,7 @@ class RaceScreen final : public Screen { } } - void drawRemoteCars(Context& ctx, float dt, const game::Camera& camera) { + void drawRemoteCars(Context& ctx, const game::Camera& camera) { if (!multiplayer(ctx)) return; for (const auto& rc : m_netCars) { @@ -3523,16 +3521,13 @@ class RaceScreen final : public Screen { if (!rc.hasState || it == m_remotes.end() || !it->second.renderer || !it->second.sim) continue; RemoteVehicle& rv = it->second; - game::VehiclePose pose; + // vehCarModel::Draw takes the wheels from the car's vehCarSim: the + // kinematic one's wheels have run this frame's samples against + // the ground under the placed body (suspension, roll, steering + // from the snapshot's pedals), so they sit on the road instead of + // hanging at their rest positions. + game::VehiclePose pose = rv.sim->pose(); pose.body = rc.transform; - // Wheels roll with the forward speed (radius from the model). - const float forward = -rc.velocity.dot(rc.transform.m2); - for (const auto& w : rv.sim->model().wheels) { - const auto i = static_cast(std::clamp(w.index, 0, 5)); - rv.spin[i] -= forward / std::max(w.radius, 0.1f) * dt; - pose.wheelSpin[i] = rv.spin[i]; - pose.wheelSteer[i] = w.index < 2 ? -rc.controls.steering * 0.5f : 0.0f; - } pose.headlights = (rc.flags & net::kVehicleHeadlights) != 0; pose.brakeLights = (rc.flags & net::kVehicleBrakeLights) != 0; pose.reverseLights = rc.controls.gear < 0; @@ -4111,7 +4106,6 @@ class RaceScreen final : public Screen { std::unique_ptr sim; // kinematic body (and its trailer) std::unique_ptr renderer, trailer; std::unique_ptr audio; - std::array spin{}; }; std::map m_remotes; std::vector m_netCars; // this frame's sample (updateRemoteCars) From d7fb35b945a8eec58a5e5f2d6ecca8a256c9faf4 Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Thu, 8 Oct 2026 23:16:23 -0700 Subject: [PATCH 49/54] Keep at most 256 unread game events A cruise without shared traffic reads no game events, so whatever a peer sends (up to its relay budget of 30 a second) piled up for the whole session. The queue now keeps the newest 256; the modes that read events take them every frame and never come near it. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/game/net/NetGame.cpp | 4 ++++ src/game/net/NetGame.h | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/src/game/net/NetGame.cpp b/src/game/net/NetGame.cpp index 41093e6..99060fa 100644 --- a/src/game/net/NetGame.cpp +++ b/src/game/net/NetGame.cpp @@ -315,6 +315,10 @@ void NetGame::handleEvents() { m_raceStarted = false; addSystemLine("**Session returning to Lobby**"); // string 73 } else if constexpr (std::is_same_v) { + // A mode that reads no events (a cruise without shared + // traffic) never takes them: keep the newest only. + if (m_gameEvents.size() >= kMaxQueuedGameEvents) + m_gameEvents.erase(m_gameEvents.begin()); m_gameEvents.push_back( {ev.from, static_cast(ev.type), ev.time, std::move(ev.payload)}); } else { diff --git a/src/game/net/NetGame.h b/src/game/net/NetGame.h index 7a09793..1ed9030 100644 --- a/src/game/net/NetGame.h +++ b/src/game/net/NetGame.h @@ -131,6 +131,10 @@ RaceConfig fromSessionSettings(const net::SessionSettings& settings); // Quarter Ton, Half Ton (index 0..2). inline constexpr int kGoldMassChoices = 3; +// Game events kept until the race takes them; older ones are dropped first. +// The relay budget (30 a second per peer) bounds the rate, this the total. +inline constexpr std::size_t kMaxQueuedGameEvents = 256; + class NetGame { public: enum class Phase { From c2cfc5e70c8915a412a6ba4a5059fcdc6a353a2c Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Thu, 8 Oct 2026 23:19:07 -0700 Subject: [PATCH 50/54] Add a lossy UDP relay to netprobe netprobe relay --port N --delay MS --jitter MS --loss PERCENT [--reorder] forwards each player's datagrams to the host and back with a delay of delay +- jitter (uniform) each way, dropping the given share, and keeps them in order unless --reorder. Players join the relay's port: a host and its clients on one machine then see an Internet link, which is how the replication fixes were measured (the system's tc/netem needs root). Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/multiplayer.md | 12 +++-- tools/netprobe/main.cpp | 113 ++++++++++++++++++++++++++++++++++++++-- 2 files changed, 117 insertions(+), 8 deletions(-) diff --git a/docs/multiplayer.md b/docs/multiplayer.md index 1310306..484ac5e 100644 --- a/docs/multiplayer.md +++ b/docs/multiplayer.md @@ -396,8 +396,11 @@ if (session.phase() == net::SessionPhase::InGame) { with `-fsanitize=address,undefined` to run them under the sanitizers (ENet's range decoder shifts a byte into an `int`'s sign bit; suppress `shift-base:compress.c`). -* `netprobe info | host | join | scan | portmap` for manual testing on real - networks. `netprobe portmap --discover-only` is read-only: it finds the +* `netprobe info | host | join | scan | portmap | relay` for manual testing on + real networks. `netprobe relay --port N --delay MS --jitter MS --loss + PERCENT [--reorder]` is a UDP relay that gives each player's link a bad + Internet connection on one machine: players join the relay's port instead + of the host's. `netprobe portmap --discover-only` is read-only: it finds the gateway and the external address without creating a mapping. ## Game integration (`game::NetGame`) @@ -556,6 +559,5 @@ each: With one trace per machine, a remote car as drawn (`R`) can be compared with where the other machine's car really was at `sampleTime` (its `F` lines). -Latency, jitter and loss can be added between machines on one computer with a -UDP relay in front of the host (any proxy that delays datagrams; tc/netem -needs root). +Latency, jitter and loss can be added between machines on one computer with +`netprobe relay` in front of the host (tc/netem needs root). diff --git a/tools/netprobe/main.cpp b/tools/netprobe/main.cpp index cd2e44f..6f0494f 100644 --- a/tools/netprobe/main.cpp +++ b/tools/netprobe/main.cpp @@ -5,6 +5,8 @@ // netprobe join [--name S] [--password P] [--chat TEXT] [--seconds N] // netprobe scan [--port N] [--seconds N] [--passive] // netprobe portmap [--port N] [--seconds N] [--discover-only] [--no-upnp] [--no-natpmp] +// netprobe relay [--port N] [--delay MS] [--jitter MS] [--loss PERCENT] [--reorder] +// [--seed N] [--seconds N] // // --seconds 0 runs until Ctrl-C. Port mappings are removed on exit. #include "core/Log.h" @@ -18,6 +20,8 @@ #include #include #include +#include +#include #include using namespace mm2; @@ -43,7 +47,8 @@ struct Args { }; Args parseArgs(int argc, char** argv, int first) { - static const char* kValueOptions[] = {"--port", "--name", "--password", "--seconds", "--chat", "--car"}; + static const char* kValueOptions[] = {"--port", "--name", "--password", "--seconds", "--chat", + "--car", "--delay", "--jitter", "--loss", "--seed"}; Args a; for (int i = first; i < argc; ++i) { std::string arg = argv[i]; @@ -266,13 +271,113 @@ int cmdPortmap(const Args& a) { return mapper.status().state == PortMappingStatus::State::Failed ? 1 : 0; } +// A UDP relay that delays, jitters and drops datagrams between the players +// connecting to it and a host, to test the game over a bad link on one +// machine (development aid; nothing in the game uses it). Each direction of +// each player's link gets `delay` +- `jitter` ms (uniform) and loses `loss` +// percent; without --reorder a datagram never overtakes an earlier one. +int cmdRelay(const Args& a) { + if (a.positional.empty()) { + std::println(stderr, "usage: netprobe relay [--port N] [--delay MS] [--jitter MS] " + "[--loss PERCENT] [--reorder] [--seed N] [--seconds N]"); + return 2; + } + const auto target = Address::resolve(a.positional[0], kDefaultGamePort); + if (!target) { + std::println(stderr, "cannot resolve {}", a.positional[0]); + return 1; + } + const auto port = static_cast(a.getInt("--port", kDefaultGamePort + 10)); + const double delay = str::parseDouble(a.get("--delay")).value_or(50.0); + const double jitter = str::parseDouble(a.get("--jitter")).value_or(0.0); + const double loss = str::parseDouble(a.get("--loss")).value_or(0.0) / 100.0; + const bool reorder = a.flag("--reorder"); + std::string error; + auto listen = UdpSocket::open(Address::any(port), false, false, &error); + if (!listen) { + std::println(stderr, "{}", error); + return 1; + } + std::mt19937 rng(static_cast(a.getInt("--seed", 1))); + std::uniform_real_distribution unit(0.0, 1.0); + using Clock = std::chrono::steady_clock; + const auto start = Clock::now(); + auto nowMs = [&] { return std::chrono::duration(Clock::now() - start).count(); }; + + struct Link { + std::unique_ptr up; // to the host + Address player; + double lastDue[2] = {0.0, 0.0}; // per direction, to keep the order + }; + struct Datagram { + double due; + std::uint64_t seq; + bool toHost; + std::size_t link; + std::vector data; + bool operator>(const Datagram& o) const { return due != o.due ? due > o.due : seq > o.seq; } + }; + std::vector links; + std::priority_queue, std::greater<>> queue; + std::uint64_t seq = 0, relayed = 0, dropped = 0; + auto schedule = [&](std::size_t link, bool toHost, std::span data) { + if (unit(rng) < loss) { + ++dropped; + return; + } + double due = nowMs() + std::max(0.0, delay + (unit(rng) * 2.0 - 1.0) * jitter); + double& last = links[link].lastDue[toHost ? 0 : 1]; + if (!reorder) + due = std::max(due, last); + last = due; + queue.push({due, seq++, toHost, link, {data.begin(), data.end()}}); + }; + + std::println("relaying UDP {} -> {}: {} +- {} ms each way, {}% lost{}", port, target->toString(), delay, jitter, + loss * 100.0, reorder ? ", reordered" : ""); + std::array buffer{}; + while (keepRunning(start, a.getInt("--seconds", 0))) { + Address from; + for (int n; (n = listen->receiveFrom(from, buffer)) > 0;) { + auto it = std::ranges::find(links, from, &Link::player); + if (it == links.end()) { + auto up = UdpSocket::open(Address::any(0), false, false, &error); + if (!up) + continue; + links.push_back({std::move(up), from}); + it = links.end() - 1; + std::println("player {} via local port {}", from.toString(), it->up->localAddress().port); + } + schedule(static_cast(it - links.begin()), true, + std::span(buffer.data(), static_cast(n))); + } + for (std::size_t i = 0; i < links.size(); ++i) + for (int n; (n = links[i].up->receiveFrom(from, buffer)) > 0;) + schedule(i, false, std::span(buffer.data(), static_cast(n))); + while (!queue.empty() && queue.top().due <= nowMs()) { + const Datagram& d = queue.top(); + if (d.toHost) + links[d.link].up->sendTo(*target, d.data); + else + listen->sendTo(links[d.link].player, d.data); + ++relayed; + queue.pop(); + } + std::this_thread::sleep_for(std::chrono::microseconds(250)); + } + std::println("relayed {} datagrams, dropped {}", relayed, dropped); + return 0; +} + int usage() { - std::println(stderr, "usage: netprobe [options]\n" + std::println(stderr, "usage: netprobe [options]\n" " info\n" " host [--port N] [--name S] [--car S] [--password P] [--upnp] [--seconds N]\n" " join [--name S] [--car S] [--password P] [--chat TEXT] [--seconds N]\n" " scan [--port N] [--seconds N] [--passive]\n" - " portmap [--port N] [--seconds N] [--discover-only] [--no-upnp] [--no-natpmp]"); + " portmap [--port N] [--seconds N] [--discover-only] [--no-upnp] [--no-natpmp]\n" + " relay [--port N] [--delay MS] [--jitter MS] [--loss PERCENT] [--reorder]\n" + " [--seed N] [--seconds N]"); return 2; } @@ -300,5 +405,7 @@ int main(int argc, char** argv) { return cmdScan(args); if (cmd == "portmap") return cmdPortmap(args); + if (cmd == "relay") + return cmdRelay(args); return usage(); } From 9ecacd8097d74759d416a101babb01f6ae6b3aea Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Thu, 8 Oct 2026 23:28:47 -0700 Subject: [PATCH 51/54] Show shared traffic as far back as the host's link needs The client drew the shared traffic a fixed 100 ms behind the session clock. Its messages come over the host's link on the host's 50 ms cadence, as the host's car does: over a 150 ms round trip with jitter 27% of the logged traffic cars ran past their newest message and were extrapolated. The traffic is now shown at least as far back as the host car's playout delay (15%, the rest being the far cars whose state comes in every other message), on the frame's precise session time. The host's handling of the clients' traffic hit reports read the game events itself; since the race screen takes each frame's events once (for the race and Cops and Robbers rules), it now reads that frame's list, as a client's cruise no longer needs to throw them away. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/app/RaceScreen.cpp | 18 ++++++++++-------- src/game/net/NetGame.cpp | 4 ++++ src/game/net/NetGame.h | 2 ++ 3 files changed, 16 insertions(+), 8 deletions(-) diff --git a/src/app/RaceScreen.cpp b/src/app/RaceScreen.cpp index 39288d3..c5bae52 100644 --- a/src/app/RaceScreen.cpp +++ b/src/app/RaceScreen.cpp @@ -339,7 +339,7 @@ class RaceScreen final : public Screen { updateRemoteCars(ctx, static_cast(dt)); updateNetTraffic(ctx, static_cast(dt)); // OpenMM2: a client's shared traffic if (netTrafficHost(ctx) && m_ai) - applyNetTrafficHits(ctx); // OpenMM2: the clients' hits on the host's traffic + applyNetTrafficHits(); // OpenMM2: the clients' hits on the host's traffic // aiVehicleManager::Update and the rail cars' rooms, before the // collision manager runs. if (m_trafficBodies) @@ -3533,8 +3533,8 @@ class RaceScreen final : public Screen { // it, allowing for the lag), leaves its rail with the impulse of a car of // the client's mass at the reported velocity meeting it (elasticity 0.25: // two lvlMaterial defaults of 0.5); anything else is dropped. - void applyNetTrafficHits(Context& ctx) { - for (const auto& ev : ctx.netGame->takeGameEvents()) { + void applyNetTrafficHits() { + for (const auto& ev : m_netEvents) { if (static_cast(ev.type) != net::kTrafficHitEvent || !m_trafficBodies) continue; const auto hit = ev.as(); @@ -3711,9 +3711,6 @@ class RaceScreen final : public Screen { return; for (const auto& m : ctx.netGame->takeAmbientStates()) m_trafficClient->receive(m); - // Game events are the host's hit reports' channel; a client has no - // use for any in cruise. - (void)ctx.netGame->takeGameEvents(); // The traffic cars the local car hit in the last physics step: the // host's view of this car lags behind, so it is told (at most once // a second per car), with the car's velocity before that step. @@ -3745,8 +3742,13 @@ class RaceScreen final : public Screen { "wrong"); m_trafficCatalogWarned = true; } - const double renderTime = - static_cast(ctx.netGame->sessionTime()) - m_trafficClient->options().interpolationDelayMs; + // The traffic comes over the host's link on the host's cadence, as + // the host's car does: it is shown at least as far back as that car's + // snapshots need (SnapshotBuffer::requiredDelay), or it would run + // past its newest message and be extrapolated on a slow link. + const double delay = std::max(m_trafficClient->options().interpolationDelayMs, + ctx.netGame->playoutDelay(net::kHostPlayerId)); + const double renderTime = ctx.netGame->frameTime() - delay; m_trafficClient->update(renderTime); if (const auto steps = m_trafficClient->lightSteps(renderTime)) m_ai->advanceLightsTo(*steps); diff --git a/src/game/net/NetGame.cpp b/src/game/net/NetGame.cpp index a32562d..cfa0de8 100644 --- a/src/game/net/NetGame.cpp +++ b/src/game/net/NetGame.cpp @@ -678,6 +678,10 @@ std::vector NetGame::remoteCars(double stateAgeMs) const { return out; } +double NetGame::playoutDelay(std::uint8_t playerId) const { + return m_impl->session ? m_impl->session->playoutDelay(playerId) : 0.0; +} + void NetGame::traceFrame(const Mat34& transform, const Vec3& velocity, double stateAgeMs, const std::vector& cars) { std::FILE* f = m_impl->trace.get(); diff --git a/src/game/net/NetGame.h b/src/game/net/NetGame.h index e2c19fe..1372483 100644 --- a/src/game/net/NetGame.h +++ b/src/game/net/NetGame.h @@ -260,6 +260,8 @@ class NetGame { std::vector remoteCars(double stateAgeMs = 0.0) const; // The session time update() last saw (ms). double frameTime() const { return m_frameTime; } + // How far in the past a player's car is shown (ms; net::Session::playoutDelay). + double playoutDelay(std::uint8_t playerId) const; // Development aid: with OPENMM2_NET_TRACE= set, each session // writes every remote snapshot it takes in and, through this call from // the race once a frame, the local car and the remote cars as drawn (see From e1a238a3b4c24c782b80259df61ab9e3aa6cd0ab Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Thu, 8 Oct 2026 23:32:29 -0700 Subject: [PATCH 52/54] Record the multiplayer in-race synchronisation review The jitter the maintainer reported, its four causes in the network cars' timing (stamps, frame-rate sampling, the fixed delay, the stepping clock) and the network's share (sequenced drops, the per-tick relay, ENet's throttle), measured before and after with a host and two clients behind lossy relays; the other in-race findings, what was checked, and what is left open. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/review/multiplayer-sync.md | 218 ++++++++++++++++++++++++++++++++ 1 file changed, 218 insertions(+) create mode 100644 docs/review/multiplayer-sync.md diff --git a/docs/review/multiplayer-sync.md b/docs/review/multiplayer-sync.md new file mode 100644 index 0000000..5208fbe --- /dev/null +++ b/docs/review/multiplayer-sync.md @@ -0,0 +1,218 @@ +# Multiplayer review: in-race synchronisation + +Reviewed on 2026-10-08 from `integration` at 2734bea, merged with the network +input review (84fda68: line numbers below are at that commit), then with the +lobby review (77fc127) and the shared cruise traffic (d7fb35b). + +The maintainer's report for this area: "There is also an intermitten issue +where the cars slightly bounce", later clarified as "sometimes the vehicles +jitter": shaking or stuttering in place or along their path. + +## Root cause of the jitter + +It is the other players' cars, not the local one, and it had four causes, all +measured with a host and one or two clients on this machine (method below). +Each one shows on its own; together they explain why it is intermittent: +they depend on the speed, the frame rate, the link and who looks at whom. + +1. **Snapshot time stamps did not match their state** (finding 1). A car's + state is its last fixed 60 Hz simulation step, up to 16.7 ms older than the + frame, but it was stamped with the frame's time. 50 ms of stamps carried + 3 or 4 steps of motion (2.05 or 2.73 m at 41 m/s, measured), so even on + loopback the remote car surged back and forth along its path by up to + 0.35 m at 18 Hz (and 10 cm up and down on a slope): drawn-vs-true error + 20 cm rms, 55 cm at worst, on loopback. +2. **Remote cars moved at the frame rate, everything else in 60 Hz steps** + (finding 2). There is no render interpolation: the local car, the camera, + the AI racers and the police move in whole simulation steps. A remote car + sampled at the frame's own time shook against them by up to half a step: + 29 cm rms at 35 m/s at 144 fps; at 60 fps 4-10% of frames jumped by a whole + step (55 cm at 35 m/s) when a frame ran 0 or 2 steps. Visible as the car + next to yours vibrating or twitching, at any network quality. +3. **The fixed 100 ms interpolation delay was too short for the Internet** + (finding 3). Over a 100 ms round trip with ±15 ms jitter and 1% loss, a + client's view of the host extrapolated 14-20% of the time and the host's + view of a client 24-40%, snapping back at each snapshot: horizontal + errors in a sawtooth up to 40-56 cm, height errors up to 48 cm. A + client's view of another client (two hops and the host's relay tick, 127 ms + mean lateness) was extrapolated **100%** of the time, with errors up to + 3-5 m. That is the "sometimes" of the report: it depends on the link and on + whether you look at the host or at another client. +4. **The client's clock stepped** (finding 4) whenever the lowest-round-trip + sample in its window changed: every remote car jumped by its speed times + the step (4 ms steps measured on a clean link: 14 cm at 35 m/s). + +The network made it worse: ENet's sequenced unreliable channel dropped any +snapshot that arrived after a newer one (15-20% with reordering), the host +relayed only one state per player per tick (a quarter of client-to-client +snapshots lost at 30 ms of jitter), and ENet's packet throttle drops +unreliable packets after round-trip spikes (findings 5-7). + +The local car's own motion is not corrected by anyone (no host correction +exists), and its contacts with a remote car did not jitter, but they were +wrong (finding 8): a remote car was a still wall to the physics. + +Before and after, the same scenario (host and two clients, each client +behind a relay adding 50 ±15 ms each way and 1% loss, all three driving +flat out from a circuit start; "extrapolated" is the share of frames a car +was drawn past its newest snapshot; "error" is the drawn position against +the other machine's own trace at the same session time; "shake" the remote +car's motion against the viewer's own car from frame to frame): + +| View | Extrapolated before / after | Error rms before / after | Error 99th pct. before / after | Shake rms before / after | +| --- | --- | --- | --- | --- | +| host sees client 1 | 24.1% / 0.4% | 28.2 / 1.8 cm | 45 / 7 cm | 22.4 / 0.6 cm | +| client 1 sees host | 19.6% / 0.4% | 22.2 / 1.5 cm | 38 / 5 cm | 23.6 / 0.3 cm | +| client 1 sees client 2 | 100% / 0.4% | 33.7 / 1.8 cm | 100 / 8 cm | 23.3 / 0.4 cm | +| client 2 sees client 1 | 99.9% / 0.3% | 37.0 / 1.8 cm | 63 / 9 cm | 22.4 / 0.4 cm | + +Final build, harsher link (75 ±30 ms each way, 2% loss, reordering, through +`netprobe relay`): 0.1-0.8% extrapolated in all six views, 1.6-2.2 cm rms, +5-7 cm at the 99th percentile; the remaining large errors (20-50 cm) are the +moments a car hits a wall between two snapshots. + +## How it was tested + +* Host and clients on one machine, UDP port 2320, each with its own config + and data directories, `[Display] VSync=off`, `FrameCap=120..144` (with + vsync on, the compositor throttles a hidden window to a few frames a + second), `OPENMM2_DEBUG_INPUT` driving the cars, `OPENMM2_FRONTEND_SCRIPT` + hosting, joining and starting. +* `OPENMM2_NET_TRACE` (added, off by default, documented in + `docs/multiplayer.md`): every snapshot taken in with its stamp and arrival, + the local car once a frame with the time its state belongs to, and each + remote car as drawn with its sample time. Scripts compared each machine's + drawing of a car with the other machine's trace. +* Latency, jitter, loss and reordering: a UDP relay in front of the host for + each client, first a scratch script, then `netprobe relay` (added). The + scratch relay's own reordering (asyncio does not keep timers of equal time + in order) is what first showed ENet dropping late snapshots. +* The pre-fix build was rebuilt from 84fda68 with the same trace added, for + the before columns. +* Unit tests in `tests/net/test_sync.cpp`, `tests/game/test_netgame_sync.cpp`, + `tests/phys/test_kinematic.cpp` and `tests/game/test_session.cpp`. + +## What I read + +* `src/net/Snapshot.{h,cpp}`, `ClockSync.{h,cpp}`, `Transport.{h,cpp}`, + `BitStream` (quantisation, quaternions), `Protocol.h`, and the in-race parts + of `Session.{h,cpp}` (replication, clock, relay, events, players leaving), + completely; ENet 1.3.18's unreliable delivery and packet throttle + (`protocol.c`, `peer.c`). +* `src/game/net/NetGame.{h,cpp}`: replication, events, race start. +* `src/app/RaceScreen.cpp`: the frame loop's order, `sendLocalState`, + `updateRemoteCars`, `drawRemoteCars`, `hudBlips`, `updateNetPlayers`, + `updateNetRace`, `updateCopsAndRobbers`, `leaveRace`/`quitToMenu`, the AI + audio; later the shared traffic's client and host parts. +* `src/phys/World.cpp` (fixed step, kinematic bodies), `Collider`, `Impact` + (`calcImpact`), `CarSim` (what a kinematic car's controller does), + `src/game/session/Session.cpp` (multiplayer finishes and standings), + `CopsAndRobbers.cpp`. +* MM2: `mmNetObject` (`SetPositionData`, `PositionUpdate`, `Predict`, + `Update`), `mmGameMulti` (`SendPosition`, `Update`, `QuitNetwork`), + `mmMultiRace::SystemMessage` / `GameMessage`, `mmMultiCR::UpdateGame` / + `UpdateLimit`. + +## Findings + +Severity: crash / gameplay-breaking / visible / minor / code quality. + +### Fixed + +| # | Severity | Location (84fda68) | Scenario | Confirmed | Fix | +| --- | --- | --- | --- | --- | --- | +| 1 | visible | `NetGame.cpp:585`, `Session.cpp:996` | Snapshots stamped with the frame's time while carrying the last fixed step's state (up to 16.7 ms older): remote cars surged ±0.35 m along their path at speed, even on loopback. | trace: 50 ms stamps with 3 or 4 steps of motion; test `NetGameSync.StatesCarryTheSimulationTimeAndCarsAreSampledWithItsLag`, `SessionSync.StatesCarryTheTimeTheyWereSimulatedAt` | 3edfaf6: `submitLocalState(..., stateAgeMs)`, stamped at the frame's session time (`NetGame::frameTime`) less the step's remainder | +| 2 | visible | `RaceScreen.cpp:299`, `:489`, `:2148`, `:3330`, `:3402`; `NetGame.cpp:608` | Remote cars sampled at the frame's time (and again for each use, at slightly different times) while everything else moves in 60 Hz steps: 29 cm rms shake against the local car at 35 m/s, 144 fps; whole-step jumps on 4-10% of frames at 60 fps. | trace (cadence mismatch 8.2 ms rms before, 0.1 ms after); test `World.RemainderAfterIsWhatAdvanceFixedLeaves` | 3edfaf6: one sample a frame at the time the simulation reaches after the frame's steps (`World::remainderAfter`), used for the body, drawing, HUD and Cops and Robbers | +| 3 | visible | `Session.h:59`, `Session.cpp:1007` | Fixed 100 ms delay: 14-40% extrapolated at 100 ms round trip, 100% for client-to-client views, snapping back by up to 0.5 m (3-5 m between clients). | trace, before/after table; tests `PlayoutDelay.*`, `SessionSync.PlayoutDelayFollowsTheArrivals` | 3edfaf6: per-car playout delay from the snapshots' measured lateness plus gap over 3 s (`SnapshotBuffer::requiredDelay`), 50-500 ms, growing at 25% and shrinking at 2% of real time | +| 4 | visible | `ClockSync.cpp:12`, `Session.cpp:858` | The client's clock offset stepped when its best sample changed (and was rounded to whole ms): every remote car jumped by speed × step. | trace (4 ms steps); test `SlewedClock.StepsOutsideARaceAndSlewsInOne`, `ClockSync.KeepsSubMillisecondPrecision` | 3edfaf6: `SlewedClock` slews the shown offset at 5% during a race (steps in the lobby or above 250 ms); sub-millisecond session clock | +| 5 | visible | `Transport.cpp:30` | State channel unreliable *sequenced*: a snapshot arriving after a newer one was dropped, 15-20% of the snapshots with reordering, each a hole to extrapolate across. | relay with `--reorder`: 410-440 of 513 before, 510-514 after, 0% extrapolated | d86626c: unsequenced (the buffer orders by time) | +| 6 | visible | `Session.cpp:422`, `:610` | The host relayed a joiner's state at its own 20 Hz tick, newest only: of two arriving within a tick one was lost (25% of client-to-client snapshots at 30 ms jitter), and each waited up to 50 ms more. | gap counts in the trace; test `SessionSync.TheHostPassesEveryJoinersStateOnAtOnce` | d86626c: relayed as it arrives | +| 7 | minor | `Transport.cpp:103` | ENet's packet throttle drops unreliable packets at the sender after round-trip spikes (0.94 for seconds in jittery runs). | ENet's throttle logged | d86626c: each side's peer never throttles down | +| 8 | gameplay-breaking | `Collider.cpp:88`, `RaceScreen.cpp:3324` | A remote car was a kinematic body whose collider reported no velocity: a still wall to the physics. Closing at 1 m/s on a car doing 30 m/s cost the chaser 12.6 m/s; a rear tap threw it back; rubbing side by side dragged it. MM2's network car is a simulated vehCar with its own velocity. | test `Kinematic.TouchingAMovingKinematicBodyTakesTheRelativeSpeed` (failed before) | acb820c, then the shared traffic's `kinematicMoves` (merge ea3f6ec): the car reports its snapshot velocity and spin in every mode (it still does not give way: deviation) | +| 9 | minor (robustness) | `Session.cpp:610`, `:860` | Snapshot times were trusted and relayed: a sender stamping minutes ahead dragged its car toward that state and pinned the playout delay. | test `SessionSync.StatesStampedFarFromTheClockAreDropped` | 793a736: dropped (and not relayed) more than 1 s ahead or 5 s behind the receiver's clock | +| 10 | visible | `Session.cpp:398`, `:375` | Snapshots went out on the first frame 50 ms after the last send, drifting to 17-18 Hz, and the same state was resent when no step had run. | trace (55.6 ms gaps at 144 fps) | 3edfaf6: steady 20 Hz cadence, only new states | +| 11 | gameplay-breaking | `game/session/Session.cpp:1722` | A race or circuit counted every result against the racers still listed: with three players, one finishing and then leaving ended the race (and sent everyone to the lobby) while the third was still driving. MM2's host compares at each finish (`mmMultiRace::GameMessage` 0x206). | test `Session.MultiplayerRaceWaitsForThePlayersStillInIt` (failed before) | db18f06: waits until every player still in the race has finished, or the timeout | +| 12 | gameplay-breaking | `RaceScreen.cpp:2469` | A joiner quitting a running race stays in the session: the others kept its car frozen in the road as a solid obstacle and a race waited 60-120 s for its finish. MM2's quitter left the session (`QuitNetwork`), and the others deactivated its car (`SystemMessage` 0x2d). | host and client, the client quitting by popup script: the host logged the quit and stopped drawing the car | e328e17: `GameEventType::LeftRace`; the others take the car out, show "has left the game", drop it from the racers (Cops and Robbers: its gold drops) | +| 13 | visible | `RaceScreen.cpp:2152` | Cops and Robbers' time limit, warnings and lockouts ran in frames from each machine's own load, through the countdown: machines disagreed by their load times, one showing "Time's up" while the others played on. `mmMultiCR::UpdateGame` starts the clock when it enables the racers. | log: host and client agree to 1 ms after | f5ca06e: the rules run on the session clock from the shared start | +| 14 | visible (parity) | `RaceScreen.cpp:3315-3418` | The network cars were silent. MM2's `mmNetObject::PositionUpdate` drives the car's `vehCarAudioContainer` (engine, horn, siren) from each packet. | log: the kinematic car's engine runs 600-7000 rpm and shifts with the sender's gear | ad4357c: the AI cars' positioned audio, from the kinematic `vehCarSim` and the snapshot's pedals, gear, horn, siren, wreck | +| 15 | visible (parity) | `RaceScreen.cpp:3413` | Remote wheels at their rest positions, spun from the speed: they hung in the air or sank into the road. `vehCarModel::Draw` uses the sim's wheels. | screenshots | 0b4fe05: the kinematic car's wheel matrices | +| 16 | visible | `RaceScreen.cpp` `updateNetTraffic` (d7fb35b) | Shared traffic drawn a fixed 100 ms behind: 27% of the logged cars extrapolated over a 150 ms round trip. | `OPENMM2_DEBUG_NETTRAFFIC` counts: 27% before, 15% after (the rest far cars whose state comes every other message) | 9ecacd8: at least the host car's playout delay | +| 17 | gameplay-breaking (merge) | `RaceScreen.cpp` `applyNetTrafficHits` (d7fb35b) | After the merge, the host's traffic-hit handling read `takeGameEvents()` after the race screen had already taken the frame's events: clients' hits would never be applied. | by reading | 9ecacd8: reads the frame's list | + +The race screen now takes the frame's game events once (`takeNetEvents`) for +the race rules, Cops and Robbers, the traffic hits and `LeftRace`; before, +a cruise never took them, so they piled up (also capped by d7fb35b). + +### Checked and correct + +* **Quantisation** (`Snapshot.h`): position 2 mm, velocity 6 mm/s, angular + velocity 8 mrad/s, orientation smallest-three 10 bits (0.08°, 3 mm at a + car's corner): below anything visible. +* **Rotation**: `Quat::slerp` takes the short way (flips on a negative dot), + the encoder sends the largest component positive, extrapolation multiplies + the world-space delta on the left, which matches `toMatrix`'s convention + (checked against the test `ExtrapolatesRotation`). No Euler angles. +* **Hermite interpolation** with the replicated velocities follows the true + path to 0.7 cm rms (second difference of the error on the 60 Hz grid, + smooth stretches) once the stamps are right; what remains is the + centre-of-mass velocity against the model origin's (sub-centimetre). +* **Remote heights and suspension**: the drawn car is the snapshot's model + matrix; nothing in the kinematic `vehCarSim` moves its body (only + `resetBody` writes the matrix), so the ground cannot fight the received + height. +* **Contact with a still remote car** (both parked on the grid): stable; the + push only resolves penetration. +* **Events**: reliable and ordered on their own channel; a client's gold + pickup goes through the host (`PickupRequest`) so two players cannot both + take it; drops and deliveries are announced by the carrier, new sets by the + host, as `mmMultiCR::GameMessage`. +* **Players leaving or timing out**: `PlayerLeft` removes the car and its + buffer on every machine; a carrier's gold drops (host); the race stops + counting the player. +* **Time wrap**: session times are 32-bit milliseconds (49.7 days); the + 32-bit echo of a time request is rebuilt to 64 bits. +* **Bandwidth**, three players, measured (ENet overhead included): each + client sends 0.95 KB/s and receives 0.93 KB/s per other car; the host + sends 3.7 KB/s. For eight players: about 6.5 KB/s down per client and + 46 KB/s up for the host, plus the shared traffic in cruise. + +### Open + +| # | Severity | Location | Scenario | Status / what it needs | +| --- | --- | --- | --- | --- | +| S1 | visible | `RaceScreen.cpp` (frame loop), `phys::World::interpolationAlpha` | Nothing is drawn interpolated between simulation steps: above 60 fps the local car, the camera and every simulated car move in 60 Hz steps against smoothly drawn scenery and rail traffic. The remote cars now step with the simulated ones (finding 2), as the AI racers do. | outside this area (rendering): needs the drawn poses blended by `interpolationAlpha`, and then remote cars sampled at the blended time. | +| S2 | minor | `RaceScreen::updateRemoteCars` | The remote car is a kinematic body of infinite mass: a hit never moves it, and the local car takes the whole response. MM2's network car is a simulated vehCar pulled toward its packets (`mmNetObject::Predict`). | deviation, by design; giving it a mass would need the remote car simulated and corrected, as MM2 does. | +| S3 | minor | `NetGame::remoteCars` | Remote cars are shown a playout delay in the past (70 ms on a LAN, 150-300 ms over the Internet); MM2 predicts them forward to the present from the average packet interval (`mmNetObject::PositionUpdate`). Racing side by side, each player sees the other a little behind and collisions happen at the past position. | deviation (smoothness over immediacy); a forward-predicted collision body is possible but would bring back the snapping. | +| S4 | minor | `Session.cpp` (snapshots) | A car whose snapshots stop (a crashed client) is held still as a solid obstacle until ENet times the player out (5-15 s). | could drop a car silent for a few seconds; not done because a stalled link would then reload the car when it comes back. | +| S5 | visible (parity) | `RaceScreen::drawRemoteCars` | A remote car's damage (replicated, 0-1) does not show: no dents, smoke or fire. MM2 sets the remote `vehCarDamage` from each packet (`PositionUpdate`, clearing it when it drops to 0). | needs `VehicleEffects` per remote car fed from the damage value. | +| S6 | minor (parity) | `CopsAndRobbers::tickLimits` | Each machine checks the time and point limits itself; MM2's host alone does and tells the others (`mmMultiCR::UpdateLimit`, `SendLimitReached`). With the shared clock (finding 13) the time limit agrees; the point limit agrees as long as the scores do. | deviation; a host-sent limit message would make it exact. | +| S7 | minor | `RaceScreen::sendLocalState` | The local player has no siren (a police car's horn key in MM2 toggles it), so `kVehicleSiren` is never sent; the remote audio and lights would play it. | outside this area (player controls). | + +Lobby review O1 (the race starts a fixed 6 s after GO DRIVE instead of after +everyone's RaceReady) touches this area only in that a slow loader starts its +own race countdown late: its clock and its finish time are measured from its +own start, while Cops and Robbers' clock (finding 13) runs from the shared +start time. Nothing here depends on it otherwise. + +### MM2 rules compared + +* Remote cars: MM2 sends position, Euler angles, velocities and inputs at a + per-player rate and dead-reckons them forward with a smoothing path + (`mmNetObject`); OpenMM2 interpolates in the past (S2, S3). +* Finishes: MM2's host counts finishes against the session's players at each + finish (0x206) with a 60/120 s timeout; OpenMM2 waits for the players still + in the race, which also covers a finished player leaving (finding 11). +* A player quitting a race: MM2 leaves the session; OpenMM2 stays in the + lobby, so the others are told (finding 12). +* Cops and Robbers: clock from the racers' release (finding 13); limits per + machine (S6). + +## Notes for the other areas + +* The shared traffic: its client now uses the host car's playout delay and + the frame's session time (finding 16); its messages are stamped at the + host's frame time, while the police and off-rail cars it carries are + physics bodies whose state is up to a step older (as finding 1 was for the + players' cars): `TrafficHost` could stamp those with the step's time too. +* The network players' kinematic bodies report their motion in every mode, + not only in shared-traffic cruise (finding 8). From c029d657c1c4bbd2842989b76a62e9e5b5cd8150 Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Thu, 8 Oct 2026 23:49:24 -0700 Subject: [PATCH 53/54] Call this version 0.2.0 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 2392356..d4be2fd 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,7 +1,7 @@ cmake_minimum_required(VERSION 3.25...4.0) project(OpenMM2 - VERSION 0.1.0 + VERSION 0.2.0 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 a182e1a..8c5e795 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: races after the first in a session no longer send everyone back to the lobby, and other players' cars no longer jitter. Multiplayer cruise gains shared traffic and police, run by the host.

+
+

Initial development version.

From 0c23b02e1577dddb0c0168f5f70c252d7f6d0468 Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Fri, 9 Oct 2026 00:08:27 -0700 Subject: [PATCH 54/54] Keep the network tests' ports below Windows' dynamic range SharedTrafficNet.HostAndClientInOneProcess failed on the Windows runner: it picked ports from 51000-63000, inside Windows' dynamic range (49152-65535), where the system reserves and hands out ports, and the bind failed. test_netgame.cpp's range reached 56998 as well, and the lobby and sync tests shared one block. Each file now has its own block below 49152. Co-Authored-By: Claude Opus 5.5 (1M context) --- tests/game/test_netgame.cpp | 6 ++++-- tests/game/test_netgame_lobby.cpp | 6 ++++-- tests/game/test_netgame_sync.cpp | 5 ++++- tests/game/test_traffic_net.cpp | 5 ++++- 4 files changed, 16 insertions(+), 6 deletions(-) diff --git a/tests/game/test_netgame.cpp b/tests/game/test_netgame.cpp index e3f89d3..d535125 100644 --- a/tests/game/test_netgame.cpp +++ b/tests/game/test_netgame.cpp @@ -13,10 +13,12 @@ using game::NetGame; namespace { -// Distinct ports per test process so parallel ctest runs do not collide. +// Each test file has its own block of ports, all below 49152: Windows hands +// out and reserves ports in its dynamic range (49152-65535), where a bind can +// fail. Within a block, distinct ports per process for parallel ctest runs. std::uint16_t basePort() { static const std::uint16_t port = static_cast( - 41000 + (std::chrono::steady_clock::now().time_since_epoch().count() / 1000) % 8000 * 2); + 41000 + (std::chrono::steady_clock::now().time_since_epoch().count() / 1000) % 2000 * 2); return port; } diff --git a/tests/game/test_netgame_lobby.cpp b/tests/game/test_netgame_lobby.cpp index 9675501..8f4a2a7 100644 --- a/tests/game/test_netgame_lobby.cpp +++ b/tests/game/test_netgame_lobby.cpp @@ -14,10 +14,12 @@ using game::NetGame; namespace { -// Distinct ports per test process so parallel ctest runs do not collide. +// Each test file has its own block of ports, all below 49152: Windows hands +// out and reserves ports in its dynamic range (49152-65535), where a bind can +// fail. Within a block, distinct ports per process for parallel ctest runs. std::uint16_t basePort() { static const std::uint16_t port = static_cast( - 33000 + (std::chrono::steady_clock::now().time_since_epoch().count() / 1000) % 4000 * 2); + 33000 + (std::chrono::steady_clock::now().time_since_epoch().count() / 1000) % 2000 * 2); return port; } diff --git a/tests/game/test_netgame_sync.cpp b/tests/game/test_netgame_sync.cpp index c68383c..bb1d263 100644 --- a/tests/game/test_netgame_sync.cpp +++ b/tests/game/test_netgame_sync.cpp @@ -14,9 +14,12 @@ using game::NetGame; namespace { +// Each test file has its own block of ports, all below 49152: Windows hands +// out and reserves ports in its dynamic range (49152-65535), where a bind can +// fail. Within a block, distinct ports per process for parallel ctest runs. std::uint16_t syncPort() { static const std::uint16_t port = static_cast( - 33000 + (std::chrono::steady_clock::now().time_since_epoch().count() / 1000) % 4000 * 2); + 37000 + (std::chrono::steady_clock::now().time_since_epoch().count() / 1000) % 2000 * 2); return port; } diff --git a/tests/game/test_traffic_net.cpp b/tests/game/test_traffic_net.cpp index 86f5986..99c0329 100644 --- a/tests/game/test_traffic_net.cpp +++ b/tests/game/test_traffic_net.cpp @@ -22,9 +22,12 @@ using game::TrafficHost; namespace { +// Each test file has its own block of ports, all below 49152: Windows hands +// out and reserves ports in its dynamic range (49152-65535), where a bind can +// fail. Within a block, distinct ports per process for parallel ctest runs. std::uint16_t trafficPort() { static const std::uint16_t port = static_cast( - 51000 + (std::chrono::steady_clock::now().time_since_epoch().count() / 1000) % 6000 * 2); + 45000 + (std::chrono::steady_clock::now().time_since_epoch().count() / 1000) % 2000 * 2); return port; }