From 231069dde8239ba442d90a241f3935a46afde72b Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Fri, 9 Oct 2026 12:43:24 -0700 Subject: [PATCH 001/129] Trace the shared traffic on every machine for desync measurements With OPENMM2_NET_TRACE set, the host writes each shared car near a player at the session time its state belongs to (the AI's last step for a car on its rail, the physics step's for a police car or a knocked one), and every knock and refused hit report; a client writes the received cars where its car meets them each frame, with the session time its own car reaches in that frame's steps, and the hits it reports. Comparing the files measures how far each client's traffic is from the host's at the same moment. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/app/RaceScreen.cpp | 83 ++++++++++++++++++++++++++++++----- src/game/net/NetGame.cpp | 2 + src/game/net/NetGame.h | 4 ++ src/game/net/TrafficSync.cpp | 1 + src/game/net/TrafficSync.h | 3 ++ src/game/net/TrafficTrace.cpp | 55 +++++++++++++++++++++++ src/game/net/TrafficTrace.h | 40 +++++++++++++++++ 7 files changed, 177 insertions(+), 11 deletions(-) create mode 100644 src/game/net/TrafficTrace.cpp create mode 100644 src/game/net/TrafficTrace.h diff --git a/src/app/RaceScreen.cpp b/src/app/RaceScreen.cpp index 82b865f..88bfff8 100644 --- a/src/app/RaceScreen.cpp +++ b/src/app/RaceScreen.cpp @@ -46,6 +46,7 @@ #include "game/net/RaceStart.h" #include "game/net/TrafficProxies.h" #include "game/net/TrafficSync.h" +#include "game/net/TrafficTrace.h" #include "game/CamMirror.h" #include "game/CamPlayer.h" #include "game/CityRenderer.h" @@ -371,7 +372,7 @@ class RaceScreen final : public Screen { if (multiplayer(ctx)) m_netDamage.settle(ctx.netGame->frameTime()); // OpenMM2: the damage of cars not drawn if (netTrafficHost(ctx) && m_ai) - applyNetTrafficHits(); // OpenMM2: the clients' hits on the host's traffic + 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) @@ -3874,7 +3875,9 @@ 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() { + void applyNetTrafficHits(Context& ctx) { + std::FILE* trace = ctx.netGame->traceFile(); + const double now = ctx.netGame->frameTime(); for (const auto& ev : m_netEvents) { if (static_cast(ev.type) != net::kTrafficHitEvent || !m_trafficBodies) continue; @@ -3886,17 +3889,24 @@ class RaceScreen final : public Screen { 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) + if (car == m_ai->cars().end() || car->physical) { + game::traceRefusedHit(trace, now, ev.from, hit->id, hit->generation, + car == m_ai->cars().end() ? "gone" : "off-rail"); 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) + if (other.ics.matrix.m3.dist(car->transform.m3) > reach) { + game::traceRefusedHit(trace, now, ev.from, hit->id, hit->generation, "far"); 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) + if (mt <= 0.0f || mc <= 0.0f || speed < 0.5f) { + game::traceRefusedHit(trace, now, ev.from, hit->id, hit->generation, "slow"); continue; + } const Vec3 n = rel * (1.0f / speed); const float reduced = mc * mt / (mc + mt); const Vec3 impulse = n * (reduced * speed * 1.25f); @@ -3906,12 +3916,39 @@ class RaceScreen final : public Screen { const float d = toward.mag(); 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) + const bool knocked = m_trafficBodies->knock(car->id, impulse, point); + if (knocked) + game::traceKnock(trace, now, car->id, car->spawns, ev.from); + if (knocked && m_debugNetTraffic) log::info("nettraffic: host knocks traffic car {} for player {} ({:.1f} m/s)", car->id, ev.from, speed); } } + // OPENMM2_NET_TRACE on the host: the shared cars near any player, each at + // the session time its state belongs to (the AI's last step, or the + // physics step's for a body). + void traceNetTraffic(Context& ctx, std::FILE* trace, std::span cars) { + const double frame = ctx.netGame->frameTime(); + const double aiTime = + frame - static_cast(m_ai->interpolationAlpha() * ai::kAiStepSeconds) * 1000.0; + const double bodyTime = frame - (m_world ? static_cast(m_world->remainder()) * 1000.0 : 0.0); + std::vector players; + if (m_player) + players.push_back(m_player->sim().modelMatrix().m3); + for (const auto& rc : ctx.netGame->remoteCars()) + if (rc.hasState) + players.push_back(rc.transform.m3); + // The AI's cars once per AI step; the bodies every frame. + const bool aiStepped = m_ai->lightSteps() != m_traceAiSteps; + m_traceAiSteps = m_ai->lightSteps(); + for (const game::SharedCar& c : cars) { + const auto near = [&](const Vec3& p) { return p.dist2(c.transform.m3) < 9e4f; }; + if ((c.body || aiStepped) && std::ranges::any_of(players, near)) + game::traceHostCar(trace, c.body ? bodyTime : aiTime, c); + } + } + void sendNetTraffic(Context& ctx) { if (!m_trafficHost || !m_ai || !netTrafficHost(ctx)) return; @@ -3921,9 +3958,12 @@ class RaceScreen final : public Screen { if (c.horn) m_hornLatch.insert(c.id); const std::uint64_t now = net::monotonicMs(); - if (now - m_trafficSentAt < kNetTrafficIntervalMs) + std::FILE* trace = ctx.netGame->traceFile(); // OPENMM2_NET_TRACE: every frame + const bool send = now - m_trafficSentAt >= kNetTrafficIntervalMs; + if (!send && !trace) return; - m_trafficSentAt = now; + if (send) + m_trafficSentAt = now; std::vector cars; cars.reserve(m_ai->cars().size() + m_cops.size()); for (const ai::AmbientCar& c : m_ai->cars()) { @@ -3947,15 +3987,18 @@ class RaceScreen final : public Screen { 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) { + if (send) + m_hornLatch.clear(); + if (m_debugNetTraffic || trace) { 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)) + if (!m_debugKnocked.contains(c.id) && m_debugNetTraffic) log::info("nettraffic: host traffic car {} knocked off its rail at session t {}", c.id, ctx.netGame->sessionTime()); + if (!m_debugKnocked.contains(c.id)) + game::traceKnock(trace, ctx.netGame->frameTime(), c.id, c.spawns, 255); } m_debugKnocked = std::move(knocked); } @@ -3993,8 +4036,13 @@ class RaceScreen final : public Screen { s.rpm = sim.engine.rpm; s.throttle = sim.engine.throttle; s.gear = sim.trans.getCurrentGear(); + s.body = true; cars.push_back(s); } + if (trace) + traceNetTraffic(ctx, trace, cars); + if (!send) + return; const std::uint32_t time = ctx.netGame->sessionTime(); std::set viewers; for (const auto& rc : ctx.netGame->remoteCars()) { @@ -4089,6 +4137,7 @@ class RaceScreen final : public Screen { hit.generation = static_cast(it->spawns); hit.velocity = m_netPreStepVelocity; ctx.netGame->sendEvent(net::kTrafficHitEvent, net::encodePayload(hit), net::kHostPlayerId); + game::traceHit(ctx.netGame->traceFile(), ctx.netGame->frameTime(), id, hit.generation); if (m_debugNetTraffic) log::info("nettraffic: client hit traffic car {} at session t {}", id, now); } @@ -4110,6 +4159,17 @@ class RaceScreen final : public Screen { const double renderTime = ctx.netGame->frameTime() - delay; m_trafficClient->update(renderTime); m_trafficRenderTime = renderTime; + if (std::FILE* trace = ctx.netGame->traceFile(); trace && m_player && m_world) { + // Where the received cars meet this machine's car in this frame's + // steps, and the session time that car reaches in them. + const double frame = ctx.netGame->frameTime(); + const double own = frame - static_cast(m_world->remainderAfter(dt)) * 1000.0; + const Vec3 me = m_player->sim().modelMatrix().m3; + game::traceClientView(trace, frame, own, me); + for (const auto& c : m_trafficClient->cars()) + if (c.transform.m3.dist2(me) < 9e4f) + game::traceClientCar(trace, frame, renderTime, c, c.extrapolated ? 1 : 0); + } if (const auto steps = m_trafficClient->lightSteps(renderTime)) m_ai->advanceLightsTo(*steps); m_netCars.clear(); @@ -5368,6 +5428,7 @@ class RaceScreen final : public Screen { // OPENMM2_DEBUG_NETDAMAGE logs every damage event sent and received. bool m_debugNetDamage = std::getenv("OPENMM2_DEBUG_NETDAMAGE") != nullptr; std::unordered_set m_debugKnocked; + std::uint32_t m_traceAiSteps = 0; // OPENMM2_NET_TRACE: the AI step last traced (host) // Sound: the player's car, city ambience and rain (src/audio/game). std::unique_ptr m_bank; diff --git a/src/game/net/NetGame.cpp b/src/game/net/NetGame.cpp index 4f580d9..6364a54 100644 --- a/src/game/net/NetGame.cpp +++ b/src/game/net/NetGame.cpp @@ -726,6 +726,8 @@ double NetGame::playoutDelay(std::uint8_t playerId) const { return m_impl->session ? m_impl->session->playoutDelay(playerId) : 0.0; } +std::FILE* NetGame::traceFile() const { return m_impl->session ? m_impl->trace.get() : nullptr; } + 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 37af7af..9ac5bde 100644 --- a/src/game/net/NetGame.h +++ b/src/game/net/NetGame.h @@ -17,6 +17,7 @@ #include "net/Transport.h" #include +#include #include #include #include @@ -292,6 +293,9 @@ class NetGame { // docs/multiplayer.md, "Diagnosing replication"). void traceFrame(const Mat34& transform, const Vec3& velocity, double stateAgeMs, const std::vector& cars); + // That trace file (null when none is written), for the shared traffic's + // lines (game/net/TrafficTrace.h). + std::FILE* traceFile() const; // --- Shared ambient traffic (multiplayer cruise, OpenMM2 extra) ---------------- // Whether this session's cruise shares the host's traffic and police diff --git a/src/game/net/TrafficSync.cpp b/src/game/net/TrafficSync.cpp index ec2aef2..2c5cde8 100644 --- a/src/game/net/TrafficSync.cpp +++ b/src/game/net/TrafficSync.cpp @@ -372,6 +372,7 @@ SharedCar shareTrafficCar(const ai::AmbientCar& car, int model, int paint, const s.speed = car.speed; s.velocity = body ? body->velocity : car.velocity; s.angularVelocity = body ? body->angularVelocity : Vec3{}; + s.body = body != nullptr; if (body && body->wheels) { s.wheels = true; s.wheelOffsets = *body->wheels; diff --git a/src/game/net/TrafficSync.h b/src/game/net/TrafficSync.h index 4973352..ae8e685 100644 --- a/src/game/net/TrafficSync.h +++ b/src/game/net/TrafficSync.h @@ -67,6 +67,9 @@ struct SharedCar { Vec3 velocity; Vec3 angularVelocity; std::uint8_t flags = 0; // net::AmbientFlags + // Its state is a physics body's (a police car, a knocked traffic car): + // it belongs to the physics step's time, not the AI step's. + bool body = false; // Traffic with a physics body: WHL0-3's drawing offsets from their // pivots (trafficWheelOffsets). bool wheels = false; diff --git a/src/game/net/TrafficTrace.cpp b/src/game/net/TrafficTrace.cpp new file mode 100644 index 0000000..382978a --- /dev/null +++ b/src/game/net/TrafficTrace.cpp @@ -0,0 +1,55 @@ +// The shared traffic in the OPENMM2_NET_TRACE file (development aid). See +// TrafficTrace.h. +#include "game/net/TrafficTrace.h" + +#include + +namespace mm2::game { +namespace { + +void traceCar(std::FILE* f, const Mat34& m, float speed, std::uint8_t flags, std::uint8_t target) { + std::fprintf(f, " %.3f %.3f %.3f %.4f %.4f %.3f %u %d", m.m3.x, m.m3.y, m.m3.z, -m.m2.x, -m.m2.z, speed, + flags, target == net::kAmbientNoTarget ? -1 : static_cast(target)); +} + +} // namespace + +void traceHostCar(std::FILE* f, double time, const SharedCar& car) { + if (!f) + return; + std::fprintf(f, "TH %.3f %d %d %d", time, car.id, car.generation % static_cast(net::kAmbientGenerations), + static_cast(car.kind)); + traceCar(f, car.transform, car.speed, car.flags, car.target); + std::fputc('\n', f); +} + +void traceClientCar(std::FILE* f, double frame, double time, const TrafficClient::Car& car, int mode) { + if (!f) + return; + std::fprintf(f, "TC %.3f %.3f %d %d %d", frame, time, car.id, car.generation, static_cast(car.kind)); + traceCar(f, car.transform, car.speed, car.flags, car.target); + std::fprintf(f, " %d\n", mode); +} + +void traceClientView(std::FILE* f, double frame, double time, const Vec3& position) { + if (f) + std::fprintf(f, "TV %.3f %.3f %.3f %.3f %.3f\n", frame, time, position.x, position.y, position.z); +} + +void traceHit(std::FILE* f, double time, int id, int generation) { + if (f) + std::fprintf(f, "TX %.3f %d %d\n", time, id, generation); +} + +void traceKnock(std::FILE* f, double time, int id, int generation, int player) { + if (f) + std::fprintf(f, "TK %.3f %d %d %d\n", time, id, generation % static_cast(net::kAmbientGenerations), + player); +} + +void traceRefusedHit(std::FILE* f, double time, int from, int id, int generation, std::string_view why) { + if (f) + std::fprintf(f, "TR %.3f %d %d %d %s\n", time, from, id, generation, std::string(why).c_str()); +} + +} // namespace mm2::game diff --git a/src/game/net/TrafficTrace.h b/src/game/net/TrafficTrace.h new file mode 100644 index 0000000..e12dde3 --- /dev/null +++ b/src/game/net/TrafficTrace.h @@ -0,0 +1,40 @@ +#pragma once + +// Development aid for the shared traffic of a network cruise (OpenMM2 extra): +// with OPENMM2_NET_TRACE= set, each machine writes the shared cars as it +// has them, one line each, next to the players' cars' lines (docs/ +// multiplayer.md, "Diagnosing replication"). `mm2tool nettrace` compares a +// host's trace with its clients' (docs/review/multiplayer-desync-traffic.md). +// +// TH time id gen kind x y z fx fz speed flags target +// host: a car as its simulation has it at session time `time` (its own: +// the AI step's for a car on its rail, the physics step's for a body) +// TC frame time id gen kind x y z fx fz speed flags target mode +// client: a car where its collisions meet it (the physics proxy) for the +// frame that began at session time `frame`, placed for session time +// `time`; mode 0 interpolated, 1 extrapolated, 2 predicted +// TV frame time x y z +// client: where this machine's car is for the same frame, and its time +// TX time id gen client: reported hitting car id +// TK time id gen player host: knocked car id off its rail (player 255: +// its own simulation's collision) +// TR time from id gen why host: a client's hit report it did not apply +// +// (fx, fz) is the car's forward direction (-m2) on the ground plane. + +#include "game/net/TrafficSync.h" + +#include +#include +#include + +namespace mm2::game { + +void traceHostCar(std::FILE* f, double time, const SharedCar& car); +void traceClientCar(std::FILE* f, double frame, double time, const TrafficClient::Car& car, int mode); +void traceClientView(std::FILE* f, double frame, double time, const Vec3& position); +void traceHit(std::FILE* f, double time, int id, int generation); +void traceKnock(std::FILE* f, double time, int id, int generation, int player); +void traceRefusedHit(std::FILE* f, double time, int from, int id, int generation, std::string_view why); + +} // namespace mm2::game From 80b8b8ecff232170b210085c4effee0f40fbbc37 Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Fri, 9 Oct 2026 12:54:58 -0700 Subject: [PATCH 002/129] Trace every player's car as each machine draws it, and measure the divergence OPENMM2_NET_TRACE gains three opt-in lines: D for every player's car as the frame draws it (the machine's own car marked), K for a collision between two players' cars in this machine's simulation, and C for a correction of a predicted car. netprobe syncreport reads one trace per machine and prints how far apart the machines show each car at the same wall-clock moment, the frames in which a car moves further than its velocity explains, which collisions between players each machine had and how far apart every machine drew the two cars at that moment, and the corrections. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/app/RaceScreen.cpp | 23 +++ src/game/net/NetGame.cpp | 31 +++ src/game/net/NetGame.h | 9 + tools/netprobe/CMakeLists.txt | 2 +- tools/netprobe/SyncReport.cpp | 351 ++++++++++++++++++++++++++++++++++ tools/netprobe/SyncReport.h | 21 ++ tools/netprobe/main.cpp | 18 +- 7 files changed, 451 insertions(+), 4 deletions(-) create mode 100644 tools/netprobe/SyncReport.cpp create mode 100644 tools/netprobe/SyncReport.h diff --git a/src/app/RaceScreen.cpp b/src/app/RaceScreen.cpp index 82b865f..ce776ab 100644 --- a/src/app/RaceScreen.cpp +++ b/src/app/RaceScreen.cpp @@ -121,6 +121,7 @@ class RaceScreen final : public Screen { // The network race this screen runs (FrontendScreen starts it when // the countdown arrives). if (config.multiplayer && ctx.netGame) { + m_traceNet = ctx.netGame.get(); m_netRace = ctx.netGame->raceNumber(); m_chatSeen = ctx.netGame->chatSerial(); // the lobby's lines stay there } @@ -409,6 +410,7 @@ class RaceScreen final : public Screen { sendLocalState(ctx); } updateDrawnPoses(); // OpenMM2: between the last two simulation steps + traceNetDrawn(ctx); // OPENMM2_NET_TRACE: every player's car as drawn sendNetTraffic(ctx); // OpenMM2: the host's shared traffic if (m_flyCamera || !m_player) updateFlyCamera(ctx, static_cast(dt)); @@ -3681,7 +3683,27 @@ class RaceScreen final : public Screen { // vehCarDamage::ApplyImpact for the player's car: AudImpact (the impact // sounds), the damage effects, and the game's impact callback // (mmPlayer::ImpactCallback), which counts the hits. + // OPENMM2_NET_TRACE: every player's car as this frame draws it, for the + // divergence between machines (netprobe syncreport). + void traceNetDrawn(Context& ctx) { + if (!multiplayer(ctx) || !ctx.netGame->tracing()) + return; + if (m_player) + ctx.netGame->traceDrawn(ctx.netGame->localId(), true, m_drawPose.body, + m_player->sim().body.ics.frameVelocity); + for (const auto& rc : m_remoteCars) + if (const auto drawn = netCarDrawn(rc.id)) + ctx.netGame->traceDrawn(rc.id, false, *drawn, rc.velocity); + } + void playerImpact(const phys::CarImpact& impact) { + // OPENMM2_NET_TRACE: a collision with another player's car. + if (impact.otherBody && m_traceNet && m_traceNet->tracing()) + for (const auto& [id, rv] : m_remotes) + if (rv.sim && &rv.sim->sim().body == impact.otherBody) + m_traceNet->traceImpact( + m_traceNet->localId(), id, impact.position, impact.total, + m_world ? static_cast(m_world->remainder()) * 1000.0 : 0.0); // mmMultiCR::ImpactCallback (from vehCarDamage::ApplyImpact's damaging // branch): a hit from another player's car, its summed total. if (m_cr && impact.otherBody && impact.damaging) @@ -5326,6 +5348,7 @@ class RaceScreen final : public Screen { 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 + game::NetGame* m_traceNet = nullptr; // the race's session, for OPENMM2_NET_TRACE's impacts std::optional m_netStart; // from the end of the loading (updateNetStart) bool m_netHeld = true; // its car hold std::optional m_netToGo; // its countdown (Session::setNetStart) diff --git a/src/game/net/NetGame.cpp b/src/game/net/NetGame.cpp index 4f580d9..ed38bb6 100644 --- a/src/game/net/NetGame.cpp +++ b/src/game/net/NetGame.cpp @@ -743,6 +743,37 @@ void NetGame::traceFrame(const Mat34& transform, const Vec3& velocity, double st c.velocity.z, c.time); } +bool NetGame::tracing() const { return m_impl->trace && m_impl->session; } + +void NetGame::traceDrawn(std::uint8_t id, bool own, const Mat34& transform, const Vec3& velocity) { + std::FILE* f = m_impl->trace.get(); + if (!f || !m_impl->session) + return; + // D wall frameTime id own x y z vx vy vz + const Vec3& p = transform.m3; + std::fprintf(f, "D %.3f %.3f %u %d %.3f %.3f %.3f %.3f %.3f %.3f\n", net::monotonicMsPrecise(), + m_frameTime, id, own ? 1 : 0, p.x, p.y, p.z, velocity.x, velocity.y, velocity.z); +} + +void NetGame::traceImpact(std::uint8_t a, std::uint8_t b, const Vec3& position, float strength, + double stateAgeMs) { + std::FILE* f = m_impl->trace.get(); + if (!f || !m_impl->session) + return; + // K wall sessionTime a b x y z strength + std::fprintf(f, "K %.3f %.3f %u %u %.3f %.3f %.3f %.1f\n", net::monotonicMsPrecise(), + m_frameTime - stateAgeMs, a, b, position.x, position.y, position.z, strength); +} + +void NetGame::traceCorrection(std::uint32_t seq, int replayed, const Vec3& dx, float dv, bool snapped) { + std::FILE* f = m_impl->trace.get(); + if (!f || !m_impl->session) + return; + // C wall frameTime seq replayed dx dy dz dv snapped + std::fprintf(f, "C %.3f %.3f %u %d %.4f %.4f %.4f %.4f %d\n", net::monotonicMsPrecise(), m_frameTime, seq, + replayed, dx.x, dx.y, dx.z, dv, snapped ? 1 : 0); +} + 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 37af7af..551041a 100644 --- a/src/game/net/NetGame.h +++ b/src/game/net/NetGame.h @@ -292,6 +292,15 @@ class NetGame { // docs/multiplayer.md, "Diagnosing replication"). void traceFrame(const Mat34& transform, const Vec3& velocity, double stateAgeMs, const std::vector& cars); + // The same trace's divergence lines (docs/review/multiplayer-desync-cars.md; + // `netprobe syncreport` reads them): every player's car as this frame + // draws it (`own`: this machine's player), a collision between two + // players' cars in this machine's simulation, and a correction of this + // player's car by the host's state (`dx`: how far the car moved). + bool tracing() const; + void traceDrawn(std::uint8_t id, bool own, const Mat34& transform, const Vec3& velocity); + void traceImpact(std::uint8_t a, std::uint8_t b, const Vec3& position, float strength, double stateAgeMs); + void traceCorrection(std::uint32_t seq, int replayed, const Vec3& dx, float dv, bool snapped); // --- Shared ambient traffic (multiplayer cruise, OpenMM2 extra) ---------------- // Whether this session's cruise shares the host's traffic and police diff --git a/tools/netprobe/CMakeLists.txt b/tools/netprobe/CMakeLists.txt index 446d76b..7f7f56b 100644 --- a/tools/netprobe/CMakeLists.txt +++ b/tools/netprobe/CMakeLists.txt @@ -1,4 +1,4 @@ if(TARGET mm2_net) - add_executable(netprobe main.cpp) + add_executable(netprobe main.cpp SyncReport.cpp) target_link_libraries(netprobe PRIVATE OpenMM2::net) endif() diff --git a/tools/netprobe/SyncReport.cpp b/tools/netprobe/SyncReport.cpp new file mode 100644 index 0000000..ee8b16a --- /dev/null +++ b/tools/netprobe/SyncReport.cpp @@ -0,0 +1,351 @@ +// netprobe syncreport: how far apart the machines of a network race show the +// players' cars, how often a car jumps, and which collisions between players +// one machine had and another did not. Reads each machine's +// OPENMM2_NET_TRACE file (the D, K and C lines, see NetGame::traceDrawn). +// +// The machines must have run on one computer: "the same moment" is the same +// monotonic wall clock, which every process on a machine shares, so the +// comparison is what two screens side by side show. + +#include "SyncReport.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace mm2::netprobe { +namespace { + +struct V3 { + double x = 0, y = 0, z = 0; + V3 operator-(const V3& o) const { return {x - o.x, y - o.y, z - o.z}; } + V3 operator+(const V3& o) const { return {x + o.x, y + o.y, z + o.z}; } + V3 operator*(double k) const { return {x * k, y * k, z * k}; } + double length() const { return std::sqrt(x * x + y * y + z * z); } + double horizontal() const { return std::sqrt(x * x + z * z); } +}; + +struct Drawn { + double wall = 0, time = 0; + V3 p, v; +}; +struct Hit { + double wall = 0, time = 0; + int a = 0, b = 0; + V3 p; + double strength = 0; +}; +struct Correction { + double wall = 0, time = 0; + int replayed = 0; + V3 dx; + double dv = 0; + bool snapped = false; +}; + +struct Machine { + std::string path; + int self = -1; + std::map> drawn; + std::vector hits; + std::vector corrections; + double firstWall = 0; +}; + +bool load(const std::string& path, Machine& m) { + std::ifstream in(path); + if (!in) + return false; + m.path = path; + std::string line; + bool first = true; + while (std::getline(in, line)) { + if (line.size() < 2 || line[1] != ' ') + continue; + std::istringstream s(line.substr(2)); + switch (line[0]) { + case 'D': { + Drawn d; + int id = 0, own = 0; + if (!(s >> d.wall >> d.time >> id >> own >> d.p.x >> d.p.y >> d.p.z >> d.v.x >> d.v.y >> d.v.z)) + continue; + if (own) + m.self = id; + if (first) { + m.firstWall = d.wall; + first = false; + } + m.drawn[id].push_back(d); + break; + } + case 'K': { + Hit h; + if (!(s >> h.wall >> h.time >> h.a >> h.b >> h.p.x >> h.p.y >> h.p.z >> h.strength)) + continue; + m.hits.push_back(h); + break; + } + case 'C': { + Correction c; + unsigned seq = 0; + int snapped = 0; + if (!(s >> c.wall >> c.time >> seq >> c.replayed >> c.dx.x >> c.dx.y >> c.dx.z >> c.dv >> + snapped)) + continue; + c.snapped = snapped != 0; + m.corrections.push_back(c); + break; + } + default: break; + } + } + return true; +} + +// A series' position at wall time `w` (linear between its samples), if it +// has samples within 200 ms on each side. +bool at(const std::vector& series, double w, V3& out) { + const auto it = std::ranges::lower_bound(series, w, {}, &Drawn::wall); + if (it == series.begin() || it == series.end()) + return false; + const Drawn& b = *it; + const Drawn& a = *(it - 1); + if (b.wall - a.wall > 200.0) + return false; + const double f = b.wall > a.wall ? (w - a.wall) / (b.wall - a.wall) : 0.0; + out = a.p + (b.p - a.p) * f; + return true; +} + +struct Stats { + std::vector v; + void add(double x) { v.push_back(x); } + double pct(double p) { + if (v.empty()) + return 0; + std::ranges::sort(v); + const double k = std::clamp(p, 0.0, 1.0) * static_cast(v.size() - 1); + const auto i = static_cast(k + 0.5); + return v[i]; + } + double rms() const { + double s = 0; + for (double x : v) + s += x * x; + return v.empty() ? 0 : std::sqrt(s / static_cast(v.size())); + } +}; + +std::string who(const std::vector& ms, std::size_t i) { + return ms[i].self == 0 ? std::string("host") : std::format("client {}", ms[i].self); +} + +// Collision episodes: a pair's impacts closer than 500 ms apart are one; an +// episode is its first impact. +std::map, std::vector> episodes(const Machine& m) { + std::map, std::vector> byPair; + for (const Hit& h : m.hits) + byPair[{std::min(h.a, h.b), std::max(h.a, h.b)}].push_back(h); + std::map, std::vector> out; + for (auto& [pair, hits] : byPair) { + std::ranges::sort(hits, {}, &Hit::time); + double last = -1e18; + for (const Hit& h : hits) { + if (h.time - last > 500.0) + out[pair].push_back(h); + last = h.time; + } + } + return out; +} + +// How far apart machine `m` drew two players' cars at wall time `w`. +bool drawnGap(const Machine& m, int a, int b, double w, double& gap) { + const auto ia = m.drawn.find(a); + const auto ib = m.drawn.find(b); + V3 pa, pb; + if (ia == m.drawn.end() || ib == m.drawn.end() || !at(ia->second, w, pa) || !at(ib->second, w, pb)) + return false; + gap = (pa - pb).length(); + return true; +} + +} // namespace + +int syncReport(const SyncReportOptions& o) { + std::vector ms; + for (const auto& path : o.traces) { + Machine m; + if (!load(path, m)) { + std::println(stderr, "cannot read {}", path); + return 1; + } + if (m.self < 0) + std::println(stderr, "{}: no line for this machine's own car", path); + ms.push_back(std::move(m)); + } + const double skipMs = o.skipSeconds * 1000.0; + + // 1. Where each machine draws each player's car, against where the + // car's own player sees it at the same moment. + std::println("Screen divergence: a player's car as another machine draws it, against its own player's"); + std::println("screen at the same wall-clock moment (metres; samples after the first {:.0f} s):", + o.skipSeconds); + std::println(" {:<10} {:<10} {:>7} {:>7} {:>7} {:>7} {:>7} {:>7}", "viewer", "car of", "frames", + "median", "p90", "p99", "max", "rms"); + for (std::size_t v = 0; v < ms.size(); ++v) { + for (std::size_t own = 0; own < ms.size(); ++own) { + if (own == v || ms[own].self < 0) + continue; + const auto seen = ms[v].drawn.find(ms[own].self); + const auto truth = ms[own].drawn.find(ms[own].self); + if (seen == ms[v].drawn.end() || truth == ms[own].drawn.end()) + continue; + Stats s; + for (const Drawn& d : seen->second) { + if (d.wall < ms[v].firstWall + skipMs || d.wall < ms[own].firstWall + skipMs) + continue; + V3 p; + if (at(truth->second, d.wall, p)) + s.add((d.p - p).length()); + } + if (s.v.empty()) + continue; + const double rms = s.rms(); + std::println(" {:<10} {:<10} {:>7} {:>7.3f} {:>7.3f} {:>7.3f} {:>7.3f} {:>7.3f}", who(ms, v), + who(ms, own), s.v.size(), s.pct(0.5), s.pct(0.9), s.pct(0.99), s.pct(1.0), rms); + } + } + + // 2. Jumps: a car's move from one drawn frame to the next that its + // velocity does not explain. + std::println("\nJumps: a frame's move that the car's velocity does not explain (frames 100 ms apart"); + std::println("or more are left out):"); + std::println(" {:<10} {:<10} {:>7} {:>9} {:>9} {:>9} {:>8}", "machine", "car of", "frames", ">0.25 m", + std::format(">{:.2g} m", o.teleportMetres), "/minute", "max"); + for (std::size_t m = 0; m < ms.size(); ++m) { + for (const auto& [id, series] : ms[m].drawn) { + int small = 0, big = 0; + double worst = 0; + std::size_t frames = 0; + for (std::size_t k = 1; k < series.size(); ++k) { + const Drawn& a = series[k - 1]; + const Drawn& b = series[k]; + if (a.wall < ms[m].firstWall + skipMs) + continue; + const double dt = (b.wall - a.wall) / 1000.0; + if (dt <= 0.0 || dt > 0.1) + continue; + ++frames; + const V3 expected = a.p + (a.v + b.v) * (0.5 * dt); + const double r = (b.p - expected).length(); + worst = std::max(worst, r); + if (r > 0.25) + ++small; + if (r > o.teleportMetres) + ++big; + } + if (frames == 0) + continue; + const double span = + (series.back().wall - std::max(series.front().wall, ms[m].firstWall + skipMs)) / 60000.0; + std::println(" {:<10} {:<10} {:>7} {:>9} {:>9} {:>9.2f} {:>8.3f}", who(ms, m), + id == ms[m].self ? std::string("(own)") : (id == 0 ? std::string("host") + : std::format("client {}", id)), + frames, small, big, span > 0 ? static_cast(big) / span : 0.0, worst); + } + } + + // 3. Collisions between players: episodes (impacts of a pair less than + // 500 ms apart) in each machine's simulation; whether the other machines + // that report that pair had one within 750 ms of session time and 4 m; + // and how far apart each machine drew the two cars at that moment + // (touching cars' centres are 2-5 m apart). + std::println("\nCollisions between players' cars (episodes; matched: another machine reporting the"); + std::println("pair had one within 750 ms of session time and 4 m; gap: the two cars' centres as each"); + std::println("machine drew them at that moment, median and largest):"); + std::vector, std::vector>> eps; + for (const auto& m : ms) + eps.push_back(episodes(m)); + std::set> pairs; + for (const auto& e : eps) + for (const auto& [pair, list] : e) + pairs.insert(pair); + if (pairs.empty()) + std::println(" none"); + for (const auto& pair : pairs) { + for (std::size_t m = 0; m < ms.size(); ++m) { + const auto it = eps[m].find(pair); + if (it == eps[m].end()) + continue; + std::string line = std::format(" {}-{} on {:<9}: {:>3} episodes", pair.first, pair.second, + who(ms, m), it->second.size()); + for (std::size_t n = 0; n < ms.size(); ++n) { + if (n == m) + continue; + const auto other = eps[n].find(pair); + int matched = 0; + for (const Hit& h : it->second) { + const auto near = [&h](const Hit& u) { + return std::abs(u.time - h.time) <= 750.0 && (u.p - h.p).length() <= 4.0; + }; + if (other != eps[n].end() && std::ranges::any_of(other->second, near)) + ++matched; + } + line += std::format(", {} also on {}", matched, who(ms, n)); + } + std::println("{}", line); + for (std::size_t n = 0; n < ms.size(); ++n) { + Stats gaps; + for (const Hit& h : it->second) { + double g = 0; + if (drawnGap(ms[n], pair.first, pair.second, h.wall, g)) + gaps.add(g); + } + if (!gaps.v.empty()) + std::println(" drawn gap on {:<9}: median {:.2f} m, largest {:.2f} m", who(ms, n), + gaps.pct(0.5), gaps.pct(1.0)); + } + } + } + + // 4. Corrections of a predicted car by the host's states. + bool anyCorrections = false; + for (std::size_t m = 0; m < ms.size(); ++m) { + const auto& cs = ms[m].corrections; + if (cs.empty()) + continue; + if (!anyCorrections) + std::println("\nCorrections of each machine's own car by the host's state (metres):"); + anyCorrections = true; + Stats size, replayed; + int over1 = 0, snaps = 0, over10cm = 0; + for (const auto& c : cs) { + const double d = c.dx.length(); + size.add(d); + replayed.add(c.replayed); + over1 += d > 1.0 ? 1 : 0; + over10cm += d > 0.1 ? 1 : 0; + snaps += c.snapped ? 1 : 0; + } + const double span = (cs.back().wall - cs.front().wall) / 60000.0; + std::println(" {:<10} {} corrections ({:.1f} a minute), median {:.4f}, p90 {:.4f}, p99 {:.4f}, " + "max {:.3f}; {} over 10 cm, {} over 1 m, {} snapped; steps replayed median {:.0f}, " + "max {:.0f}", + who(ms, m), cs.size(), span > 0 ? static_cast(cs.size()) / span : 0.0, + size.pct(0.5), size.pct(0.9), size.pct(0.99), size.pct(1.0), over10cm, over1, snaps, + replayed.pct(0.5), replayed.pct(1.0)); + } + return 0; +} + +} // namespace mm2::netprobe diff --git a/tools/netprobe/SyncReport.h b/tools/netprobe/SyncReport.h new file mode 100644 index 0000000..b02d335 --- /dev/null +++ b/tools/netprobe/SyncReport.h @@ -0,0 +1,21 @@ +#pragma once + +// netprobe syncreport: the divergence between machines in a network race, +// from each machine's OPENMM2_NET_TRACE (docs/multiplayer.md, "Diagnosing +// replication"; docs/review/multiplayer-desync-cars.md). + +#include +#include + +namespace mm2::netprobe { + +struct SyncReportOptions { + std::vector traces; // one per machine + double skipSeconds = 3.0; // of each trace's drawn cars, from the first (the start) + double teleportMetres = 1.0; // a frame's unexplained move that counts as a teleport +}; + +// Prints the report; returns 0, or 1 when a trace cannot be read. +int syncReport(const SyncReportOptions& options); + +} // namespace mm2::netprobe diff --git a/tools/netprobe/main.cpp b/tools/netprobe/main.cpp index b53fafd..163599e 100644 --- a/tools/netprobe/main.cpp +++ b/tools/netprobe/main.cpp @@ -7,8 +7,11 @@ // 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] +// netprobe syncreport ... [--skip S] [--teleport M] +// divergence between machines (OPENMM2_NET_TRACE files) // // --seconds 0 runs until Ctrl-C. Port mappings are removed on exit. +#include "SyncReport.h" #include "core/Log.h" #include "core/StringUtil.h" #include "net/Discovery.h" @@ -48,7 +51,8 @@ struct Args { Args parseArgs(int argc, char** argv, int first) { static const char* kValueOptions[] = {"--port", "--name", "--password", "--seconds", "--chat", - "--car", "--delay", "--jitter", "--loss", "--seed"}; + "--car", "--delay", "--jitter", "--loss", "--seed", + "--skip", "--teleport"}; Args a; for (int i = first; i < argc; ++i) { std::string arg = argv[i]; @@ -378,14 +382,15 @@ int cmdRelay(const Args& a) { } 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]\n" " relay [--port N] [--delay MS] [--jitter MS] [--loss PERCENT] [--reorder]\n" - " [--seed N] [--seconds N]"); + " [--seed N] [--seconds N]\n" + " syncreport ... [--skip S] [--teleport M]"); return 2; } @@ -415,5 +420,12 @@ int main(int argc, char** argv) { return cmdPortmap(args); if (cmd == "relay") return cmdRelay(args); + if (cmd == "syncreport") { + netprobe::SyncReportOptions o; + o.traces = args.positional; + o.skipSeconds = str::parseDouble(args.get("--skip")).value_or(o.skipSeconds); + o.teleportMetres = str::parseDouble(args.get("--teleport")).value_or(o.teleportMetres); + return netprobe::syncReport(o); + } return usage(); } From 48d1a70a99e5f95906bdd283d083b15a8b003ebb Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Fri, 9 Oct 2026 12:58:38 -0700 Subject: [PATCH 003/129] Let a car's simulation be saved, put back and run again on its own For the host-authoritative network cars: CarSim and Trailer save and restore everything a sample changes; World::replaySample runs one sample of a car (and its trailer) alone, against the city, the instances around it and the other movers held still, so a client can run its own car's samples again after the host corrected an earlier one without moving anything else; World gets hooks around every sample for the players' per-sample inputs; and a car can draw its wheels' bump numbers from its own stream, so the host and the car's own machine simulate it alike whatever else each one simulates (MM2 shares one rand() across the game). Single player sets none of these. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/phys/Joint.h | 4 + src/phys/World.cpp | 126 +++++++++++++++++ src/phys/World.h | 22 +++ src/phys/vehicle/CarSim.cpp | 66 ++++++++- src/phys/vehicle/CarSim.h | 52 ++++++++ src/phys/vehicle/Trailer.cpp | 41 +++++- src/phys/vehicle/Trailer.h | 22 +++ tests/phys/test_net_prediction.cpp | 208 +++++++++++++++++++++++++++++ 8 files changed, 539 insertions(+), 2 deletions(-) create mode 100644 tests/phys/test_net_prediction.cpp diff --git a/src/phys/Joint.h b/src/phys/Joint.h index a462c34..1cd3143 100644 --- a/src/phys/Joint.h +++ b/src/phys/Joint.h @@ -60,6 +60,10 @@ class Joint { Vec3 position; // the joint point (world) Vec3 initialPosition; // where Init put it (phJoint::Reset returns there) + // OpenMM2 (network prediction): the inverse mass matrix as the last + // update left it, saved and put back with the joint's other state. + void setInvMassMatrix(const Mat34& m) { m_invMassMatrix = m; } + protected: Mat34 m_invMassMatrix; }; diff --git a/src/phys/World.cpp b/src/phys/World.cpp index a131e13..6902308 100644 --- a/src/phys/World.cpp +++ b/src/phys/World.cpp @@ -522,6 +522,8 @@ void World::step(float dt) { return; if (m_stepObserver) m_stepObserver(); + if (m_beforeSample) + m_beforeSample(); const float invDt = 1.0f / dt; // datTimeManager::SetTempOverSampling: Seconds is the sample's length. sampleTime() = {dt, invDt}; @@ -618,6 +620,130 @@ void World::step(float dt) { m_stepping = false; std::erase_if(m_movers, [](const Mover& m) { return m.removed; }); m_time += dt; + if (m_afterSample) + m_afterSample(); +} + +void World::replaySample(std::span bodies, float dt) { + if (dt <= 0) + return; + const float invDt = 1.0f / dt; + sampleTime() = {dt, invDt}; + const bool stepping = m_stepping; + m_stepping = true; + const auto replayed = [&](const Body* b) { return std::ranges::find(bodies, b) != bodies.end(); }; + // The update, as step() runs it for these bodies. + for (Body* b : bodies) + if (b->updates && b->controller) + b->controller->beforeIntegrate(*b, dt, *this); + for (Body* b : bodies) { + if (!b->updates || b->kinematic) + continue; + b->ics.update(dt, invDt); + b->syncBoundMatrix(); + } + for (Body* b : bodies) { + if (!b->updates) + continue; + if (b->controller) + b->controller->afterIntegrate(*b, dt, *this); + if (m_level) + b->room = m_level->findRoom(b->position(), b->room); + b->collider.joint = b->joint; + b->collider.id = b->audioId; + } + // The collisions: the city, the replayed bodies among themselves, the + // other movers and the gathered instances, all held still. + for (std::size_t i = 0; i < bodies.size(); ++i) { + Body* a = bodies[i]; + if (!a->collisionBound) + continue; + if (a->collideTerrain) + collideTerrain(*a); + if (a->collideMovers) { + for (std::size_t j = i + 1; j < bodies.size(); ++j) { + Body* b = bodies[j]; + if (!b->collideMovers || !b->collisionBound) + continue; + if (a->joint && !a->joint->isBroken() && a->joint == b->joint) + continue; + if (trivialCollide(*a, *b)) + collideInstances(*a, *b); + } + for (const Mover& m : m_movers) { + Body* b = m.body; + if (!running(m) || m.fresh || !b || replayed(b) || !b->collideMovers || !b->collisionBound) + continue; + if (a->joint && !a->joint->isBroken() && a->joint == b->joint) + continue; + if (trivialCollide(*a, *b)) + collideHeld(*a, *b); + } + } + if (a->collideTerrain || a->collideInstances) { + Mover self; + self.body = a; + self.instance = a; + gatherCollidables(self); + for (Instance* c : self.collidables) + if (Body* e = c->entity(); !e || !replayed(e)) + collideHeld(*a, *c); + } + } + for (Body* b : bodies) + if (b->updates) + b->collider.updateMtx(); + for (Body* b : bodies) + if (b->updates && b->controller) + b->controller->afterCollisions(*b, dt, *this); + m_stepping = stepping; +} + +bool World::collideHeld(Body& a, Instance& b) { + // collideInstances with B as a static instance whatever it is: a + // temporary collider without a body, moving at B's own velocity, so + // that only A takes the impulse and the push; nothing is attached, no + // banger breaks loose and nothing is marked hit. + const Bound* boundA = a.bound(0); + const Bound* boundB = b.bound(0); + if (!boundA || !boundB || boundB->type == BoundType::ForceSphere) + return false; + m_tempMatrixB = b.matrix(); + const Vec3 relPos = m_tempMatrixB.m3 - a.matrix().m3; + Collider* colA = &a.collider; + // A body (moved by the simulation or from outside) as a kinematic one, + // a static instance as itself. + Body* entity = b.entity(); + if (entity) + m_tempB.init(boundB, &m_tempMatrixB, nullptr); + else + m_tempB.initStatic(boundB, &m_tempMatrixB); + m_tempB.id = b.audioId; + if (entity && !entity->kinematic) { + m_tempB.moving = true; + m_tempB.motionVelocity = entity->ics.linearVelocity; + m_tempB.motionSpin = entity->ics.angularVelocity; + m_tempB.motionCentre = entity->ics.matrix.m3; + } else { + m_tempB.moving = b.kinematicMotion(m_tempB.motionVelocity, m_tempB.motionSpin, m_tempB.motionCentre); + } + if (!colA->ics) + colA->moving = a.kinematicMotion(colA->motionVelocity, colA->motionSpin, colA->motionCentre); + const int n = testBoundGeneric(*boundA, *colA, *boundB, m_tempB, m_isectsA.data(), m_isectsB.data(), + m_impacts.data(), kMaxIntersections, kMaxImpacts, relPos); + if (n == 0) + return false; + std::span impacts(m_impacts.data(), static_cast(n)); + const float weight = 1.0f / static_cast(n); + if (b.isBanger() && !entity) { + const float limit2 = b.bangerImpulseLimit2(); + for (Impact& im : impacts) + calcBangerImpact(im, weight, limit2); + } else { + for (Impact& im : impacts) + calcImpact(im, weight); + } + return true; } bool World::trivialCollide(const Instance& a, const Instance& b) const { diff --git a/src/phys/World.h b/src/phys/World.h index b9123dd..e8642e4 100644 --- a/src/phys/World.h +++ b/src/phys/World.h @@ -18,6 +18,7 @@ #include #include #include +#include #include namespace mm2::phys { @@ -238,6 +239,24 @@ class World final : public GroundQuery { // anything moves, so that the drawing can keep each body's state from // before the sample (game::StepHistory). It must not change the world. void setStepObserver(std::function observer) { m_stepObserver = std::move(observer); } + // OpenMM2 (network races): `before` runs at the start of every sample, + // after the step observer and before anything moves (the players' cars + // take their inputs for that sample), `after` at its end (a client keeps + // its car's state). Unlike the observer they may change the bodies. + void setSampleHooks(std::function before, std::function after) { + m_beforeSample = std::move(before); + m_afterSample = std::move(after); + } + + // OpenMM2 (network prediction): one sample of `bodies` alone (a car and + // its trailer): their controllers, their integration and their + // collisions with the city, with the instances around them and with the + // world's other movers as they stand now. Nothing else moves or changes: + // what they hit holds still for them (it takes no impulse or push, is + // not knocked loose and is not marked as hit by the player) and meets + // them at its own velocity. A network client runs its own car's samples + // again with it once the host's state has corrected an earlier one. + void replaySample(std::span bodies, float dt); bool probe(const Vec3& a, const Vec3& b, RayHit& hit) const override; // dgPhysManager::Collide(segment, mask 0x20) as vehWheel::ComputeDwtdw @@ -312,6 +331,8 @@ class World final : public GroundQuery { bool collideProbe(const Segment& seg, Instance& inst, Intersection& hit, const Material*& material) const; void collideTerrain(Body& body); bool collideInstances(Instance& a, Instance& b); + // replaySample's collision of a body with something that holds still. + bool collideHeld(Body& a, Instance& b); mutable std::uint32_t m_randomSeed = 1; MaterialTable m_materials; @@ -323,6 +344,7 @@ class World final : public GroundQuery { float m_accumulator = 0; double m_time = 0; std::function m_stepObserver; + std::function m_beforeSample, m_afterSample; Stats m_stats; // dgPhysManager's static buffers and helper colliders: the level's diff --git a/src/phys/vehicle/CarSim.cpp b/src/phys/vehicle/CarSim.cpp index 67661e9..5656ec6 100644 --- a/src/phys/vehicle/CarSim.cpp +++ b/src/phys/vehicle/CarSim.cpp @@ -331,6 +331,70 @@ void CarSim::preUpdate() { } } +CarSimState CarSim::saveState() const { + CarSimState s; + s.ics = body.ics; + s.boundMatrix = body.boundMatrix; + s.collider = body.collider; + s.room = body.room; + s.wheels = wheels; + s.engine = engine; + s.trans = trans; + s.drivetrains = drivetrains; + s.axles = axles; + s.aero = aero; + s.gyro = gyro; + s.stuck = stuck; + s.splash = splash; + s.damage = damage; + s.brakes = brakes; + s.handBrake = handBrake; + s.steering = steering; + s.raceFinished = raceFinished; + s.drivable = drivable; + s.undrivableMode = undrivableMode; + s.speed = m_speed; + s.speedMph = m_speedMph; + s.resets = resets; + s.random = randomState; + s.resetPos = m_resetPos; + s.resetRotation = resetRotation; + s.waterLevel = m_waterLevel; + return s; +} + +void CarSim::restoreState(const CarSimState& s) { + body.ics = s.ics; + body.boundMatrix = s.boundMatrix; + body.collider = s.collider; + body.room = s.room; + wheels = s.wheels; + engine = s.engine; + trans = s.trans; + drivetrains = s.drivetrains; + axles = s.axles; + aero = s.aero; + gyro = s.gyro; + stuck = s.stuck; + splash = s.splash; + const CarDamageParams limits = damage.params; + damage = s.damage; + damage.params = limits; + brakes = s.brakes; + handBrake = s.handBrake; + steering = s.steering; + raceFinished = s.raceFinished; + drivable = s.drivable; + undrivableMode = s.undrivableMode; + m_speed = s.speed; + m_speedMph = s.speedMph; + resets = s.resets; + randomState = s.random; + m_resetPos = s.resetPos; + resetRotation = s.resetRotation; + m_waterLevel = s.waterLevel; +} + Mat34 CarSim::modelMatrix() const { // vehCarSim::SetWorldMatrix: the body matrix moved by R * CenterOfGravity // (summed in its order). @@ -403,7 +467,7 @@ WheelEnv CarSim::makeEnv(float dt, const World& world) { env.weatherFriction = options.weatherFriction; env.hasCar = true; env.carFrictionHandling = params.carFrictionHandling; - env.randomSeed = world.randomSeed(); + env.randomSeed = ownRandom ? &randomState : world.randomSeed(); env.self = &body; return env; } diff --git a/src/phys/vehicle/CarSim.h b/src/phys/vehicle/CarSim.h index db8e5ef..245db7d 100644 --- a/src/phys/vehicle/CarSim.h +++ b/src/phys/vehicle/CarSim.h @@ -128,6 +128,42 @@ struct CarSimOptions { bool polygonalBound = false; }; +// OpenMM2 (network prediction, docs/multiplayer.md "Players' cars"): +// everything a car's simulation changes from one sample to the next, so +// that a machine can put its car back where an earlier sample left it and +// run the samples after it again (CarSim::saveState / restoreState). It +// restores into the car it was saved from only: the parts keep their +// pointers to that car's wheels, engine and gearbox. +struct CarSimState { + InertialCS ics; + Mat34 boundMatrix; + Collider collider; + int room = 0; + std::array wheels; + Engine engine; + Transmission trans; + std::array drivetrains; + std::array axles; + Aero aero; + Gyro gyro; + Stuck stuck; + Splash splash; + CarDamage damage; // its params are not restored + float brakes = 0.0f; + float handBrake = 0.0f; + float steering = 0.0f; + bool raceFinished = false; + bool drivable = true; + int undrivableMode = 0; + float speed = 0.0f; + float speedMph = 0.0f; + std::uint32_t resets = 0; + std::uint32_t random = 1; + Vec3 resetPos; + float resetRotation = 0.0f; + std::optional waterLevel; +}; + // The turn about Y of a spawn transform built by Mat34::rotationY (the // session's starts, posts and checkpoints keep MM2's angle only in this // form): the reset rotation (vehCarSim +0x250) that faces the car the same @@ -262,6 +298,22 @@ class CarSim final : public BodyController, public ImpactHandler { // blended across a reset (game::StepHistory); the simulation never // reads it. std::uint32_t resets = 0; + // OpenMM2 (network prediction): the state after the last sample, and + // that state put back (CarSimState). + CarSimState saveState() const; + void restoreState(const CarSimState& state); + // OpenMM2 (network races): the wheels draw their bump numbers from the + // car's own stream (`randomState`) instead of the world's, which every + // simulated object shares (MM2: one rand() for the whole game). The + // host and the car's own machine then simulate the car alike whatever + // else each one simulates. + bool ownRandom = false; + std::uint32_t randomState = 1; + // The reset position as vehCarSim +0x210 holds it (CenterOfGravity + // added), set as it is (another machine's car, placed where that machine + // put it). + void setResetPosRaw(const Vec3& position) { m_resetPos = position; } + // vehCar's drivable flag (vehCar +0xe8 bit 2, vehCar::SetDrivable): the // game clears it while a car is held before the start. vehCar::Update // then runs neither vehStuck nor vehSplash. diff --git a/src/phys/vehicle/Trailer.cpp b/src/phys/vehicle/Trailer.cpp index be8fe01..fea8dce 100644 --- a/src/phys/vehicle/Trailer.cpp +++ b/src/phys/vehicle/Trailer.cpp @@ -179,6 +179,44 @@ void Trailer::reset() { body.collider.reset(); } +TrailerState Trailer::saveState() const { + TrailerState s; + s.ics = body.ics; + s.boundMatrix = body.boundMatrix; + s.collider = body.collider; + s.room = body.room; + s.drivetrains = drivetrains; + s.wheels = wheels; + s.status = joint.status; + s.lean = joint.lean; + s.leanRate = joint.leanRate; + s.roll = joint.roll; + s.rollRate = joint.rollRate; + s.jointForce = joint.jointForce; + s.gap = joint.gap; + s.position = joint.position; + s.invMassMatrix = joint.invMassMatrix(); + return s; +} + +void Trailer::restoreState(const TrailerState& s) { + body.ics = s.ics; + body.boundMatrix = s.boundMatrix; + body.collider = s.collider; + body.room = s.room; + drivetrains = s.drivetrains; + wheels = s.wheels; + joint.status = s.status; + joint.lean = s.lean; + joint.leanRate = s.leanRate; + joint.roll = s.roll; + joint.rollRate = s.rollRate; + joint.jointForce = s.jointForce; + joint.gap = s.gap; + joint.position = s.position; + joint.setInvMassMatrix(s.invMassMatrix); +} + void Trailer::addTo(World& world) { world.add(&body); } @@ -247,7 +285,8 @@ void Trailer::afterIntegrate(Body& b, float dt, const World& world) { env.invDt = 1.0f / dt; env.weatherFriction = m_tractor->options.weatherFriction; env.hasCar = false; - env.randomSeed = world.randomSeed(); + // (OpenMM2: a network player's car's own stream, see CarSim::ownRandom.) + env.randomSeed = m_tractor->ownRandom ? &m_tractor->randomState : world.randomSeed(); for (Drivetrain& d : drivetrains) d.update(env, m_tractor->params.mass); // dgTrailerJoint::Update first breaks a holding hitch on Ctrl+B (and diff --git a/src/phys/vehicle/Trailer.h b/src/phys/vehicle/Trailer.h index 589010a..2d53cca 100644 --- a/src/phys/vehicle/Trailer.h +++ b/src/phys/vehicle/Trailer.h @@ -49,6 +49,23 @@ struct TrailerOptions { bool mm2ForceRotation = true; }; +// OpenMM2 (network prediction): what a trailer's simulation changes from one +// sample to the next (see CarSimState). +struct TrailerState { + InertialCS ics; + Mat34 boundMatrix; + Collider collider; + int room = 0; + std::array drivetrains; + std::array wheels; + // The hitch (dgTrailerJoint): broken or not, its angles and rates, its + // force and gap, the joint point and the last inverse mass matrix. + int status = 0; + float lean = 0.0f, leanRate = 0.0f, roll = 0.0f, rollRate = 0.0f; + Vec3 jointForce, gap, position; + Mat34 invMassMatrix; +}; + // vehTrailer (Midtown Madness 2, verified against the build 3393 code): the // semi trailers of vpsemi and vpcentury. Its own rigid body, four wheels // each in a free drivetrain, the back wheels taking the tractor's inputs, @@ -97,6 +114,11 @@ class Trailer final : public BodyController { void beforeIntegrate(Body& body, float dt, const World& world) override; void afterIntegrate(Body& body, float dt, const World& world) override; + // OpenMM2 (network prediction): the state after the last sample, and + // that state put back. + TrailerState saveState() const; + void restoreState(const TrailerState& state); + // dgTrailerJoint::Update's debug key: in the frame Ctrl+B goes down // (ioKeyboard's state, global like it), every hitch still holding // breaks. The game sets it once a frame. diff --git a/tests/phys/test_net_prediction.cpp b/tests/phys/test_net_prediction.cpp new file mode 100644 index 0000000..9c82aeb --- /dev/null +++ b/tests/phys/test_net_prediction.cpp @@ -0,0 +1,208 @@ +// OpenMM2's network prediction support in the physics: a car's state saved +// and put back (CarSim::saveState / restoreState), a sample of one car on +// its own (World::replaySample), the sample hooks and the car's own random +// stream. See docs/multiplayer.md, "Players' cars". +#include "phys/Bound.h" +#include "phys/World.h" +#include "phys/vehicle/CarSim.h" +#include "phys/vehicle/Controls.h" + +#include + +#include +#include +#include + +using namespace mm2; +using namespace mm2::phys; + +namespace { + +// vpbug's wheels and body box (geometry/vpbug_whl*.mtx, bound/vpbug_bound.bnd). +VehicleGeometry bugGeometry() { + VehicleGeometry g; + const float x[4] = {-0.754f, 0.754f, -0.754f, 0.754f}; + const float z[4] = {-1.184f, -1.184f, 1.236f, 1.236f}; + for (int i = 0; i < 4; ++i) + g.wheels[static_cast(i)] = {{x[i], 0.307f, z[i]}, 0.336f, 0.255f, true}; + g.body = Aabb{{-0.92f, 0.245f, -1.98f}, {0.94f, 1.56f, 2.01f}}; + return g; +} + +PolygonSoup flatGround(float half = 2000.0f) { + SoupGeometry g; + g.vertices = {{-half, 0, -half}, {-half, 0, half}, {half, 0, half}, {half, 0, -half}}; + g.polys.push_back({{0, 1, 2, 3}, 4, 0}); + g.materialNames = {"default"}; + PolygonSoup soup; + soup.add(g, Mat34::identity(), MaterialTable{}); + soup.finalize(2048.0f); + return soup; +} + +std::unique_ptr makeCar(World& world, const Vec3& at) { + auto car = std::make_unique(); + car->init(CarSimParams{}, bugGeometry()); + Mat34 m = Mat34::identity(); + m.m3 = at; + car->reset(m); + world.add(&car->body); + return car; +} + +// The inputs of sample `i` of a test drive: throttle, then a turn, then the +// brakes. +PedalInput drive(int i) { + PedalInput in; + in.accelerator = i < 120 ? 1.0f : 0.6f; + in.steering = i >= 90 && i < 150 ? 0.4f : 0.0f; + in.brake = i >= 170 ? 0.8f : 0.0f; + return in; +} + +bool same(const Mat34& a, const Mat34& b) { return std::memcmp(&a, &b, sizeof(Mat34)) == 0; } +bool same(const Vec3& a, const Vec3& b) { return a.x == b.x && a.y == b.y && a.z == b.z; } + +void expectSameCar(const CarSim& a, const CarSim& b) { + EXPECT_TRUE(same(a.body.ics.matrix, b.body.ics.matrix)); + EXPECT_TRUE(same(a.body.ics.linearVelocity, b.body.ics.linearVelocity)); + EXPECT_TRUE(same(a.body.ics.angularVelocity, b.body.ics.angularVelocity)); + for (std::size_t w = 0; w < 4; ++w) { + EXPECT_EQ(a.wheels[w].rotationSpeed, b.wheels[w].rotationSpeed); + EXPECT_EQ(a.wheels[w].suspension, b.wheels[w].suspension); + } + EXPECT_EQ(a.engine.rpm, b.engine.rpm); + EXPECT_EQ(a.trans.currentGear, b.trans.currentGear); +} + +} // namespace + +TEST(NetPrediction, ARestoredCarRunsTheSameSamplesAgain) { + World world; + world.setStatic(flatGround()); + auto car = makeCar(world, {0, 0.6f, 0}); + ArcadeControls controls; + const float dt = kFixedSampleStep; + for (int i = 0; i < 60; ++i) { + controls.apply(*car, drive(i)); + world.step(dt); + } + const CarSimState saved = car->saveState(); + const ArcadeControls savedControls = controls; + for (int i = 60; i < 200; ++i) { + controls.apply(*car, drive(i)); + world.step(dt); + } + const CarSimState first = car->saveState(); + EXPECT_GT(car->body.ics.matrix.m3.dist(Vec3{0, 0.6f, 0}), 5.0f); // it drove + + car->restoreState(saved); + controls = savedControls; + for (int i = 60; i < 200; ++i) { + controls.apply(*car, drive(i)); + world.step(dt); + } + CarSim other; + other.init(CarSimParams{}, bugGeometry()); + other.restoreState(first); + expectSameCar(*car, other); +} + +TEST(NetPrediction, ReplayingACarAloneIsTheWorldsSample) { + // With nothing else around, replaySample is the world's own sample. + World world; + world.setStatic(flatGround()); + auto car = makeCar(world, {0, 0.6f, 0}); + ArcadeControls controls; + const float dt = kFixedSampleStep; + for (int i = 0; i < 30; ++i) { + controls.apply(*car, drive(i)); + world.step(dt); + } + const CarSimState saved = car->saveState(); + const ArcadeControls savedControls = controls; + for (int i = 30; i < 200; ++i) { + controls.apply(*car, drive(i)); + world.step(dt); + } + const CarSimState stepped = car->saveState(); + + car->restoreState(saved); + controls = savedControls; + Body* bodies[] = {&car->body}; + for (int i = 30; i < 200; ++i) { + controls.apply(*car, drive(i)); + world.replaySample(bodies, dt); + } + CarSim other; + other.init(CarSimParams{}, bugGeometry()); + other.restoreState(stepped); + expectSameCar(*car, other); +} + +TEST(NetPrediction, AReplayedCarPushesNothing) { + // A replayed car that runs into another one bounces off it, while the + // other one stays exactly where it was, at rest. + World world; + world.setStatic(flatGround()); + auto car = makeCar(world, {0, 0.6f, 0}); + auto parked = makeCar(world, {0, 0.6f, -8.0f}); + const float dt = kFixedSampleStep; + for (int i = 0; i < 30; ++i) + world.step(dt); // both settle + const CarSimState parkedBefore = parked->saveState(); + ArcadeControls controls; + Body* bodies[] = {&car->body}; + float slowest = 1e9f; + bool moving = false; + for (int i = 0; i < 240; ++i) { + controls.apply(*car, {1.0f, 0.0f, 0.0f, 0.0f}); + world.replaySample(bodies, dt); + const float v = -car->body.ics.linearVelocity.z; + moving = moving || v > 3.0f; + if (moving) + slowest = std::min(slowest, v); + } + EXPECT_TRUE(moving); + EXPECT_LT(slowest, 3.0f); // it hit the parked car + EXPECT_GT(car->body.ics.matrix.m3.z, -8.0f); + EXPECT_TRUE(same(parked->body.ics.matrix, parkedBefore.ics.matrix)); + EXPECT_TRUE(same(parked->body.ics.linearVelocity, parkedBefore.ics.linearVelocity)); +} + +TEST(NetPrediction, TheSampleHooksRunAroundEverySample) { + World world; + int before = 0, after = 0; + bool inOrder = true; + world.setSampleHooks([&] { inOrder = inOrder && before++ == after; }, + [&] { inOrder = inOrder && ++after == before; }); + const int n = world.advanceFixed(0.051f); + EXPECT_EQ(n, 3); + EXPECT_EQ(before, 3); + EXPECT_EQ(after, 3); + EXPECT_TRUE(inOrder); +} + +TEST(NetPrediction, ACarWithItsOwnStreamLeavesTheWorldsAlone) { + // Bumpy ground draws numbers for the wheels: with its own stream the car + // runs alike whatever else draws from the world's. + auto run = [](std::uint32_t worldSeed) { + World world; + world.setStatic(flatGround()); + world.seedRandom(worldSeed); + auto car = makeCar(world, {0, 0.6f, 0}); + car->ownRandom = true; + car->randomState = 7; + ArcadeControls controls; + for (int i = 0; i < 200; ++i) { + controls.apply(*car, drive(i)); + world.step(kFixedSampleStep); + } + return std::pair{car->saveState(), *world.randomSeed()}; + }; + const auto [a, seedA] = run(1); + const auto [b, seedB] = run(12345); + EXPECT_TRUE(same(a.ics.matrix, b.ics.matrix)); + EXPECT_EQ(seedA, 1u); + EXPECT_EQ(seedB, 12345u); +} From 2c8679c897b19e88e793b0843a412b447686b1a6 Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Fri, 9 Oct 2026 12:59:08 -0700 Subject: [PATCH 004/129] Show the shared traffic at the client car's time, predicted along its rails A client drew the host's traffic and police a trip and a playout delay in the past while its own car was in the present, and its car met them there: measured through a 60 ms (+-20) relay, a moving traffic car was 2.6 m (median, 3.7 m at the 90th percentile) from where the host had it at the same session time, 4.0 m over 150 ms. The host also stamped its messages with the frame's time while the cars were at the AI's last 30 Hz step, putting every car another 17 ms behind and making them surge. Now the host stamps a message with the AI step's time (the police and the knocked cars moved to it along their velocity) and sends, for a car on its rail, its acceleration and the heading's turn per metre measured from the AI's last two steps (protocol 5; 1 bit when both are 0). The client shows every car at the time its own car reaches in the frame's steps: a rail car predicted along an arc at its speed and acceleration (stopping at 0), a police car or a knocked car along its velocity turning with its yaw rate. Its collisions take the newest prediction; the drawing blends a newer message's correction away over 100 ms (above 4 m it jumps). The same relay: 0.02 m median, 0.06 m at the 90th percentile; 150 ms: 0.03 m and 0.14 m. MM2's network cruise has no traffic; MM2 predicts its network cars forward from the last packet (mmNetObject::PositionUpdate). OPENMM2_DEBUG_TRAFFIC_LEAD_MS shows the cars that much later, to measure the prediction over the longer horizon a host-simulated car will need. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/app/RaceScreen.cpp | 86 ++++++-- src/game/net/TrafficPrediction.cpp | 131 +++++++++++ src/game/net/TrafficPrediction.h | 82 +++++++ src/game/net/TrafficSync.cpp | 111 +++++++++- src/game/net/TrafficSync.h | 42 +++- src/game/net/TrafficTrace.cpp | 2 +- src/game/net/TrafficTrace.h | 2 +- src/net/AmbientState.h | 22 +- src/net/Protocol.h | 4 +- tests/game/test_traffic_prediction.cpp | 290 +++++++++++++++++++++++++ tests/net/test_ambient_state.cpp | 15 +- 11 files changed, 747 insertions(+), 40 deletions(-) create mode 100644 src/game/net/TrafficPrediction.cpp create mode 100644 src/game/net/TrafficPrediction.h create mode 100644 tests/game/test_traffic_prediction.cpp diff --git a/src/app/RaceScreen.cpp b/src/app/RaceScreen.cpp index 88bfff8..4f5909c 100644 --- a/src/app/RaceScreen.cpp +++ b/src/app/RaceScreen.cpp @@ -3925,14 +3925,23 @@ class RaceScreen final : public Screen { } } + // Host: the session time the AI's cars are at after this frame's update + // (its last fixed step), and the physics bodies' (the last simulation + // step). + double netAiStateTime(Context& ctx) const { + return ctx.netGame->frameTime() - + static_cast(m_ai->interpolationAlpha() * ai::kAiStepSeconds) * 1000.0; + } + double netBodyStateTime(Context& ctx) const { + const double lag = m_world ? static_cast(m_world->remainder()) * 1000.0 : 0.0; + return ctx.netGame->frameTime() - lag; + } + // OPENMM2_NET_TRACE on the host: the shared cars near any player, each at // the session time its state belongs to (the AI's last step, or the // physics step's for a body). void traceNetTraffic(Context& ctx, std::FILE* trace, std::span cars) { - const double frame = ctx.netGame->frameTime(); - const double aiTime = - frame - static_cast(m_ai->interpolationAlpha() * ai::kAiStepSeconds) * 1000.0; - const double bodyTime = frame - (m_world ? static_cast(m_world->remainder()) * 1000.0 : 0.0); + const double aiTime = netAiStateTime(ctx), bodyTime = netBodyStateTime(ctx); std::vector players; if (m_player) players.push_back(m_player->sim().modelMatrix().m3); @@ -3957,6 +3966,9 @@ class RaceScreen final : public Screen { for (const ai::AmbientCar& c : m_ai->cars()) if (c.horn) m_hornLatch.insert(c.id); + // How each car on its rail is moving, from the AI's last two steps: + // the clients predict it along its rail with that. + m_railMotion.update(m_ai->cars(), netAiStateTime(ctx)); const std::uint64_t now = net::monotonicMs(); std::FILE* trace = ctx.netGame->traceFile(); // OPENMM2_NET_TRACE: every frame const bool send = now - m_trafficSentAt >= kNetTrafficIntervalMs; @@ -3986,6 +3998,12 @@ class RaceScreen final : public Screen { } const bool horn = m_hornLatch.contains(c.id); cars.push_back(game::shareTrafficCar(c, model, paint, body ? &*body : nullptr, horn)); + game::SharedCar& shared = cars.back(); + shared.motion = m_railMotion.motion(c.id); + // A car the AI drives off its rail (avoiding a player, regaining + // its lane) turns as its heading does. + if (!body) + shared.angularVelocity.y = shared.motion.curvature * c.speed; } if (send) m_hornLatch.clear(); @@ -4043,7 +4061,18 @@ class RaceScreen final : public Screen { traceNetTraffic(ctx, trace, cars); if (!send) return; - const std::uint32_t time = ctx.netGame->sessionTime(); + // The message's time is the AI step's the rail cars are at (stamping + // the frame's time, up to a step and a frame later, put every car + // 17 ms behind on the clients on average and made them surge); the + // police and the knocked cars are moved to it along their velocity + // from the physics step's (at most a step either way). The light + // steps go with it. + const double aiTime = netAiStateTime(ctx); + const auto shift = static_cast((aiTime - netBodyStateTime(ctx)) / 1000.0); + for (game::SharedCar& c : cars) + if (c.body) + c.transform.m3 += c.velocity * shift; + const auto time = static_cast(std::llround(std::max(0.0, aiTime))); std::set viewers; for (const auto& rc : ctx.netGame->remoteCars()) { if (!rc.hasState) @@ -4150,25 +4179,24 @@ class RaceScreen final : public Screen { "wrong"); m_trafficCatalogWarned = true; } - // 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; + // The received cars are shown where they are at the time this + // machine's car reaches in this frame's steps (predicted beyond the + // newest message, game::TrafficPrediction): drawn a trip and a + // playout delay in the past, they were met 2-4 m from where the host + // had them (docs/review/multiplayer-desync-traffic.md). + const double frame = ctx.netGame->frameTime(); + const double own = netTrafficOwnTime(ctx, dt); + const double renderTime = netTrafficPresentTime(ctx, dt); m_trafficClient->update(renderTime); m_trafficRenderTime = renderTime; - if (std::FILE* trace = ctx.netGame->traceFile(); trace && m_player && m_world) { + if (std::FILE* trace = ctx.netGame->traceFile(); trace && m_player) { // Where the received cars meet this machine's car in this frame's // steps, and the session time that car reaches in them. - const double frame = ctx.netGame->frameTime(); - const double own = frame - static_cast(m_world->remainderAfter(dt)) * 1000.0; const Vec3 me = m_player->sim().modelMatrix().m3; game::traceClientView(trace, frame, own, me); for (const auto& c : m_trafficClient->cars()) if (c.transform.m3.dist2(me) < 9e4f) - game::traceClientCar(trace, frame, renderTime, c, c.extrapolated ? 1 : 0); + game::traceClientCar(trace, frame, renderTime, c, c.extrapolated ? 2 : 0); } if (const auto steps = m_trafficClient->lightSteps(renderTime)) m_ai->advanceLightsTo(*steps); @@ -4196,9 +4224,10 @@ class RaceScreen final : public Screen { std::erase_if(m_netTireRotation, [&](const auto& e) { return !seen.contains(e.first); }); if (m_trafficProxies) m_trafficProxies->update(m_netCars); - // Drawn where the rest of the scene is, a physics sample further back - // (game::StepHistory). - m_netDrawTime = renderTime - static_cast(phys::kFixedSampleStep) * 1000.0; + // Drawn where the rest of the scene is, a physics sample behind the + // frame (game::StepHistory), as far from the cars' time as the scene + // is from this machine's car's. + m_netDrawTime = renderTime - (own - (frame - static_cast(phys::kFixedSampleStep) * 1000.0)); m_netCarsDrawn = m_netCars; for (ai::AmbientCar& c : m_netCarsDrawn) if (const auto m = m_trafficClient->transformAt(c.id, m_netDrawTime)) @@ -4260,6 +4289,24 @@ class RaceScreen final : public Screen { } } + // A client: the session time this machine's car reaches in this frame's + // fixed steps (as sendLocalState stamps it). + double netTrafficOwnTime(Context& ctx, float dt) const { + const double lag = m_world ? static_cast(m_world->remainderAfter(dt)) * 1000.0 : 0.0; + return ctx.netGame->frameTime() - lag; + } + // ... and the time the received cars are shown at: that car's, so that it + // meets them where the host has them at the same moment. + // OPENMM2_DEBUG_TRAFFIC_LEAD_MS= shows them that much later + // (negative: earlier), for measuring the prediction over other horizons. + double netTrafficPresentTime(Context& ctx, float dt) const { + static const double lead = [] { + const char* v = std::getenv("OPENMM2_DEBUG_TRAFFIC_LEAD_MS"); + return v ? str::parseDouble(v).value_or(0.0) : 0.0; + }(); + return netTrafficOwnTime(ctx, dt) + lead; + } + // 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). @@ -5429,6 +5476,7 @@ class RaceScreen final : public Screen { bool m_debugNetDamage = std::getenv("OPENMM2_DEBUG_NETDAMAGE") != nullptr; std::unordered_set m_debugKnocked; std::uint32_t m_traceAiSteps = 0; // OPENMM2_NET_TRACE: the AI step last traced (host) + game::RailMotionTracker m_railMotion; // host: the rail cars' acceleration and curvature // Sound: the player's car, city ambience and rain (src/audio/game). std::unique_ptr m_bank; diff --git a/src/game/net/TrafficPrediction.cpp b/src/game/net/TrafficPrediction.cpp new file mode 100644 index 0000000..72c89d0 --- /dev/null +++ b/src/game/net/TrafficPrediction.cpp @@ -0,0 +1,131 @@ +// The shared traffic predicted forward on a client (OpenMM2 extra). See +// TrafficPrediction.h. +#include "game/net/TrafficPrediction.h" + +#include +#include + +namespace mm2::game { +namespace { + +// `v` turned by `angle` about the unit axis `k` (Rodrigues: a physical +// rotation, the right-hand way round the axis). +Vec3 rotated(const Vec3& v, const Vec3& k, float angle) { + const float c = std::cos(angle), s = std::sin(angle); + return v * c + k.cross(v) * s + k * (k.dot(v) * (1.0f - c)); +} + +// Turned by `angle` about +y (from +z toward +x). +Vec3 yawed(const Vec3& v, float angle) { + const float c = std::cos(angle), s = std::sin(angle); + return {v.x * c + v.z * s, v.y, v.z * c - v.x * s}; +} + +float wrapAngle(float a) { + while (a > kPi) + a -= kTwoPi; + while (a < -kPi) + a += kTwoPi; + return a; +} + +// The chord of an arc of length `s` that turns by `angle`: its length. +float chord(float s, float angle) { + return std::abs(angle) > 1e-4f ? s * (2.0f * std::sin(angle * 0.5f) / angle) : s; +} + +} // namespace + +float groundHeading(const Vec3& forward) { return std::atan2(forward.x, forward.z); } + +// --- RailMotionTracker -------------------------------------------------------------------- + +void RailMotionTracker::update(std::span cars, double time) { + for (auto& [id, last] : m_cars) + last.seen = false; + for (const ai::AmbientCar& c : cars) { + Last& last = m_cars[c.id]; + last.seen = true; + const Vec3 forward = -c.transform.m2; + const float heading = groundHeading(forward); + const Vec3& p = c.transform.m3; + if (last.spawns != c.spawns) { + last = {c.spawns, p, heading, c.speed, time, {}, true}; + continue; + } + const double dtMs = time - last.time; + if (dtMs <= 0.5) + continue; // the AI has not stepped since + const auto dt = static_cast(dtMs / 1000.0); + last.motion.accel = (c.speed - last.speed) / dt; + const float ds = std::hypot(p.x - last.position.x, p.z - last.position.z); + // Over a short move the heading's change is mostly rounding: the + // curvature stays as it was. + if (ds > 0.05f) + last.motion.curvature = std::clamp(wrapAngle(heading - last.heading) / ds, -2.0f, 2.0f); + last.position = p; + last.heading = heading; + last.speed = c.speed; + last.time = time; + } + std::erase_if(m_cars, [](const auto& e) { return !e.second.seen; }); +} + +RailMotion RailMotionTracker::motion(int id) const { + const auto it = m_cars.find(id); + return it == m_cars.end() ? RailMotion{} : it->second.motion; +} + +// --- Prediction --------------------------------------------------------------------------- + +PredictedPose predictRailCar(const Mat34& transform, float speed, const RailMotion& motion, float dt) { + PredictedPose out; + dt = std::max(dt, 0.0f); + // The distance its speed and acceleration give, stopping at 0 (or at + // the speed it had, for a car reversing). + float v = speed + motion.accel * dt; + float s = 0.0f; + if ((speed >= 0.0f && v < 0.0f) || (speed < 0.0f && v > 0.0f)) { + const float stop = motion.accel != 0.0f ? -speed / motion.accel : 0.0f; + s = speed * stop * 0.5f; + v = 0.0f; + } else { + s = (speed + v) * 0.5f * dt; + } + const float turn = motion.curvature * s; + const Vec3 forward = -transform.m2; + out.transform.m0 = yawed(transform.m0, turn); + out.transform.m1 = yawed(transform.m1, turn); + out.transform.m2 = yawed(transform.m2, turn); + // Along the arc: its chord, half way round. + out.transform.m3 = transform.m3 + yawed(forward, turn * 0.5f) * chord(s, turn); + out.speed = v; + out.velocity = -out.transform.m2 * v; + return out; +} + +PredictedPose predictBody(const Mat34& transform, const Vec3& velocity, const Vec3& spin, float dt) { + PredictedPose out; + dt = std::max(dt, 0.0f); + // The path turns with the yaw rate: an arc on the ground plane, straight + // up and down. + const float turn = spin.y * dt; + const Vec3 ground{velocity.x, 0.0f, velocity.z}; + const float s = ground.mag() * dt; + Vec3 move = s > 1e-6f ? yawed(ground * (1.0f / ground.mag()), turn * 0.5f) * chord(s, turn) : Vec3{}; + move.y = velocity.y * dt; + const float w = spin.mag(); + out.transform = transform; + if (w > 1e-6f) { + const Vec3 k = spin * (1.0f / w); + out.transform.m0 = rotated(transform.m0, k, w * dt); + out.transform.m1 = rotated(transform.m1, k, w * dt); + out.transform.m2 = rotated(transform.m2, k, w * dt); + } + out.transform.m3 = transform.m3 + move; + out.velocity = yawed(velocity, turn); + out.speed = -out.transform.m2.dot(out.velocity); + return out; +} + +} // namespace mm2::game diff --git a/src/game/net/TrafficPrediction.h b/src/game/net/TrafficPrediction.h new file mode 100644 index 0000000..a1a4393 --- /dev/null +++ b/src/game/net/TrafficPrediction.h @@ -0,0 +1,82 @@ +#pragma once + +// The shared traffic of a network cruise predicted forward on a client +// (OpenMM2 extra; docs/multiplayer.md "Shared traffic", docs/review/ +// multiplayer-desync-traffic.md). MM2's network cruise has no traffic, so +// there is nothing of MM2's to follow; MM2 does predict its network cars +// forward from their last packet (mmNetObject::PositionUpdate), which is +// what this does for the host's traffic and police. +// +// A client's own car is in the present (or, with the host simulating it, +// ahead of the host), while every message from the host is at least a trip +// old. Drawn at the messages' times, a traffic car was 2.6 m (median, at a +// 60 ms one-way trip) behind where the host had it when the client's car +// met it. The client therefore shows each car where it will be at its own +// car's time: +// * a car on its rail drives on along its heading at its speed and +// acceleration, turning by its rail's curvature per metre (ai::Traffic +// moves it along cubic curves whose heading changes smoothly, and its +// speed by an acceleration each step); +// * a police car or a knocked car (a physics body on the host) moves on at +// its velocity, turning with its yaw rate. +// Where a newer message shows the prediction was off, the drawing blends the +// difference away (TrafficClient); the collisions take the newest +// prediction at once. + +#include "ai/Traffic.h" +#include "core/Math.h" + +#include +#include + +namespace mm2::game { + +// How a car on its rail is moving: its speed's change and its heading's turn +// per metre (the sign of a spin about +y: from +z toward +x). +struct RailMotion { + float accel = 0.0f; // m/s^2 + float curvature = 0.0f; // rad/m +}; + +// Host: each AI car's acceleration and curvature from its last two AI steps +// (a backward difference: the AI changes both smoothly from step to step). +class RailMotionTracker { +public: + // After an AI update; `time` is the session time of its last step (ms). + // Cars whose time has not changed keep their values. + void update(std::span cars, double time); + RailMotion motion(int id) const; + void clear() { m_cars.clear(); } + +private: + struct Last { + int spawns = -1; + Vec3 position; + float heading = 0.0f; + float speed = 0.0f; + double time = 0.0; + RailMotion motion; + bool seen = false; + }; + std::unordered_map m_cars; +}; + +// The heading on the ground plane of a forward direction (atan2(x, z)). +float groundHeading(const Vec3& forward); + +struct PredictedPose { + Mat34 transform; // model origin + Vec3 velocity; + float speed = 0.0f; // along -m2 +}; + +// A car on its rail `dt` seconds after a state (`transform`, `speed`): +// along its heading (-m2, with its slope) for the distance its speed and +// acceleration give (stopping at 0: a rail car never reverses), its heading +// and its whole frame turned about the vertical by curvature x distance. +PredictedPose predictRailCar(const Mat34& transform, float speed, const RailMotion& motion, float dt); +// A physics body `dt` seconds after a state: along its velocity turned by +// its yaw rate (an arc), its frame turned by its spin. +PredictedPose predictBody(const Mat34& transform, const Vec3& velocity, const Vec3& spin, float dt); + +} // namespace mm2::game diff --git a/src/game/net/TrafficSync.cpp b/src/game/net/TrafficSync.cpp index 2c5cde8..6d5e63c 100644 --- a/src/game/net/TrafficSync.cpp +++ b/src/game/net/TrafficSync.cpp @@ -30,6 +30,14 @@ net::AmbientEntity toEntity(const SharedCar& c) { e.position = c.transform.m3; e.orientation = Quat::fromMatrix(c.transform); e.speed = c.speed; + // A rail car's acceleration and curvature, 0 where they would move it by + // a centimetre or so over half a second (or 5 m): those cost 1 bit. A + // car standing still turns nowhere. + if (std::abs(c.motion.accel) >= 0.1f) + e.accel = std::clamp(c.motion.accel, -net::kAmbientAccelRange, net::kAmbientAccelRange); + constexpr float kMaxCurvature = net::kAmbientCurvatureRange; + if (std::abs(c.motion.curvature) >= 0.001f && (c.speed != 0.0f || e.accel > 0.0f)) + e.curvature = std::clamp(c.motion.curvature, -kMaxCurvature, kMaxCurvature); e.velocity = c.velocity; e.angularVelocity = c.angularVelocity; e.flags = c.flags; @@ -177,6 +185,10 @@ void TrafficClient::restart(Entry& entry, const net::AmbientEntity& e, std::uint entry.goneAt.reset(); entry.horn = false; entry.shown = false; + entry.motion = {}; + entry.basis.reset(); + entry.offset = {}; + entry.offsetTurn = 0.0f; } void TrafficClient::receive(const net::AmbientStateMsg& msg) { @@ -207,7 +219,7 @@ void TrafficClient::receive(const net::AmbientStateMsg& msg) { 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) { + !std::isfinite(e.accel) || !std::isfinite(e.curvature) || !listed.insert(id).second) { ++m_stats.refused; continue; } @@ -263,6 +275,8 @@ void TrafficClient::receive(const net::AmbientStateMsg& msg) { entry.lastTime = msg.time; entry.target = e.target; entry.rpm = e.rpm; + // A rail car's (the others move on at their velocities). + entry.motion = e.fullMotion() ? RailMotion{} : RailMotion{e.accel, e.curvature}; } if (later(msg.time, entry.firstTime) < 0) entry.firstTime = msg.time; @@ -296,19 +310,64 @@ void TrafficClient::update(double renderTime) { ++it; if (renderTime < static_cast(entry.firstTime)) continue; // not on the host's screen yet at this time + // The drawing's correction fades (OpenMM2 presentation). + const double elapsed = std::max(0.0, renderTime - entry.updatedAt); + entry.updatedAt = renderTime; + if (m_options.correctionMs > 0.0) { + const auto keep = static_cast(std::exp(-elapsed / m_options.correctionMs)); + entry.offset = entry.offset * keep; + entry.offsetTurn *= keep; + } net::VehicleSnapshot s; - const auto result = entry.buffer.sample(renderTime, s, m_options.maxExtrapolationMs); - if (result == net::SnapshotBuffer::Result::Empty) - continue; + const net::VehicleSnapshot& newest = *entry.buffer.latest(); + PredictedPose predicted; + const bool ahead = predict(entry, newest, entry.motion, renderTime, predicted); + auto result = net::SnapshotBuffer::Result::Interpolated; + if (ahead) { + // A newer basis: the drawing stays where it was and blends the + // difference away (beyond the snap distance it jumps). + PredictedPose before; + if (entry.shown && entry.basis && entry.basis->time != newest.time && + predict(entry, *entry.basis, entry.basisMotion, renderTime, before)) { + const Vec3 offset = before.transform.m3 + entry.offset - predicted.transform.m3; + const float turn = std::remainder(groundHeading(-before.transform.m2) + entry.offsetTurn - + groundHeading(-predicted.transform.m2), + kTwoPi); + if (offset.mag() > m_options.snapDistance || std::abs(turn) > m_options.snapTurn) { + ++m_stats.snaps; + entry.offset = {}; + entry.offsetTurn = 0.0f; + } else { + entry.offset = offset; + entry.offsetTurn = turn; + } + } + entry.basis = newest; + entry.basisMotion = entry.motion; + s = newest; + result = net::SnapshotBuffer::Result::Extrapolated; + } else { + result = entry.buffer.sample(renderTime, s, 0.0); + if (result == net::SnapshotBuffer::Result::Empty) + continue; + entry.offset = {}; + entry.offsetTurn = 0.0f; + } 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; + if (ahead) { + c.transform = predicted.transform; + c.velocity = predicted.velocity; + c.angularVelocity = s.angularVelocity; + } else { + 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; @@ -457,12 +516,48 @@ void trafficWheelMatrices(const Mat34& transform, const std::array& off } } +bool TrafficClient::predict(const Entry& entry, const net::VehicleSnapshot& state, const RailMotion& motion, + double time, PredictedPose& out) const { + const double ahead = time - static_cast(state.time); + if (ahead <= 0.0) + return false; + const Mat34 m = state.orientation.normalized().toMatrix(state.position); + // A car on its rail goes on along it; a body (a police car, a car off + // its rail) at its velocity, for a shorter while. + const bool rail = entry.kind == net::AmbientKind::Traffic && (state.flags & net::kAmbientOffRail) == 0; + if (rail) { + const auto dt = static_cast(std::min(ahead, m_options.maxRailPredictionMs) / 1000.0); + out = predictRailCar(m, -m.m2.dot(state.linearVelocity), motion, dt); + } else { + const auto dt = static_cast(std::min(ahead, m_options.maxBodyPredictionMs) / 1000.0); + out = predictBody(m, state.linearVelocity, state.angularVelocity, dt); + } + return true; +} + std::optional TrafficClient::transformAt(int id, double time) const { const auto it = m_entries.find(id); if (it == m_entries.end()) return std::nullopt; + const Entry& entry = it->second; + const net::VehicleSnapshot* newest = entry.buffer.latest(); + if (!newest) + return std::nullopt; + PredictedPose p; + if (predict(entry, *newest, entry.motion, time, p)) { + // Drawn with the correction still being blended away. + Mat34 m = p.transform; + if (entry.offsetTurn != 0.0f) { + const Mat34 turn = Mat34::rotationY(entry.offsetTurn); // +y, from +z toward +x + m.m0 = turn.transformDir(m.m0); + m.m1 = turn.transformDir(m.m1); + m.m2 = turn.transformDir(m.m2); + } + m.m3 += entry.offset; + return m; + } net::VehicleSnapshot s; - if (it->second.buffer.sample(time, s, m_options.maxExtrapolationMs) == net::SnapshotBuffer::Result::Empty) + if (entry.buffer.sample(time, s, 0.0) == net::SnapshotBuffer::Result::Empty) return std::nullopt; return s.orientation.normalized().toMatrix(s.position); } diff --git a/src/game/net/TrafficSync.h b/src/game/net/TrafficSync.h index ae8e685..70974b3 100644 --- a/src/game/net/TrafficSync.h +++ b/src/game/net/TrafficSync.h @@ -19,6 +19,7 @@ #include "ai/Traffic.h" #include "core/Math.h" +#include "game/net/TrafficPrediction.h" #include "net/AmbientState.h" #include "net/Snapshot.h" @@ -64,6 +65,7 @@ struct SharedCar { int paint = 0; // paint job Mat34 transform; // model origin float speed = 0.0f; // along -m2 (rail cars) + RailMotion motion; // rail cars: acceleration and curvature (RailMotionTracker) Vec3 velocity; Vec3 angularVelocity; std::uint8_t flags = 0; // net::AmbientFlags @@ -131,10 +133,19 @@ class TrafficHost { 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. + // The least delay a car is shown at when it is interpolated (as the + // remote players, net::SessionConfig). double interpolationDelayMs = 100.0; - double maxExtrapolationMs = 250.0; + // Beyond its newest message a car is predicted (TrafficPrediction.h) + // at most this far, then held: a car on its rail, and a body. + double maxRailPredictionMs = 1000.0; + double maxBodyPredictionMs = 500.0; + // A predicted car's drawing blends a newer message's correction away + // with this time constant; a correction beyond snapDistance (or + // snapTurn radians) is shown at once. + double correctionMs = 100.0; + float snapDistance = 4.0f; + float snapTurn = 1.0f; // 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; @@ -150,8 +161,10 @@ class TrafficClient { // 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. + // The cars at `renderTime` (session ms): interpolated between the + // host's messages while it is behind the newest, predicted beyond it + // (normally: the time this machine's car is at); cars that have left by + // then are dropped. void update(double renderTime); struct Car { @@ -174,7 +187,7 @@ class TrafficClient { float rpm = 0.0f; float throttle = 0.0f; int gear = 0; - bool extrapolated = false; // beyond the newest message (late or lost packets) + bool extrapolated = false; // beyond the newest message (predicted) bool hornStarted = false; // the horn sounded since the previous update bool fresh = false; // shown for the first time (or put somewhere new) this update }; @@ -184,7 +197,8 @@ class TrafficClient { // OpenMM2 presentation: car `id` (one update() listed) as it was at // `time`, up to kDrawBehindMs before update()'s render time: the drawing // shows the shared cars a physics step further back, with everything - // else (game::StepHistory). Before the car's first state, that state. + // else (game::StepHistory). Before the car's first state, that state. A + // predicted car is drawn with the correction still being blended away. std::optional transformAt(int id, double time) const; static constexpr double kDrawBehindMs = 100.0; // The host's catalog differs from this client's. @@ -198,6 +212,7 @@ class TrafficClient { 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; + std::uint64_t snaps = 0; // predicted cars whose correction was shown at once }; const Stats& stats() const { return m_stats; } const Options& options() const { return m_options; } @@ -221,10 +236,23 @@ class TrafficClient { std::optional goneAt; // missing from a newer message: gone from this time on std::uint8_t target = net::kAmbientNoTarget; float rpm = 0.0f; + RailMotion motion; // the newest state's bool horn = false; // the horn flag as last shown bool shown = false; // shown since its last (re)start + // The prediction's last basis (the newest state then) and the + // drawing's correction: where it was drawn less where the newest + // prediction puts it, blended away. + std::optional basis; + RailMotion basisMotion; + Vec3 offset; + float offsetTurn = 0.0f; + double updatedAt = 0.0; }; void restart(Entry& entry, const net::AmbientEntity& e, std::uint32_t time); + // The car predicted from `state` (its newest) to `time`; false when + // `time` is not beyond it. + bool predict(const Entry& entry, const net::VehicleSnapshot& state, const RailMotion& motion, double time, + PredictedPose& out) const; Options m_options; std::size_t m_catalogSize = 0; diff --git a/src/game/net/TrafficTrace.cpp b/src/game/net/TrafficTrace.cpp index 382978a..98f2b92 100644 --- a/src/game/net/TrafficTrace.cpp +++ b/src/game/net/TrafficTrace.cpp @@ -20,7 +20,7 @@ void traceHostCar(std::FILE* f, double time, const SharedCar& car) { std::fprintf(f, "TH %.3f %d %d %d", time, car.id, car.generation % static_cast(net::kAmbientGenerations), static_cast(car.kind)); traceCar(f, car.transform, car.speed, car.flags, car.target); - std::fputc('\n', f); + std::fprintf(f, " %.3f %.3f %.3f\n", car.velocity.x, car.velocity.y, car.velocity.z); } void traceClientCar(std::FILE* f, double frame, double time, const TrafficClient::Car& car, int mode) { diff --git a/src/game/net/TrafficTrace.h b/src/game/net/TrafficTrace.h index e12dde3..a5389da 100644 --- a/src/game/net/TrafficTrace.h +++ b/src/game/net/TrafficTrace.h @@ -6,7 +6,7 @@ // multiplayer.md, "Diagnosing replication"). `mm2tool nettrace` compares a // host's trace with its clients' (docs/review/multiplayer-desync-traffic.md). // -// TH time id gen kind x y z fx fz speed flags target +// TH time id gen kind x y z fx fz speed flags target vx vy vz // host: a car as its simulation has it at session time `time` (its own: // the AI step's for a car on its rail, the physics step's for a body) // TC frame time id gen kind x y z fx fz speed flags target mode diff --git a/src/net/AmbientState.h b/src/net/AmbientState.h index 21c61fe..9491728 100644 --- a/src/net/AmbientState.h +++ b/src/net/AmbientState.h @@ -16,8 +16,10 @@ // 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. A car +// so only its speed is sent, with how it is changing (its acceleration and +// its rail's curvature, which let a client predict it along its rail); a +// car off its rail (knocked loose, a wreck) 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. @@ -39,6 +41,8 @@ inline constexpr std::size_t kMaxAmbientPerMessage = 96; 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 kAmbientAccelRange = 16.0f; // rail cars, m/s^2 +inline constexpr float kAmbientCurvatureRange = 0.5f; // rail cars, rad/m (a 2 m radius) 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; @@ -74,6 +78,10 @@ struct AmbientEntity { Vec3 position; // model origin, world space Quat orientation; float speed = 0.0f; // rail cars: along the car's forward axis (-m2) + // Rail cars: the speed's change (m/s^2) and the heading's turn per metre + // driven (rad/m, positive turning from +z toward +x: a spin about +y). + float accel = 0.0f; + float curvature = 0.0f; Vec3 velocity; // off-rail cars and police Vec3 angularVelocity; // off-rail cars and police std::uint8_t flags = 0; // AmbientFlags @@ -139,6 +147,16 @@ bool serializeAmbientEntity(S& s, AmbientEntity& e, const Vec3& origin) { e.speed = -(e.orientation.toMatrix().m2.dot(e.velocity)); } else { s.quantized(e.speed, -kAmbientSpeedRange, kAmbientSpeedRange, 11); + // Its acceleration and curvature, unless both are 0 (most cars stand + // or drive straight on at their speed: 1 bit). + bool changing = e.accel != 0.0f || e.curvature != 0.0f; + s.boolean(changing); + if (changing) { + s.quantized(e.accel, -kAmbientAccelRange, kAmbientAccelRange, 7); + s.quantized(e.curvature, -kAmbientCurvatureRange, kAmbientCurvatureRange, 9); + } else if constexpr (S::kReading) { + e.accel = e.curvature = 0.0f; + } if constexpr (S::kReading) { // ai::Traffic's published velocity of a rail car. e.velocity = -e.orientation.toMatrix().m2 * e.speed; diff --git a/src/net/Protocol.h b/src/net/Protocol.h index 8d0fbed..5fb0b85 100644 --- a/src/net/Protocol.h +++ b/src/net/Protocol.h @@ -30,7 +30,9 @@ inline constexpr std::uint16_t kProtocolMagic = 0x4D32; // "M2" // RaceStart; the race in Welcome). // 4: the cars' damage (net/VehicleDamage.h events; in AmbientState the // police's damage at 10 bits and the knocked traffic cars' wheels). -inline constexpr std::uint16_t kProtocolVersion = 4; +// 5: in AmbientState a rail car's acceleration and curvature (the clients +// predict the shared traffic to the present). +inline constexpr std::uint16_t kProtocolVersion = 5; inline constexpr std::uint32_t kConnectData = (std::uint32_t{kProtocolMagic} << 16) | kProtocolVersion; // ENet channels. diff --git a/tests/game/test_traffic_prediction.cpp b/tests/game/test_traffic_prediction.cpp new file mode 100644 index 0000000..4ce5f0f --- /dev/null +++ b/tests/game/test_traffic_prediction.cpp @@ -0,0 +1,290 @@ +// The shared traffic of a network cruise predicted to the present on a +// client (game/net/TrafficPrediction, TrafficClient): the host's rail motion +// measured from its AI steps, the prediction along a rail, and a host and a +// client over a simulated Internet link (docs/review/ +// multiplayer-desync-traffic.md reproduces its numbers in the game). +#include "game/net/TrafficPrediction.h" +#include "game/net/TrafficSync.h" + +#include + +#include +#include +#include +#include +#include +#include +#include + +using namespace mm2; +using game::PredictedPose; +using game::RailMotion; +using game::SharedCar; +using game::TrafficClient; +using game::TrafficHost; + +namespace { + + +constexpr double kAiStepMs = 1000.0 / 30.0; + +// A model matrix facing heading `h` (forward -m2 = (sin h, 0, cos h)). +Mat34 facing(float h, const Vec3& p) { + Mat34 m = Mat34::rotationY(h + kPi); + m.m3 = p; + return m; +} + +// A car's true path: speed against time, curvature against distance, +// integrated in 1 ms steps. +struct Path { + struct State { + Vec3 position; + float heading = 0.0f; + float speed = 0.0f; + }; + std::vector states; // one per ms + + Path(Vec3 start, float heading, double ms, const std::function& speed, + const std::function& curvature) { + State s{start, heading, speed(0.0)}; + double distance = 0.0; + for (int i = 0; i <= static_cast(ms); ++i) { + s.speed = speed(i); + states.push_back(s); + const float ds = s.speed * 0.001f; + const float turn = curvature(distance) * ds; + const float mid = s.heading + turn * 0.5f; + s.position = s.position + Vec3{std::sin(mid), 0.0f, std::cos(mid)} * ds; + s.heading += turn; + distance += ds; + } + } + State at(double ms) const { + const auto i = static_cast(std::clamp(ms, 0.0, static_cast(states.size() - 2))); + const double f = std::clamp(ms - static_cast(i), 0.0, 1.0); + const State& a = states[i]; + const State& b = states[i + 1]; + const auto t = static_cast(f); + return {a.position + (b.position - a.position) * t, a.heading + (b.heading - a.heading) * t, + a.speed + (b.speed - a.speed) * t}; + } +}; + +ai::AmbientCar aiCar(int id, const Path::State& s) { + ai::AmbientCar c; + c.id = id; + c.transform = facing(s.heading, s.position); + c.speed = s.speed; + c.velocity = -c.transform.m2 * s.speed; + return c; +} + +float percentile(std::vector v, double q) { + if (v.empty()) + return 0.0f; + std::ranges::sort(v); + return v[std::min(v.size() - 1, static_cast(static_cast(v.size()) * q))]; +} + +} // namespace + +TEST(TrafficPrediction, ARailCarFollowsItsArc) { + // 10 m/s on a 20 m radius, turning from +z toward +x. + const float speed = 10.0f, radius = 20.0f; + const RailMotion motion{0.0f, 1.0f / radius}; + const Mat34 start = facing(0.0f, {0, 5, 0}); + for (float dt : {0.1f, 0.3f, 0.6f}) { + const PredictedPose p = game::predictRailCar(start, speed, motion, dt); + const float turn = speed * dt / radius; + const Vec3 truth{radius * (1.0f - std::cos(turn)), 5.0f, radius * std::sin(turn)}; + EXPECT_NEAR(p.transform.m3.dist(truth), 0.0f, 1e-3f) << dt; + EXPECT_NEAR(game::groundHeading(-p.transform.m2), turn, 1e-4f) << dt; + EXPECT_NEAR(p.speed, speed, 1e-4f); + EXPECT_NEAR(p.velocity.dist(-p.transform.m2 * speed), 0.0f, 1e-4f); + // The frame stays a rotation. + EXPECT_NEAR(p.transform.m0.dot(p.transform.m2), 0.0f, 1e-5f); + EXPECT_NEAR(p.transform.m1.y, 1.0f, 1e-5f); + } +} + +TEST(TrafficPrediction, ABrakingRailCarStopsAndNeverReverses) { + const Mat34 start = facing(kPi * 0.5f, {0, 0, 0}); // along +x + const PredictedPose p = game::predictRailCar(start, 10.0f, {-8.0f, 0.0f}, 3.0f); + EXPECT_NEAR(p.transform.m3.x, 6.25f, 1e-3f); // v^2 / 2a + EXPECT_NEAR(p.transform.m3.z, 0.0f, 1e-4f); + EXPECT_EQ(p.speed, 0.0f); + // Starting from a standstill: half a t^2. + const PredictedPose q = game::predictRailCar(start, 0.0f, {4.0f, 0.0f}, 0.5f); + EXPECT_NEAR(q.transform.m3.x, 0.5f, 1e-4f); + EXPECT_NEAR(q.speed, 2.0f, 1e-5f); +} + +TEST(TrafficPrediction, ABodyTurnsWithItsYawRate) { + // A police car at 20 m/s turning at 0.5 rad/s: a 40 m radius. + const Mat34 start = facing(0.0f, {0, 0, 0}); + const Vec3 v = -start.m2 * 20.0f; + const PredictedPose p = game::predictBody(start, v, {0, 0.5f, 0}, 0.4f); + const float turn = 0.2f; + const Vec3 truth{40.0f * (1.0f - std::cos(turn)), 0, 40.0f * std::sin(turn)}; + EXPECT_NEAR(p.transform.m3.dist(truth), 0.0f, 1e-3f); + EXPECT_NEAR(game::groundHeading(-p.transform.m2), turn, 1e-4f); + EXPECT_NEAR(game::groundHeading(p.velocity), turn, 1e-4f); +} + +TEST(TrafficPrediction, TheHostMeasuresTheRailMotionFromItsSteps) { + const auto speed = [](double ms) { return static_cast(5.0 + 2.0 * ms / 1000.0); }; + const Path path({0, 0, 0}, 0.0f, 3000.0, speed, [](double) { return 0.05f; }); + game::RailMotionTracker tracker; + for (int step = 0; step < 60; ++step) { + const double t = step * kAiStepMs; + const std::vector cars{aiCar(3, path.at(t))}; + tracker.update(cars, t); + tracker.update(cars, t); // a frame without an AI step changes nothing + } + const RailMotion m = tracker.motion(3); + EXPECT_NEAR(m.accel, 2.0f, 0.01f); + EXPECT_NEAR(m.curvature, 0.05f, 0.001f); + EXPECT_EQ(tracker.motion(4).accel, 0.0f); + // A recycled slot starts again. + ai::AmbientCar again = aiCar(3, path.at(0.0)); + again.spawns = 1; + tracker.update(std::vector{again}, 3000.0); + EXPECT_EQ(tracker.motion(3).curvature, 0.0f); +} + +// The correction a newer message brings is blended away in the drawing; the +// physics take the newest prediction at once, and a big one is shown at once. +TEST(TrafficPrediction, CorrectionsAreBlendedInTheDrawingOnly) { + TrafficHost host; + TrafficClient client(8, 0x1234); + auto send = [&](std::uint32_t time, float z) { + SharedCar c; + c.id = 5; + c.model = 1; + c.transform = facing(kPi, {0, 0, z}); // along -z + c.speed = 10.0f; + c.velocity = -c.transform.m2 * 10.0f; + const std::vector list{c}; + net::AmbientStateMsg out; + const auto msg = host.build({1, {0, 0, 0}}, list, time, 0, 0x1234); + ASSERT_TRUE(net::decodeMessage(net::encodeMessage(msg), out)); + client.receive(out); + }; + send(1000, 0.0f); + client.update(1100.0); + ASSERT_EQ(client.cars().size(), 1u); + // (Positions travel at 3 cm, speeds at 6 cm/s.) + EXPECT_NEAR(client.cars()[0].transform.m3.z, -1.0f, 0.03f); // predicted 100 ms on + // The car was half a metre further back than predicted. + send(1050, 0.0f); + client.update(1110.0); + EXPECT_NEAR(client.cars()[0].transform.m3.z, -0.6f, 0.03f); // the physics: at once + const auto drawn = client.transformAt(5, 1110.0); + ASSERT_TRUE(drawn); + EXPECT_NEAR(drawn->m3.z, -1.1f, 0.05f); // the drawing: where it was going + client.update(1410.0); + const auto later = client.transformAt(5, 1410.0); + ASSERT_TRUE(later); + EXPECT_NEAR(later->m3.z, client.cars()[0].transform.m3.z, 0.03f); // blended away + // Ten metres off: shown there at once. + send(1400, 10.0f); + client.update(1420.0); + const auto snapped = client.transformAt(5, 1420.0); + ASSERT_TRUE(snapped); + EXPECT_NEAR(snapped->m3.z, client.cars()[0].transform.m3.z, 1e-3f); + EXPECT_EQ(client.stats().snaps, 1u); +} + +// A host and a client over a simulated Internet link (60 +- 20 ms each way, +// 2 % loss, reordering): the client's cars against the host's at the same +// moment, shown at the present (predicted) and, as before, a playout delay +// in the past (interpolated). +TEST(TrafficPrediction, OverALossyLinkTheClientSeesTheHostsPresent) { + const double kMs = 20000.0; + std::vector paths; + // Round a 25 m bend at 10 m/s. + paths.emplace_back(Vec3{0, 0, 0}, 0.0f, kMs, [](double) { return 10.0f; }, [](double) { return 0.04f; }); + // Stop and go: 12 m/s, braking at 4 m/s^2, 2 s still, away at 3 m/s^2. + paths.emplace_back(Vec3{50, 0, 0}, kPi * 0.5f, kMs, + [](double ms) { + const double t = std::fmod(ms / 1000.0, 10.0); + if (t < 2.0) + return 12.0f; + if (t < 5.0) + return static_cast(std::max(0.0, 12.0 - 4.0 * (t - 2.0))); + if (t < 7.0) + return 0.0f; + return static_cast(std::min(12.0, 3.0 * (t - 7.0))); + }, + [](double) { return 0.0f; }); + // Straight, then a right turn of 12 m radius at a junction, every 60 m. + const auto junctions = [](double s) { + const double along = std::fmod(s, 60.0); + return along > 40.0 && along < 58.85 ? 1.0f / 12.0f : 0.0f; + }; + paths.emplace_back(Vec3{-50, 0, 0}, 0.0f, kMs, [](double) { return 8.0f; }, junctions); + + TrafficHost host; + TrafficClient client(8, 0x1234); + TrafficClient delayed(8, 0x1234); + game::RailMotionTracker tracker; + std::mt19937 rng(7); + std::uniform_real_distribution jitter(-20.0, 20.0); + std::uniform_real_distribution unit(0.0, 1.0); + std::multimap link; // arrival -> message + double lastAi = -1.0; + std::vector present, past, drawnErr; + for (double now = 0.0; now < kMs; now += 1000.0 / 120.0) { + // Host: its AI at 30 Hz, a message every 50 ms stamped with the AI step. + const double aiTime = std::floor(now / kAiStepMs) * kAiStepMs; + if (aiTime != lastAi) { + std::vector cars; + for (std::size_t i = 0; i < paths.size(); ++i) + cars.push_back(aiCar(static_cast(i), paths[i].at(aiTime))); + tracker.update(cars, aiTime); + if (std::floor(aiTime / 50.0) != std::floor(lastAi / 50.0)) { + std::vector shared; + for (const auto& c : cars) { + SharedCar s = game::shareTrafficCar(c, 1, 0, nullptr, false); + s.motion = tracker.motion(c.id); + shared.push_back(s); + } + const auto stamp = static_cast(std::llround(aiTime)); + const auto msg = host.build({1, {0, 0, 0}}, shared, stamp, 0, 0x1234); + net::AmbientStateMsg out; + ASSERT_TRUE(net::decodeMessage(net::encodeMessage(msg), out)); + if (unit(rng) >= 0.02) + link.emplace(now + 60.0 + jitter(rng), out); + } + lastAi = aiTime; + } + // Client: what has arrived, the cars at the present. + while (!link.empty() && link.begin()->first <= now) { + client.receive(link.begin()->second); + delayed.receive(link.begin()->second); + link.erase(link.begin()); + } + client.update(now); + delayed.update(now - 160.0); // the host car's playout delay over such a link + if (now < 1000.0) + continue; + for (const auto& c : client.cars()) { + const Vec3 truth = paths[static_cast(c.id)].at(now).position; + present.push_back(c.transform.m3.dist(truth)); + if (const auto d = client.transformAt(c.id, now)) + drawnErr.push_back(d->m3.dist(truth)); + } + for (const auto& c : delayed.cars()) + past.push_back(c.transform.m3.dist(paths[static_cast(c.id)].at(now).position)); + } + std::printf("[ measure ] same-moment error, m: predicted median %.3f 90%% %.3f 99%% %.3f max %.3f; " + "drawn median %.3f 99%% %.3f; interpolated 160 ms back median %.3f 99%% %.3f\n", + percentile(present, 0.5), percentile(present, 0.9), percentile(present, 0.99), + percentile(present, 1.0), percentile(drawnErr, 0.5), percentile(drawnErr, 0.99), + percentile(past, 0.5), percentile(past, 0.99)); + EXPECT_LT(percentile(present, 0.5), 0.05f); + EXPECT_LT(percentile(present, 0.99), 0.6f); + EXPECT_LT(percentile(drawnErr, 0.99), 0.8f); + EXPECT_GT(percentile(past, 0.5), 1.0f); +} diff --git a/tests/net/test_ambient_state.cpp b/tests/net/test_ambient_state.cpp index 3072c34..d860c48 100644 --- a/tests/net/test_ambient_state.cpp +++ b/tests/net/test_ambient_state.cpp @@ -57,6 +57,8 @@ AmbientStateMsg sampleMessage() { 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.back().accel = -3.5f; + m.entities.back().curvature = 0.08f; 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; @@ -103,6 +105,8 @@ TEST(AmbientState, RoundTripKeepsEveryField) { EXPECT_LT(rail.velocity.dist(expected), 0.1f); EXPECT_EQ(rail.flags, kAmbientBrake | kAmbientSignalLeft); EXPECT_EQ(rail.target, kAmbientNoTarget); + EXPECT_NEAR(rail.accel, -3.5f, 0.13f); + EXPECT_NEAR(rail.curvature, 0.08f, 0.001f); const AmbientEntity& cop = out.entities[1]; EXPECT_EQ(cop.id, 301); @@ -132,6 +136,8 @@ TEST(AmbientState, OutOfRangeValuesAreClampedNotTrusted) { 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}; + e.accel = std::numeric_limits::quiet_NaN(); + e.curvature = -std::numeric_limits::infinity(); in.entities.push_back(e); AmbientEntity cop = copCar(2, {}); cop.target = 200; // not a player @@ -143,6 +149,7 @@ TEST(AmbientState, OutOfRangeValuesAreClampedNotTrusted) { EXPECT_TRUE(std::isfinite(o.position.x) && std::isfinite(o.position.y) && std::isfinite(o.position.z)); EXPECT_TRUE(std::isfinite(o.speed)); + EXPECT_TRUE(std::isfinite(o.accel) && std::isfinite(o.curvature)); 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)); @@ -231,7 +238,13 @@ TEST(AmbientState, SizesFitTheBudget) { AmbientEntity offRail = rail; offRail.flags |= kAmbientOffRail; const AmbientEntity cop = copCar(2, {}); - EXPECT_EQ(ambientEntityBits(rail), 119u); + // Version 5: a rail car's acceleration and curvature when either is not + // 0 (16 bits), else 1 bit. + EXPECT_EQ(ambientEntityBits(rail), 120u); + AmbientEntity turning = rail; + turning.accel = -2.0f; + turning.curvature = 0.05f; + EXPECT_EQ(ambientEntityBits(turning), 136u); // Version 4: a knocked car says whether its wheels follow (80 bits more // when they do), a police car's damage takes 10 bits. EXPECT_EQ(ambientEntityBits(offRail), 178u); From 5f2f10d4d9bc1bd55d7c537853e883f53ba3133f Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Fri, 9 Oct 2026 13:04:16 -0700 Subject: [PATCH 005/129] Carry the players' inputs to the host and its car states back Protocol 5 for the host-authoritative cars: PlayerInput (client to host, unreliable) carries every sample's controls the host has not acknowledged, numbered and repeated until acknowledged (a frame like the one before it is one bit), with the resets the client's rules asked for; CarStates (host to each client, unreliable) carries the last input the host applied to that client's car, how many of its inputs were waiting, the car's state after it at full precision, and every other player's car as a snapshot. Both are read within ranges (no wrapping sequence, positions in the city, a rotation matrix, bounded values), only during a race, and a joiner's inputs within a budget of 90 messages a second. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/net/PlayerCars.h | 314 +++++++++++++++++++++++ src/net/Protocol.h | 8 +- src/net/Session.cpp | 50 ++++ src/net/Session.h | 34 ++- tests/net/test_player_cars.cpp | 437 +++++++++++++++++++++++++++++++++ 5 files changed, 840 insertions(+), 3 deletions(-) create mode 100644 src/net/PlayerCars.h create mode 100644 tests/net/test_player_cars.cpp diff --git a/src/net/PlayerCars.h b/src/net/PlayerCars.h new file mode 100644 index 0000000..ff08336 --- /dev/null +++ b/src/net/PlayerCars.h @@ -0,0 +1,314 @@ +#pragma once + +// The players' cars in a network race, simulated by the host (OpenMM2's own +// model, see docs/multiplayer.md "Players' cars"; the maintainer's decision +// replaced MM2's peer-to-peer mmNetObject, where each machine sent its own +// car's position and the others dead-reckoned it). +// +// PlayerInputMsg client -> host, every frame that simulated a sample: the +// inputs of every sample the host has not acknowledged yet +// (the newest kMaxInputFrames), each numbered, so a lost +// packet loses nothing; and the commands (resets) the host +// has not acknowledged. +// CarStatesMsg host -> each client, 20 times a second: the last input +// the host applied to that client's car and the car's +// state after it (full precision: the client puts its car +// back to it and runs its later inputs again), and every +// other player's car (VehicleSnapshot, drawn interpolated). +// +// Both travel on the State channel (unreliable, unsequenced). + +#include "net/Protocol.h" + +#include +#include +#include +#include +#include +#include + +namespace mm2::net { + +// --- Inputs ------------------------------------------------------------------------ + +// What the player's controls were for one sample. The pedals are the bytes +// MM2's replay records (mmReplayManager::Update: steering x 127 in a signed +// byte, the pedals x 255), which is what the car is driven with. +struct CarInputFrame { + std::int8_t steering = 0; + std::uint8_t throttle = 0; + std::uint8_t brake = 0; + std::uint8_t handbrake = 0; + std::uint8_t flags = 0; // CarInputFlags: the state of the car's switches + std::uint8_t events = 0; // CarInputEvents: keys pressed this sample (applied once) + // Cops and Robbers' gold on this car (mmMultiCR::FondleCarMass): its + // mass in kg and the throttle cap (x 255; 255 none). + std::uint16_t extraMass = 0; + std::uint8_t throttleCap = 255; + + bool operator==(const CarInputFrame&) const = default; +}; + +enum CarInputFlags : std::uint8_t { + kInputHeld = 1 << 0, // held on the grid (vehCar::SetDrivable(0, 1)) until the start + kInputFinished = 1 << 1, // mmPlayer +0x2258: brakes, wheel turned full left + kInputAutomatic = 1 << 2, // the gearbox is automatic + kInputAutoReverse = 1 << 3, // the AUTO REVERSE option (mmInput +0x18c) + kInputHorn = 1 << 4, + kInputHeadlights = 1 << 5, +}; +enum CarInputEvents : std::uint8_t { + kInputShiftUp = 1 << 0, + kInputShiftDown = 1 << 1, + kInputReverse = 1 << 2, // reverse, or first gear from reverse +}; +inline constexpr std::uint8_t kCarInputFlagMask = 0x3f; +inline constexpr std::uint8_t kCarInputEventMask = 0x07; +inline constexpr std::uint16_t kMaxExtraMass = 2000; // kg + +// A reset of the car the client's rules asked for at sample `seq`, done by +// the host at that sample too (mmPlayer::Reset and the HitWaterHandlers). +enum class CarCommandKind : std::uint8_t { + Reset, // back to its reset position (vehCar::Reset) + ResetTo, // a new reset position and rotation, then Reset (a start, a debug start) + RespawnAt, // there, the reset position kept (mmGameMulti::HitWaterHandler) + ClearDamage, // vehCar::ClearDamage (Cops and Robbers' repairs, a damage reset) + Last = ClearDamage, +}; +struct CarCommand { + std::uint32_t seq = 0; + CarCommandKind kind = CarCommandKind::Reset; + // ResetTo: vehCarSim's reset position as it holds it (CenterOfGravity + // added) and rotation; RespawnAt: the position and rotation it is reset + // at (as SetResetPos takes them). + Vec3 position; + float rotation = 0.0f; + + bool operator==(const CarCommand&) const = default; +}; + +inline constexpr std::size_t kMaxInputFrames = 48; // 0.8 s of samples +inline constexpr std::size_t kMaxCarCommands = 4; +// Positions and angles a command may carry. +inline constexpr float kMaxCommandCoordinate = 16384.0f; +inline constexpr float kMaxCommandAngle = 64.0f; + +struct PlayerInputMsg { + static constexpr MsgType kType = MsgType::PlayerInput; + std::uint32_t first = 0; // the number of frames[0] (samples count from 1) + std::vector frames; + std::vector commands; +}; + +template +bool serialize(S& s, CarInputFrame& f, const CarInputFrame& previous) { + // A frame like the one before it (no keys pressed) is one bit. + bool same = f == previous; + s.boolean(same); + if (same) { + f = previous; + return s.ok(); + } + std::uint32_t steer = static_cast(f.steering); + s.bits(steer, 8); + f.steering = static_cast(static_cast(steer)); + s.u8(f.throttle); + s.u8(f.brake); + s.u8(f.handbrake); + std::uint32_t flags = f.flags & kCarInputFlagMask; + s.bits(flags, 6); + f.flags = static_cast(flags); + std::uint32_t events = f.events & kCarInputEventMask; + s.bits(events, 3); + f.events = static_cast(events); + bool gold = f.extraMass != 0 || f.throttleCap != 255; + s.boolean(gold); + if (gold) { + std::int32_t mass = std::min(f.extraMass, kMaxExtraMass); + s.ranged(mass, 0, kMaxExtraMass); + f.extraMass = static_cast(mass); + s.u8(f.throttleCap); + } else if constexpr (S::kReading) { + f.extraMass = 0; + f.throttleCap = 255; + } + return s.ok(); +} + +template +bool serialize(S& s, CarCommand& c) { + s.u32(c.seq); + s.enumeration(c.kind, CarCommandKind::Last); + if (c.kind == CarCommandKind::ResetTo || c.kind == CarCommandKind::RespawnAt) { + s.vec3(c.position); + s.f32(c.rotation); + const auto inRange = [](float v, float r) { return v >= -r && v <= r; }; + if (!inRange(c.position.x, kMaxCommandCoordinate) || !inRange(c.position.y, kMaxCommandCoordinate) || + !inRange(c.position.z, kMaxCommandCoordinate) || !inRange(c.rotation, kMaxCommandAngle)) + return s.fail(); + } else if constexpr (S::kReading) { + c.position = {}; + c.rotation = 0.0f; + } + return s.ok(); +} + +template +bool serialize(S& s, PlayerInputMsg& m) { + s.u32(m.first); + auto count = static_cast(std::min(m.frames.size(), kMaxInputFrames)); + s.ranged(count, 1, static_cast(kMaxInputFrames)); + if constexpr (S::kReading) + m.frames.resize(static_cast(count)); + // Numbers wrap after 2^32 samples (two years at 60 a second): refused. + if (m.first == 0 || m.first > 0xFFFFFFFFu - kMaxInputFrames) + return s.fail(); + CarInputFrame previous; + for (std::int32_t i = 0; i < count; ++i) { + serialize(s, m.frames[static_cast(i)], previous); + previous = m.frames[static_cast(i)]; + } + auto commands = static_cast(std::min(m.commands.size(), kMaxCarCommands)); + s.ranged(commands, 0, static_cast(kMaxCarCommands)); + if constexpr (S::kReading) + m.commands.resize(static_cast(commands)); + for (std::int32_t i = 0; i < commands; ++i) + serialize(s, m.commands[static_cast(i)]); + return s.ok(); +} + +// --- The host's states ----------------------------------------------------------------- + +// A car's simulation as the client needs it to carry on from the host's +// state: the rigid body exactly, and the parts whose state decides the next +// samples (the wheels' spin, springs and tyres, the engine and gearbox, the +// drivetrains, the stuck watcher, the damage, the random stream). +struct OwnCarState { + Mat34 matrix; // the body (centre of mass) frame + Vec3 linearMomentum, angularMomentum, linearVelocity, angularVelocity; + Vec3 lastPush; + struct Wheel { + float rotationSpeed = 0, rotation = 0, suspension = 0, suspensionVelocity = 0; + float tireDispLat = 0, tireDispLong = 0; + }; + std::array wheels{}; + float engineSpeed = 0, gearChangeTime = 0, prevGearRpm = 0; + bool changingGear = false; + std::int32_t gear = 2; // transmission slot, 0 reverse .. 7 + bool automatic = true, gearChanged = false; + float timeInGear = 0; + std::array drivetrainSpeed{}; + float damage = 0; // vehCarDamage CurrentDamage + std::int32_t stuckState = 0; + bool stuckActive = false; + float stuckTime = 0; + Vec3 stuckPosition; + std::uint32_t random = 1; + bool swapThrottle = false; // the pedals swapped (automatic reverse) + bool held = false; // held on the grid + std::uint32_t resets = 0; // the commands the host has carried out (wraps) +}; + +// Ranges an OwnCarState must lie in (anything else is refused). +inline constexpr float kOwnStateMaxCoordinate = 16384.0f; +inline constexpr float kOwnStateMaxValue = 1.0e7f; + +template +bool serialize(S& s, OwnCarState& o) { + s.vec3(o.matrix.m0); + s.vec3(o.matrix.m1); + s.vec3(o.matrix.m2); + s.vec3(o.matrix.m3); + s.vec3(o.linearMomentum); + s.vec3(o.angularMomentum); + s.vec3(o.linearVelocity); + s.vec3(o.angularVelocity); + s.vec3(o.lastPush); + for (auto& w : o.wheels) { + s.f32(w.rotationSpeed); + s.f32(w.rotation); + s.f32(w.suspension); + s.f32(w.suspensionVelocity); + s.f32(w.tireDispLat); + s.f32(w.tireDispLong); + } + s.f32(o.engineSpeed); + s.f32(o.gearChangeTime); + s.f32(o.prevGearRpm); + s.boolean(o.changingGear); + s.ranged(o.gear, 0, 7); + s.boolean(o.automatic); + s.boolean(o.gearChanged); + s.f32(o.timeInGear); + for (float& d : o.drivetrainSpeed) + s.f32(d); + s.f32(o.damage); + s.ranged(o.stuckState, 0, 4); + s.boolean(o.stuckActive); + s.f32(o.stuckTime); + s.vec3(o.stuckPosition); + s.u32(o.random); + s.boolean(o.swapThrottle); + s.boolean(o.held); + s.u32(o.resets); + if constexpr (S::kReading) { + // Untrusted: every number finite (f32 refuses the rest) and in range. + const auto ok = [](const Vec3& v, float r) { + return std::abs(v.x) <= r && std::abs(v.y) <= r && std::abs(v.z) <= r; + }; + const Mat34& m = o.matrix; + bool good = ok(m.m0, 2.0f) && ok(m.m1, 2.0f) && ok(m.m2, 2.0f) && ok(m.m3, kOwnStateMaxCoordinate) && + ok(o.linearMomentum, kOwnStateMaxValue) && ok(o.angularMomentum, kOwnStateMaxValue) && + ok(o.linearVelocity, kOwnStateMaxValue) && ok(o.angularVelocity, kOwnStateMaxValue) && + ok(o.lastPush, kOwnStateMaxValue) && ok(o.stuckPosition, kOwnStateMaxCoordinate); + for (const auto& w : o.wheels) + for (float v : {w.rotationSpeed, w.rotation, w.suspension, w.suspensionVelocity, w.tireDispLat, + w.tireDispLong}) + good = good && std::abs(v) <= kOwnStateMaxValue; + for (float v : {o.engineSpeed, o.gearChangeTime, o.prevGearRpm, o.timeInGear, o.damage, o.stuckTime, + o.drivetrainSpeed[0], o.drivetrainSpeed[1], o.drivetrainSpeed[2]}) + good = good && std::abs(v) <= kOwnStateMaxValue; + if (!good) + return s.fail(); + } + return s.ok(); +} + +struct CarStatesMsg { + static constexpr MsgType kType = MsgType::CarStates; + std::uint32_t time = 0; // session ms of the host's sample the states belong to + // The receiving client's car: the number of the last input the host + // applied to it (0: none yet), how many of its inputs were waiting + // (beyond the one applied; negative: the host ran short) and its state. + std::uint32_t ack = 0; + std::int32_t waiting = 0; + bool hasOwn = false; + OwnCarState own; + // Every other player's car. + std::vector> cars; +}; +inline constexpr std::int32_t kMaxReportedWaiting = 63; + +template +bool serialize(S& s, CarStatesMsg& m) { + s.u32(m.time); + s.u32(m.ack); + m.waiting = std::clamp(m.waiting, -kMaxReportedWaiting, kMaxReportedWaiting); + s.ranged(m.waiting, -kMaxReportedWaiting, kMaxReportedWaiting); + s.boolean(m.hasOwn); + if (m.hasOwn) + serialize(s, m.own); + auto count = static_cast(m.cars.size()); + s.varU32(count); + if (count > kMaxPlayers) + return s.fail(); + if constexpr (S::kReading) + m.cars.resize(count); + for (auto& [id, state] : m.cars) { + s.u8(id); + serialize(s, state); + } + return s.ok(); +} + +} // namespace mm2::net diff --git a/src/net/Protocol.h b/src/net/Protocol.h index 8d0fbed..ac2a3c8 100644 --- a/src/net/Protocol.h +++ b/src/net/Protocol.h @@ -30,7 +30,9 @@ inline constexpr std::uint16_t kProtocolMagic = 0x4D32; // "M2" // RaceStart; the race in Welcome). // 4: the cars' damage (net/VehicleDamage.h events; in AmbientState the // police's damage at 10 bits and the knocked traffic cars' wheels). -inline constexpr std::uint16_t kProtocolVersion = 4; +// 5: the host simulates every player's car (net/PlayerCars.h: PlayerInput, +// CarStates; damage events about a player's car from the host). +inline constexpr std::uint16_t kProtocolVersion = 5; inline constexpr std::uint32_t kConnectData = (std::uint32_t{kProtocolMagic} << 16) | kProtocolVersion; // ENet channels. @@ -93,7 +95,9 @@ enum class MsgType : std::uint8_t { AmbientState, // net/AmbientState.h RaceLoaded, RaceStart, - Last = RaceStart, + PlayerInput, // net/PlayerCars.h + CarStates, + Last = CarStates, }; // Sent as ENet disconnect data and in Reject/PlayerLeft messages. diff --git a/src/net/Session.cpp b/src/net/Session.cpp index 0a320d0..4f7c23a 100644 --- a/src/net/Session.cpp +++ b/src/net/Session.cpp @@ -72,6 +72,10 @@ constexpr double kEventRate = 30.0, kEventBurst = 60.0; // a crash's events never use up the race events' or the other way round. The // game sends at most ten a second. constexpr double kDamageEventRate = 15.0, kDamageEventBurst = 30.0; +// A joiner's car's inputs (net/PlayerCars.h): the game sends one message a +// frame that simulated a sample, at most 60 a second; the host only queues +// them, so the budget bounds the work and the queue. +constexpr double kInputRate = 90.0, kInputBurst = 180.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 @@ -758,6 +762,19 @@ void Session::hostHandle(Remote& r, MsgType type, std::span dat hostRelayState(id, msg.state); return; } + case MsgType::PlayerInput: { + // A joiner's car's inputs, during a race only (a late one from the + // last race is old), within its budget: the next message repeats + // what a dropped one carried. + PlayerInputMsg msg; + if (!replicating() || !r.inputs.take(monotonicMs(), kInputRate, kInputBurst) || + !decodeMessage(data, msg)) + return; + if (m_playerInputs.size() >= kMaxQueuedPlayerInputs) + m_playerInputs.erase(m_playerInputs.begin()); + m_playerInputs.push_back({id, std::move(msg)}); + return; + } case MsgType::RaceLoaded: { // Only the first report of the race being loaded counts: one for an // earlier race (sent before the return to the lobby reached the @@ -1084,6 +1101,22 @@ void Session::clientHandle(MsgType type, std::span data) { } return; } + case MsgType::CarStates: { + // The host's states of the players' cars: this machine's own with + // the last input it applied, the others into their buffers. + CarStatesMsg m; + if (m_phase == SessionPhase::Lobby || !decodeMessage(data, m)) + return; + for (auto& [id, state] : m.cars) { + state.time = m.time; + if (id != m_localId) + receiveState(id, state); + } + if (m_ownCarStates.size() >= kMaxQueuedOwnCarStates) + m_ownCarStates.erase(m_ownCarStates.begin()); + m_ownCarStates.push_back({m.time, m.ack, m.waiting, m.hasOwn, m.own, timeMs()}); + return; + } case MsgType::GameEvent: { GameEventMsg m; if (decodeMessage(data, m)) @@ -1290,6 +1323,23 @@ void Session::sendGameEvent(std::uint16_t type, std::vector payload, sendTo(m_hostPeer, Channel::Events, std::move(msg)); } +void Session::sendPlayerInput(const PlayerInputMsg& msg) { + if (m_role != Role::Client || m_state != State::Active || !replicating() || msg.frames.empty()) + return; + sendTo(m_hostPeer, Channel::State, msg); +} + +std::size_t Session::sendCarStates(std::uint8_t playerId, const CarStatesMsg& msg) { + if (m_role != Role::Host || !m_transport || m_state != State::Active || !replicating() || + 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::State, packet) ? packet.size() : 0; +} + std::size_t Session::sendAmbientState(std::uint8_t playerId, const AmbientStateMsg& msg) { if (m_role != Role::Host || !m_transport || m_state != State::Active || m_phase == SessionPhase::Lobby || playerId == m_localId) diff --git a/src/net/Session.h b/src/net/Session.h index 45b2e6a..2f7afa8 100644 --- a/src/net/Session.h +++ b/src/net/Session.h @@ -17,6 +17,7 @@ #include "net/AmbientState.h" #include "net/ClockSync.h" #include "net/Discovery.h" +#include "net/PlayerCars.h" #include "net/Protocol.h" #include "net/Snapshot.h" #include "net/Transport.h" @@ -245,6 +246,35 @@ class Session { // Connection quality to the host (clients) or to a player (host). PeerStats peerStats(std::uint8_t playerId) const; + // --- The players' cars, simulated by the host (net/PlayerCars.h) --- + // Client: this machine's inputs to the host (during a race only). + void sendPlayerInput(const PlayerInputMsg& msg); + // Host: the clients' inputs received since the last call, oldest first + // (at most kMaxQueuedPlayerInputs; a joiner beyond its budget of + // kInputRate messages a second loses the excess, which the next ones + // repeat). + struct ReceivedInput { + std::uint8_t player = kInvalidPlayerId; + PlayerInputMsg msg; + }; + std::vector takePlayerInputs() { return std::exchange(m_playerInputs, {}); } + static constexpr std::size_t kMaxQueuedPlayerInputs = 256; + // Host: one client's states (its own car and the others'); returns the + // bytes sent. + std::size_t sendCarStates(std::uint8_t playerId, const CarStatesMsg& msg); + // Client: what the host said about this machine's car since the last + // call, oldest first (the other cars go into their snapshot buffers). + struct OwnCarUpdate { + std::uint32_t time = 0; + std::uint32_t ack = 0; + std::int32_t waiting = 0; + bool hasOwn = false; + OwnCarState own; + double arrival = 0.0; // session ms + }; + std::vector takeOwnCarStates() { return std::exchange(m_ownCarStates, {}); } + static constexpr std::size_t kMaxQueuedOwnCarStates = 16; + // --- 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). @@ -267,7 +297,7 @@ class Session { std::uint8_t playerId = kInvalidPlayerId; // assigned after Hello std::array nonce{}; std::uint64_t connectedAt = 0; - RateLimit chat, updates, events, damage; + RateLimit chat, updates, events, damage, inputs; bool updatePending = false; // a PlayerRequest applied but not yet relayed }; @@ -360,6 +390,8 @@ class Session { std::uint64_t m_lastSnapshotSent = 0; double m_lastPlayoutUpdate = -1.0; std::vector m_ambientStates; // client: received, not yet taken + std::vector m_playerInputs; // host: the clients' inputs, not yet taken + std::vector m_ownCarStates; // client: the host's word on its car, not yet taken }; // Proof sent in Hello: SHA-256(nonce || password); all zeros when empty. diff --git a/tests/net/test_player_cars.cpp b/tests/net/test_player_cars.cpp new file mode 100644 index 0000000..44aae5a --- /dev/null +++ b/tests/net/test_player_cars.cpp @@ -0,0 +1,437 @@ +// The host-simulated players' cars on the wire (net/PlayerCars.h): the +// clients' inputs and the host's states, their encodings, what a receiver +// refuses, mutated messages, and their delivery through real sessions. +#include "net/PlayerCars.h" +#include "net/Session.h" + +#include + +#include +#include +#include +#include +#include +#include +#include + +using namespace mm2; +using namespace mm2::net; + +namespace { + +using Bytes = std::vector; + +// This file's block of ports, below 49152 (see tests/game/test_traffic_net.cpp). +std::uint16_t testPort(int k) { + static const auto base = static_cast( + 21000 + (std::chrono::steady_clock::now().time_since_epoch().count() / 1000) % 1000 * 4); + return static_cast(base + k); +} + +PlayerInputMsg sampleInput() { + PlayerInputMsg m; + m.first = 1234; + CarInputFrame f; + f.steering = -127; + f.throttle = 255; + f.flags = kInputAutomatic | kInputAutoReverse; + for (int i = 0; i < 20; ++i) { + if (i == 5) + f.events = kInputShiftUp; + else + f.events = 0; + if (i == 9) { + f.brake = 200; + f.steering = 64; + } + if (i == 15) { + f.extraMass = 227; + f.throttleCap = 191; + } + m.frames.push_back(f); + } + m.commands.push_back({1240, CarCommandKind::RespawnAt, {-1150.5f, 112.25f, 163.0f}, 3.1f}); + m.commands.push_back({1250, CarCommandKind::ClearDamage, {}, 0.0f}); + return m; +} + +OwnCarState sampleOwn() { + OwnCarState o; + o.matrix = Mat34::rotationY(0.7f); + o.matrix.m3 = {-1149.94531f, 111.900002f, 163.194f}; + o.linearMomentum = {1234.5678f, -9.87f, 0.001f}; + o.angularMomentum = {0.1f, 1500.25f, -3.0f}; + o.linearVelocity = {1.2345678f, -0.00987f, 0.000001f}; + o.angularVelocity = {0.0001f, 0.5f, -0.003f}; + o.lastPush = {0.0f, 0.002f, 0.0f}; + for (std::size_t i = 0; i < 4; ++i) + o.wheels[i] = {-30.5f - static_cast(i), 12.0f, 0.05f, -0.2f, 0.001f, -0.002f}; + o.engineSpeed = 420.0f; + o.gearChangeTime = 0.1f; + o.prevGearRpm = 5000.0f; + o.changingGear = true; + o.gear = 4; + o.automatic = false; + o.timeInGear = 2.5f; + o.drivetrainSpeed = {-30.0f, -31.0f, -32.0f}; + o.damage = 1234.0f; + o.stuckState = 3; + o.stuckActive = true; + o.stuckTime = 0.4f; + o.stuckPosition = {-1150.0f, 112.0f, 160.0f}; + o.random = 0xDEADBEEF; + o.swapThrottle = true; + o.held = false; + o.resets = 7; + return o; +} + +CarStatesMsg sampleStates(std::size_t cars) { + CarStatesMsg m; + m.time = 98765; + m.ack = 4321; + m.waiting = -2; + m.hasOwn = true; + m.own = sampleOwn(); + for (std::size_t i = 0; i < cars; ++i) { + VehicleSnapshot s; + s.time = m.time; + s.position = {100.0f + static_cast(i), 5.0f, -20.0f}; + s.orientation = Quat::fromAxisAngle(Vec3{0, 1, 0}, 0.3f * static_cast(i)); + s.linearVelocity = {10.0f, 0.0f, 1.0f}; + s.controls.throttle = 1.0f; + s.damage = 0.5f; + m.cars.emplace_back(static_cast(i), s); + } + return m; +} + +bool sameBits(float a, float b) { return std::memcmp(&a, &b, sizeof(float)) == 0; } + +void expectSameOwn(const OwnCarState& a, const OwnCarState& b) { + const auto vec = [](const Vec3& x, const Vec3& y) { + return sameBits(x.x, y.x) && sameBits(x.y, y.y) && sameBits(x.z, y.z); + }; + EXPECT_TRUE(vec(a.matrix.m0, b.matrix.m0) && vec(a.matrix.m1, b.matrix.m1)); + EXPECT_TRUE(vec(a.matrix.m2, b.matrix.m2) && vec(a.matrix.m3, b.matrix.m3)); + EXPECT_TRUE(vec(a.linearMomentum, b.linearMomentum) && vec(a.angularMomentum, b.angularMomentum)); + EXPECT_TRUE(vec(a.linearVelocity, b.linearVelocity) && vec(a.angularVelocity, b.angularVelocity)); + EXPECT_TRUE(vec(a.lastPush, b.lastPush) && vec(a.stuckPosition, b.stuckPosition)); + for (std::size_t i = 0; i < 4; ++i) { + EXPECT_TRUE(sameBits(a.wheels[i].rotationSpeed, b.wheels[i].rotationSpeed)); + EXPECT_TRUE(sameBits(a.wheels[i].tireDispLong, b.wheels[i].tireDispLong)); + } + EXPECT_EQ(a.gear, b.gear); + EXPECT_EQ(a.automatic, b.automatic); + EXPECT_EQ(a.changingGear, b.changingGear); + EXPECT_TRUE(sameBits(a.engineSpeed, b.engineSpeed)); + EXPECT_TRUE(sameBits(a.damage, b.damage)); + EXPECT_EQ(a.drivetrainSpeed, b.drivetrainSpeed); + EXPECT_EQ(a.stuckState, b.stuckState); + EXPECT_EQ(a.random, b.random); + EXPECT_EQ(a.swapThrottle, b.swapThrottle); + EXPECT_EQ(a.held, b.held); + EXPECT_EQ(a.resets, b.resets); +} + +// What a decoded message may hold, whatever arrived. +void checkInput(const PlayerInputMsg& m) { + ASSERT_GE(m.frames.size(), 1u); + ASSERT_LE(m.frames.size(), kMaxInputFrames); + ASSERT_GE(m.first, 1u); + ASSERT_LE(m.commands.size(), kMaxCarCommands); + for (const auto& f : m.frames) { + ASSERT_EQ(f.flags & ~kCarInputFlagMask, 0); + ASSERT_EQ(f.events & ~kCarInputEventMask, 0); + ASSERT_LE(f.extraMass, kMaxExtraMass); + } + for (const auto& c : m.commands) { + ASSERT_LE(c.kind, CarCommandKind::Last); + for (float v : {c.position.x, c.position.y, c.position.z}) { + ASSERT_TRUE(std::isfinite(v)); + ASSERT_LE(std::abs(v), kMaxCommandCoordinate); + } + ASSERT_LE(std::abs(c.rotation), kMaxCommandAngle); + } +} + +void checkStates(const CarStatesMsg& m) { + ASSERT_LE(m.cars.size(), kMaxPlayers); + ASSERT_LE(std::abs(m.waiting), kMaxReportedWaiting); + if (!m.hasOwn) + return; + const OwnCarState& o = m.own; + for (const Vec3& v : {o.matrix.m0, o.matrix.m1, o.matrix.m2}) + ASSERT_TRUE(std::abs(v.x) <= 2.0f && std::abs(v.y) <= 2.0f && std::abs(v.z) <= 2.0f); + for (const Vec3& v : {o.matrix.m3, o.stuckPosition}) + ASSERT_TRUE(std::abs(v.x) <= kOwnStateMaxCoordinate && std::abs(v.y) <= kOwnStateMaxCoordinate && + std::abs(v.z) <= kOwnStateMaxCoordinate); + for (const Vec3& v : + {o.linearMomentum, o.angularMomentum, o.linearVelocity, o.angularVelocity, o.lastPush}) + ASSERT_TRUE(std::isfinite(v.x) && std::abs(v.y) <= kOwnStateMaxValue && std::isfinite(v.z)); + ASSERT_GE(o.gear, 0); + ASSERT_LE(o.gear, 7); + ASSERT_GE(o.stuckState, 0); + ASSERT_LE(o.stuckState, 4); +} + +Bytes mutate(const std::vector& corpus, std::mt19937& rng) { + auto pick = [&](std::size_t n) { return std::uniform_int_distribution(0, n - 1)(rng); }; + Bytes b = corpus[pick(corpus.size())]; + const int rounds = 1 + static_cast(pick(4)); + for (int r = 0; r < rounds; ++r) { + switch (pick(5)) { + case 0: + 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: + for (std::size_t i = 0, n = 1 + pick(4); i < n && !b.empty(); ++i) + b[pick(b.size())] = static_cast(rng() & 0xFF); + break; + case 2: + if (!b.empty()) + b.resize(pick(b.size())); + break; + case 3: + for (std::size_t i = 0, n = 1 + pick(32); i < n; ++i) + b.push_back(static_cast(rng() & 0xFF)); + break; + default: + b.resize(pick(400)); + for (auto& x : b) + x = static_cast(rng() & 0xFF); + if (!b.empty()) + b[0] = static_cast(pick(2) ? MsgType::PlayerInput : MsgType::CarStates); + break; + } + } + return b; +} + +bool pumpUntil(std::initializer_list sessions, const std::function& done, int ms = 3000) { + const auto end = std::chrono::steady_clock::now() + std::chrono::milliseconds(ms); + while (std::chrono::steady_clock::now() < end) { + for (Session* s : sessions) + s->update(); + if (done()) + return true; + std::this_thread::sleep_for(std::chrono::milliseconds(1)); + } + return false; +} + +// A host and a joined, clock-synced client. +struct Pair { + Session host, client; + explicit Pair(int k) { + HostParams h; + h.bind = Address::loopback(testPort(k)); + h.advertiseOnLan = false; + h.player.name = "Host"; + EXPECT_TRUE(host.host(h)); + JoinParams j; + j.host = Address::loopback(host.port()); + j.player.name = "Client"; + EXPECT_TRUE(client.join(j)); + EXPECT_TRUE(pumpUntil({&host, &client}, [&] { + return client.clockSynced() && client.players().size() == 2 && host.players().size() == 2; + })); + pumpUntil({&host, &client}, [] { return false; }, 300); + } + void race() { + host.startRace(0); + EXPECT_TRUE(pumpUntil({&host, &client}, [&] { + return host.phase() == SessionPhase::Countdown && client.phase() == SessionPhase::Countdown; + })); + } +}; + +} // namespace + +TEST(PlayerCars, InputsRoundTrip) { + const PlayerInputMsg m = sampleInput(); + const auto bytes = encodeMessage(m); + PlayerInputMsg out; + ASSERT_TRUE(decodeMessage(bytes, out)); + EXPECT_EQ(out.first, m.first); + EXPECT_EQ(out.frames, m.frames); + EXPECT_EQ(out.commands, m.commands); +} + +TEST(PlayerCars, RepeatedInputsCostABitEach) { + // A second of samples (60) with nothing changing after the first. + PlayerInputMsg m; + m.first = 1; + CarInputFrame f; + f.throttle = 255; + f.steering = 30; + f.flags = kInputAutomatic; + m.frames.assign(kMaxInputFrames, f); + const auto bytes = encodeMessage(m); + EXPECT_LE(bytes.size(), 20u); + PlayerInputMsg out; + ASSERT_TRUE(decodeMessage(bytes, out)); + EXPECT_EQ(out.frames, m.frames); +} + +TEST(PlayerCars, MalformedInputsAreRefused) { + PlayerInputMsg out; + auto bad = sampleInput(); + bad.first = 0; // samples count from 1 + EXPECT_FALSE(decodeMessage(encodeMessage(bad), out)); + bad = sampleInput(); + bad.first = 0xFFFFFFF0u; // the numbers would wrap + EXPECT_FALSE(decodeMessage(encodeMessage(bad), out)); + bad = sampleInput(); + bad.commands[0].position.y = 1.0e9f; // far outside any city + EXPECT_FALSE(decodeMessage(encodeMessage(bad), out)); + bad = sampleInput(); + bad.commands[0].rotation = 1000.0f; + EXPECT_FALSE(decodeMessage(encodeMessage(bad), out)); + auto bytes = encodeMessage(sampleInput()); + bytes.push_back(std::byte{0x55}); // trailing bytes + EXPECT_FALSE(decodeMessage(bytes, out)); + bytes = encodeMessage(sampleInput()); + bytes.resize(bytes.size() / 2); // cut short + EXPECT_FALSE(decodeMessage(bytes, out)); +} + +TEST(PlayerCars, StatesRoundTripTheOwnCarBitForBit) { + const CarStatesMsg m = sampleStates(kMaxPlayers - 1); + const auto bytes = encodeMessage(m); + // Fits a packet below the ENet MTU with 15 other cars. + EXPECT_LT(bytes.size(), 1200u); + CarStatesMsg out; + ASSERT_TRUE(decodeMessage(bytes, out)); + EXPECT_EQ(out.time, m.time); + EXPECT_EQ(out.ack, m.ack); + EXPECT_EQ(out.waiting, m.waiting); + ASSERT_TRUE(out.hasOwn); + expectSameOwn(out.own, m.own); + ASSERT_EQ(out.cars.size(), m.cars.size()); + EXPECT_NEAR(out.cars[3].second.position.x, 103.0f, 0.01f); +} + +TEST(PlayerCars, StatesOutOfRangeAreRefused) { + CarStatesMsg out; + auto bad = sampleStates(2); + bad.own.matrix.m0.x = 3.0f; // not a rotation + EXPECT_FALSE(decodeMessage(encodeMessage(bad), out)); + bad = sampleStates(2); + bad.own.matrix.m3.z = 1.0e6f; + EXPECT_FALSE(decodeMessage(encodeMessage(bad), out)); + bad = sampleStates(2); + bad.own.linearVelocity.y = std::numeric_limits::infinity(); + EXPECT_FALSE(decodeMessage(encodeMessage(bad), out)); + bad = sampleStates(2); + bad.own.wheels[2].rotationSpeed = 1.0e20f; + EXPECT_FALSE(decodeMessage(encodeMessage(bad), out)); + bad = sampleStates(2); + bad.waiting = 1000; // clamped to the range, not refused + ASSERT_TRUE(decodeMessage(encodeMessage(bad), out)); + EXPECT_EQ(out.waiting, kMaxReportedWaiting); +} + +TEST(PlayerCars, DecodersSurviveMutatedMessages) { + const std::vector corpus{encodeMessage(sampleInput()), encodeMessage(sampleStates(3)), + encodeMessage(sampleStates(0))}; + std::mt19937 rng(20261009); + for (int i = 0; i < 40000; ++i) { + const Bytes b = mutate(corpus, rng); + if (PlayerInputMsg m; decodeMessage(b, m)) + checkInput(m); + if (CarStatesMsg m; decodeMessage(b, m)) + checkStates(m); + if (HasFatalFailure()) { + ADD_FAILURE() << "iteration " << i; + return; + } + } +} + +TEST(PlayerCars, InputsAndStatesTravelDuringARaceOnly) { + Pair p(0); + // In the lobby nothing is taken. + p.client.sendPlayerInput(sampleInput()); + p.host.sendCarStates(p.client.localId(), sampleStates(1)); + pumpUntil({&p.host, &p.client}, [] { return false; }, 200); + EXPECT_TRUE(p.host.takePlayerInputs().empty()); + EXPECT_TRUE(p.client.takeOwnCarStates().empty()); + + p.race(); + p.client.sendPlayerInput(sampleInput()); + std::vector inputs; + ASSERT_TRUE(pumpUntil({&p.host, &p.client}, [&] { + for (auto& r : p.host.takePlayerInputs()) + inputs.push_back(std::move(r)); + return !inputs.empty(); + })); + EXPECT_EQ(inputs[0].player, p.client.localId()); + EXPECT_EQ(inputs[0].msg.frames, sampleInput().frames); + + CarStatesMsg states = sampleStates(0); + states.time = p.host.time(); + VehicleSnapshot hostCar; + hostCar.position = {10.0f, 2.0f, -5.0f}; + hostCar.orientation = Quat::fromAxisAngle(Vec3{0, 1, 0}, 0.0f); + states.cars.emplace_back(kHostPlayerId, hostCar); + states.cars.emplace_back(p.client.localId(), hostCar); // its own: not a remote car + EXPECT_GT(p.host.sendCarStates(p.client.localId(), states), 0u); + std::vector own; + ASSERT_TRUE(pumpUntil({&p.host, &p.client}, [&] { + for (auto& u : p.client.takeOwnCarStates()) + own.push_back(u); + return !own.empty(); + })); + EXPECT_EQ(own[0].ack, 4321u); + EXPECT_EQ(own[0].time, states.time); + expectSameOwn(own[0].own, sampleOwn()); + VehicleSnapshot s; + EXPECT_NE(p.client.sampleRemoteAt(kHostPlayerId, states.time, s), SnapshotBuffer::Result::Empty); + EXPECT_NEAR(s.position.x, 10.0f, 0.01f); + EXPECT_EQ(p.client.sampleRemoteAt(p.client.localId(), states.time, s), SnapshotBuffer::Result::Empty); +} + +TEST(PlayerCars, AFloodOfInputsKeepsToTheBudget) { + Pair p(1); + p.race(); + for (int i = 0; i < 1000; ++i) + p.client.sendPlayerInput(sampleInput()); + std::size_t taken = 0; + pumpUntil({&p.host, &p.client}, [&] { + taken += p.host.takePlayerInputs().size(); + return false; + }, 400); + EXPECT_GT(taken, 0u); + EXPECT_LE(taken, 260u); // the burst (180) and 0.4 s of the rate +} + +TEST(PlayerCars, MutatedTrafficLeavesBothSidesWithinLimits) { + Pair p(2); + p.race(); + // The client sends mutated inputs, and a host-like peer is not needed: + // the host's states reach the client through the same session. + const std::vector corpus{encodeMessage(sampleInput()), encodeMessage(sampleStates(3))}; + std::mt19937 rng(91); + for (int i = 0; i < 2000; ++i) { + const Bytes b = mutate(corpus, rng); + PlayerInputMsg in; + if (decodeMessage(b, in)) + p.client.sendPlayerInput(in); + CarStatesMsg st; + if (decodeMessage(b, st)) + p.host.sendCarStates(p.client.localId(), st); + if (i % 50 == 0) { + p.host.update(); + p.client.update(); + for (const auto& r : p.host.takePlayerInputs()) + checkInput(r.msg); + for (const auto& u : p.client.takeOwnCarStates()) + if (u.hasOwn) + checkStates({u.time, u.ack, u.waiting, true, u.own, {}}); + } + } + EXPECT_EQ(p.host.state(), Session::State::Active); + EXPECT_EQ(p.client.state(), Session::State::Active); +} From 0ab89fe2c52c398db4eaa8f5ed371e020ae79efb Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Fri, 9 Oct 2026 13:09:23 -0700 Subject: [PATCH 006/129] Let the host run a network race's props for everyone MM2 sends nothing about props: mmGameMulti and mmNetObject carry the cars, and every machine knocks its own props with its own simulation of each car. OpenMM2 0.3 did the same, except that a network car is a kinematic body there, which can never break a prop (dgImpact::CalcImpact could not stop it), so every prop a player knocked stood on every other screen, and the host's traffic knocked props on the host only. The host now owns them (the maintainer's decision: the host is the authority for everything): - PropState (protocol 5): every occupied slot of the host's ring of knocked-over props (dgBangerManager), about 20 times a second while any moves, every 0.5 s otherwise; complete, so a slot left out is empty. A placed prop is named by its index (every machine places the same props in the same order; a checksum travels with each message), a piece by its prop and BREAKnn part, a thrown car part by its car and part. - PropKnocks event: the placed props the host broke loose, reliable, and all of them so far to a machine that has just loaded the race. - A client shows the host's ring in mirror slots, interpolated a playout delay in the past, and breaks a prop when it shows the host's knock. Only its own car may touch its props (phys::Instance::acceptsContact): what its car knocks moves at once and hands over to the host's piece when both rest; a knock the host never makes is undone after 2 s. - A moving kinematic body breaks a banger as a body of its mass would and gives it all of the relative velocity, so the host's copies of the other players' cars knock props until they become host-simulated cars. OPENMM2_NETPROPS=local keeps 0.3's behaviour for comparison, and OPENMM2_DEBUG_NETPROPS= traces each machine's props for `netprobe propdiff`. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/app/RaceScreen.cpp | 164 ++++++++- src/game/bangers/BangerSet.cpp | 147 +++++++- src/game/bangers/BangerSet.h | 98 ++++- src/game/net/NetGame.cpp | 8 + src/game/net/NetGame.h | 7 + src/game/net/NetProps.cpp | 236 ++++++++++++ src/game/net/NetProps.h | 114 ++++++ src/game/net/PropSync.cpp | 633 +++++++++++++++++++++++++++++++++ src/game/net/PropSync.h | 262 ++++++++++++++ src/net/CMakeLists.txt | 1 + src/net/PropState.cpp | 12 + src/net/PropState.h | 215 +++++++++++ src/net/Protocol.h | 6 +- src/net/Session.cpp | 23 ++ src/net/Session.h | 11 + src/phys/Impact.cpp | 25 +- src/phys/Level.h | 5 + src/phys/World.cpp | 2 + tools/netprobe/CMakeLists.txt | 2 +- tools/netprobe/PropDiff.cpp | 298 ++++++++++++++++ tools/netprobe/PropDiff.h | 11 + tools/netprobe/main.cpp | 9 +- 22 files changed, 2264 insertions(+), 25 deletions(-) create mode 100644 src/game/net/NetProps.cpp create mode 100644 src/game/net/NetProps.h create mode 100644 src/game/net/PropSync.cpp create mode 100644 src/game/net/PropSync.h create mode 100644 src/net/PropState.cpp create mode 100644 src/net/PropState.h create mode 100644 tools/netprobe/PropDiff.cpp create mode 100644 tools/netprobe/PropDiff.h diff --git a/src/app/RaceScreen.cpp b/src/app/RaceScreen.cpp index 82b865f..63b455e 100644 --- a/src/app/RaceScreen.cpp +++ b/src/app/RaceScreen.cpp @@ -42,6 +42,7 @@ #include "game/fx/VehicleEffects.h" #include "game/fx/Weather.h" #include "game/net/DamageSync.h" +#include "game/net/NetProps.h" #include "game/net/NetGame.h" #include "game/net/RaceStart.h" #include "game/net/TrafficProxies.h" @@ -368,6 +369,7 @@ class RaceScreen final : public Screen { } updateRemoteCars(ctx, static_cast(dt)); updateNetTraffic(ctx, static_cast(dt)); // OpenMM2: a client's shared traffic + updateNetProps(ctx, static_cast(dt)); // OpenMM2: a client's props are the host's if (multiplayer(ctx)) m_netDamage.settle(ctx.netGame->frameTime()); // OpenMM2: the damage of cars not drawn if (netTrafficHost(ctx) && m_ai) @@ -400,6 +402,7 @@ class RaceScreen final : public Screen { // bodies; the ones that came to rest stop being simulated. if (m_bangers) m_bangers->update(static_cast(dt)); + sendNetProps(ctx); // OpenMM2: the host's props to the others if (m_trafficBodies) m_trafficBodies->afterStep(); if (m_player) { @@ -3466,6 +3469,12 @@ class RaceScreen final : public Screen { if (m_cityLevel) m_cityLevel->addSource(m_bangers.get()); m_bangers->setWorld(m_world.get()); + // OpenMM2: a network race's props are the host's (game/net/NetProps). + if (multiplayer(ctx)) { + m_netLocalId = ctx.netGame->localId(); + m_netProps.setup(*ctx.netGame, *m_bangers, + [this](const phys::Instance& other) { return ownCarBody(&other); }); + } } // aiMap::Init ends with the police cars (aiPoliceOfficer::Init -> // vehCar::Init; spawnPolice), after the subways (none in retail @@ -3544,15 +3553,20 @@ class RaceScreen final : public Screen { // 10000 or more; a wrecked car loses wheels, hubs and fenders by speed // (vehCarModel::EjectOneshot), thrown at 1.3 times its speed. Parts // without banger data stay on. - // `recorder` (OpenMM2): the parts the others' machines take off too. + // `recorder` (OpenMM2): the parts the others' machines take off too; + // `owner` (OpenMM2): whose car it is in a network race, which names the + // thrown parts across the machines (game/net/PropSync). void breakParts(game::fx::VehicleEffects& fx, game::VehicleRenderer& r, const phys::CarSim& sim, - const std::string& vehicle, game::DamageRecorder* recorder = nullptr) { + const std::string& vehicle, game::DamageRecorder* recorder = nullptr, + std::optional> owner = {}) { const auto impacts = fx.takeImpacts(); if (!m_bangers || !m_bangerData) return; const Mat34 body = sim.modelMatrix(); // vehBreakableMgr::Init: the car's matrix auto eject = [&](const game::VehicleRenderer::Breakable& b, float speed) { - if (!ejectCarPart(r, vehicle, b, body, speed, sim.body.room)) + const std::uint32_t tag = + owner ? netPartTag(owner->first, owner->second, b.part, r.paintjob()) : 0; + if (!ejectCarPart(r, vehicle, b, body, speed, sim.body.room, tag)) return false; if (recorder) recorder->part(m_netStateTime, b.part); @@ -3571,7 +3585,8 @@ class RaceScreen final : public Screen { // thrown off at `speed` as a banger of its tune/banger data // (_); false (the part stays on) without data. bool ejectCarPart(game::VehicleRenderer& r, const std::string& vehicle, - const game::VehicleRenderer::Breakable& b, const Mat34& body, float speed, int room) { + const game::VehicleRenderer::Breakable& b, const Mat34& body, float speed, int room, + std::uint32_t tag = 0) { const auto* data = m_bangers && m_bangerData ? m_bangerData->find(vehicle + "_" + str::lower(b.part)) : nullptr; if (!data) @@ -3583,7 +3598,7 @@ class RaceScreen final : public Screen { m_bangers->detachHit(i); }); r.detach(b.part, m_bangers->ejectPart(*data, vehicle, b.part, r.paintjob(), - Mat34::translation(b.pivot) * body, speed, room)); + Mat34::translation(b.pivot) * body, speed, room, tag)); return true; } @@ -3600,19 +3615,22 @@ class RaceScreen final : public Screen { // into the body (fxTexelDamage::ApplyDamage, TextelDamageRadius). // OpenMM2: with a `recorder` the other machines paint the same patch // (at the point as it travels, from the same random state). + using Owner = std::optional>; auto paint = [this](game::fx::VehicleEffects& fx, game::VehicleRenderer& r, const phys::CarSim& sim, - const std::string& vehicle, game::DamageRecorder* recorder = nullptr) { + const std::string& vehicle, game::DamageRecorder* recorder = nullptr, + Owner owner = {}) { if (auto p = fx.takeDamagePoint()) { const Vec3 point = recorder ? recorder->patch(m_netStateTime, *p, r.texelDamageState()) : *p; r.applyDamage(point, sim.damage.params.textelDamageRadius); } - breakParts(fx, r, sim, vehicle, recorder); + breakParts(fx, r, sim, vehicle, recorder, owner); }; if (m_player && m_vehicleFx) { m_vehicleFx->update(dt, m_player->sim(), vehicleFxContext(m_player->sim())); if (m_vehicle) paint(*m_vehicleFx, *m_vehicle, m_player->sim(), m_player->model().baseName, - m_result.config.multiplayer ? &m_netDamage.own() : nullptr); + m_result.config.multiplayer ? &m_netDamage.own() : nullptr, + m_result.config.multiplayer ? Owner{{net::PropOwner::Player, m_netLocalId}} : Owner{}); } for (auto& o : m_opponents) if (o.fx) { @@ -3622,7 +3640,9 @@ class RaceScreen final : public Screen { for (auto& c : m_cops) { if (c.fx) { c.fx->update(dt, c.sim->sim(), vehicleFxContext(c.sim->sim())); - paint(*c.fx, *c.renderer, c.sim->sim(), c.sim->model().baseName, c.damage); + const int model = m_trafficCatalog.find(c.sim->model().baseName); + paint(*c.fx, *c.renderer, c.sim->sim(), c.sim->model().baseName, c.damage, + m_trafficHost ? Owner{{net::PropOwner::Catalog, model}} : Owner{}); } if (c.driver->siren()) c.sirenAngle = std::fmod(c.sirenAngle + dt * 2.5f * 3.1415927f, 6.2831855f); @@ -3694,6 +3714,10 @@ class RaceScreen final : public Screen { m_vehicleFx->impact(impact, m_player->sim()); if (m_result.config.multiplayer && impact.damaging) // OpenMM2: the others see it too m_netDamage.own().impact(m_netStateTime, game::damageImpactOf(impact, m_player->sim())); + if (impact.damaging) + if (auto* trace = m_netProps.trace()) // OPENMM2_DEBUG_NETPROPS + trace->impact(m_netStateTime, propToucher(impact.otherBody), impact.value, + m_player->sim().damage.currentDamage); if (impact.damaging) { ++(impact.otherIsBody ? m_vehicleImpacts : m_objectImpacts); m_ff.impact(impact.total, m_player->sim().speedMph()); // mmPlayer::FFImpactCallback @@ -4601,7 +4625,15 @@ class RaceScreen final : public Screen { t.speed = std::abs(velocity.dot(body.m2)); t.texelRadius = sim.damage.params.textelDamageRadius; t.eject = [&](const game::VehicleRenderer::Breakable& b, float speed) { - return ejectCarPart(renderer, vehicle, b, body, speed, sim.body.room); + // OpenMM2: a client shows the parts the host throws (its ring), + // the host throws them for everyone. + if (m_netProps.hostThrowsParts()) + return false; + const bool player = (key & 0x10000u) != 0; + const auto owner = player ? net::PropOwner::Player : net::PropOwner::Catalog; + const int ownerId = player ? static_cast(key & 0xFFu) : m_trafficCatalog.find(vehicle); + const std::uint32_t tag = netPartTag(owner, ownerId, b.part, renderer.paintjob()); + return ejectCarPart(renderer, vehicle, b, body, speed, sim.body.room, tag); }; t.sound = [&](const Vec3& position, float strength, int audioId) { if (sounds.size() < 16) // taken by the car's audio each frame @@ -4713,6 +4745,116 @@ class RaceScreen final : public Screen { m_netDamage.logStats(now); } + // --- OpenMM2: the host's props (game/net/NetProps) --------------------------------------- + + // This machine's own car (or its trailer): on a client the only thing + // that may set a prop moving (the host decides what the others do). + bool ownCarBody(const phys::Instance* other) const { + if (!m_player || !other) + return false; + if (other == &m_player->sim().body) + return true; + const phys::Trailer* trailer = m_player->trailer(); + return trailer && other == &trailer->body; + } + + // What set a prop moving, for OPENMM2_DEBUG_NETPROPS. + std::string propToucher(const phys::Instance* by) const { + if (!by) + return "world"; + if (ownCarBody(by)) + return "own"; + for (const auto& [id, rv] : m_remotes) { + const phys::Trailer* trailer = rv.sim ? rv.sim->trailer() : nullptr; + if (rv.sim && (by == &rv.sim->sim().body || (trailer && by == &trailer->body))) + return std::format("player{}", id); + } + for (const auto& c : m_cops) + if (c.sim && by == &c.sim->sim().body) + return "police"; + for (const auto& [id, c] : m_netCops) + if (c.sim && by == &c.sim->sim().body) + return "police"; + if (m_bangers && m_bangers->isActiveBody(by)) + return "prop"; + return "traffic"; // the traffic's bodies and rail cars (or anything else) + } + + // The session time the simulation will have reached after this frame's + // fixed steps (before them), or has reached (after them). + double netSimTime(Context& ctx, float dt, bool afterSteps) const { + const float left = !m_world ? 0.0f : afterSteps ? m_world->remainder() : m_world->remainderAfter(dt); + return ctx.netGame->frameTime() - static_cast(left) * 1000.0; + } + + // A car part's owner as the props name it (game::carPartTag): this + // machine's car, another player's, or a shared police car. + std::uint32_t netPartTag(net::PropOwner owner, int ownerId, const std::string& part, int paint) const { + const int index = game::damagePartIndex(part); + if (!m_result.config.multiplayer || index < 0 || ownerId < 0) + return 0; + return game::carPartTag(owner, ownerId, index, paint); + } + + // Client: before the physics steps, the host's props. + void updateNetProps(Context& ctx, float dt) { + if (!multiplayer(ctx) || !m_bangers) + return; + m_netProps.beforeStep(*ctx.netGame, m_netEvents, netSimTime(ctx, dt, false), + [this, &ctx](const net::PropDescriptor& d) { return netCarPart(ctx, d); }); + } + + // The banger a thrown part of another machine's car is drawn as. + std::optional netCarPart(Context& ctx, + const net::PropDescriptor& d) { + std::string vehicle; + if (d.owner == net::PropOwner::Player) { + if (d.ownerId == ctx.netGame->localId() && m_player) + vehicle = m_player->model().baseName; + else if (const auto it = m_remotes.find(d.ownerId); it != m_remotes.end() && it->second.sim) + vehicle = it->second.sim->model().baseName; + else + vehicle = game::netVehicle(ctx.game->catalog, ctx.netGame->playerCar(d.ownerId).vehicle); + } else if (const std::string* model = m_trafficCatalog.name(d.ownerId)) { + vehicle = *model; + } + const std::string part = game::damagePartName(d.part, d.paint); + if (vehicle.empty() || part.empty() || !m_bangerData) + return std::nullopt; + game::bangers::BangerSet::MirrorSpec spec; + spec.data = m_bangerData->find(vehicle + "_" + str::lower(part)); + if (!spec.data) + return std::nullopt; + spec.model = vehicle; + spec.mesh = part; + spec.paint = d.paint; + spec.tag = game::carPartTag(d.owner, d.ownerId, d.part, d.paint); + return spec; + } + + // After the props' update: the host's props to the others; a client's + // predictions; the trace. + void sendNetProps(Context& ctx) { + if (!multiplayer(ctx) || !m_bangers) + return; + const double now = netSimTime(ctx, 0.0f, true); + m_netProps.afterStep(*ctx.netGame, now, net::monotonicMs(), + [this](const phys::Instance* by) { return propToucher(by); }); + auto* trace = m_netProps.trace(); + if (!trace || !m_netProps.traced()) + return; + // Each car's damage as this machine shows it: the replicated level + // (0..1 between MedDamage and MaxDamage) and the dents painted. + if (m_player) + trace->car(std::format("player{}", ctx.netGame->localId()), m_player->sim().damage.damage, + static_cast(m_netDamage.own().recorded())); + for (const auto& rc : m_remoteCars) { + const auto dents = m_netDamage.replica().recordSize(game::DamageReplica::playerKey(rc.id)); + if (rc.hasState) + trace->car(std::format("player{}", rc.id), rc.damage, static_cast(dents)); + } + } + double m_debugInputTime = 0.0; // OPENMM2_DEBUG_INPUT's phases void updatePlayer(Context& ctx, float dt) { @@ -5316,6 +5458,8 @@ class RaceScreen final : public Screen { // session time the simulation's state belongs to this frame. game::NetDamage m_netDamage; std::uint32_t m_netStateTime = 0; + game::NetProps m_netProps; // OpenMM2: the host's props (game/net/NetProps) + int m_netLocalId = -1; // this player's id in a network race std::vector m_remoteCars; // this frame's sample (updateRemoteCars) std::map m_remoteDrawn; // and where they are drawn std::map m_remoteStepBodies; // their bodies in the last sample (recordStepPoses) diff --git a/src/game/bangers/BangerSet.cpp b/src/game/bangers/BangerSet.cpp index b012cf3..7ee25f9 100644 --- a/src/game/bangers/BangerSet.cpp +++ b/src/game/bangers/BangerSet.cpp @@ -223,12 +223,15 @@ class BangerSet::Prop final : public phys::Instance { r = d->yRadius; return true; } - void bangerHit(phys::Instance& /*by*/, const Vec3& /*position*/) override { m_set.unhitImpact(m_index); } + void bangerHit(phys::Instance& by, const Vec3& /*position*/) override { m_set.unhitImpact(m_index, &by); } // The world's call on a prop's entity that held: dgBangerActive::DetachMe. void bangerHeld() override { if (Active* a = m_set.activeOf(m_index)) m_set.detachMe(*a); } + // OpenMM2: a network client's props take contacts from its own car and + // its own props only. + bool acceptsContact(const phys::Instance& other) const override { return m_set.acceptsFrom(other); } bool banger = false; // lvlInstance flag 1: a placed prop still standing bool listed = false; // in m_rooms[room] @@ -545,7 +548,9 @@ std::size_t BangerSet::getBanger() { m_instances[i].everHit = true; m_instances[i].state = State::Gone; m_ring.push_back(i); + m_ringGeneration.push_back(0); } + ++m_ringGeneration[slot]; // OpenMM2: a new prop in the slot (network games) const std::size_t i = m_ring[slot]; // Its previous prop disappears. if (Active* a = activeOf(i)) @@ -624,6 +629,7 @@ void BangerSet::activeAttach(Active& a, std::size_t i) { prop.collidable = false; inst.active = a.index; inst.state = State::Active; + inst.drawn.reset(); // OpenMM2: a client's mirror is drawn from the body now a.instance = static_cast(i); // The collider: the data's bound at the instance's matrix, with the @@ -767,7 +773,7 @@ void BangerSet::syncActiveList() { // --- Breaking loose -------------------------------------------------------------------------- -void BangerSet::unhitImpact(std::size_t i) { +void BangerSet::unhitImpact(std::size_t i, const phys::Instance* by) { // dgUnhitBangerInstance::Impact: called by the world once dgImpact's // impulses broke the prop loose; its active holds them. const Mat34 frame = m_instances[i].matrix; @@ -777,6 +783,8 @@ void BangerSet::unhitImpact(std::size_t i) { return; // OpenMM2 guard: AttachEntity always gave it an active const BangerData& d = *data; Prop& unhitProp = *m_props[i]; + if (m_recordKnocks && !m_instances[i].everHit) + m_knocks.push_back({i, by}); // OpenMM2: network games (game/net/PropSync) if (d.numParts == 0) { // A hit instance from the ring takes the prop's place in its room @@ -801,6 +809,8 @@ void BangerSet::unhitImpact(std::size_t i) { hit.paint = unhit.paint; hit.ground = unhit.ground; hit.everHit = true; + hit.source = unhit.everHit ? unhit.source : static_cast(i); + hit.tag = unhit.tag; Prop& hitProp = *m_props[h]; hitProp.collidable = false; hitProp.audioId = d.colliderId; @@ -856,6 +866,8 @@ void BangerSet::unhitImpact(std::size_t i) { hit.mesh.clear(); hit.paint = paint; hit.everHit = true; + hit.source = m_instances[i].everHit ? m_instances[i].source : static_cast(i); + hit.tag = 0; Prop& hitProp = *m_props[h]; hitProp.collidable = false; if (!hit.data) { @@ -1017,7 +1029,7 @@ void BangerSet::update(float dt) { // --- Car parts ------------------------------------------------------------------------------- std::size_t BangerSet::ejectPart(const BangerData& data, const std::string& model, const std::string& mesh, - int paint, const Mat34& frame, float speed, int room) { + int paint, const Mat34& frame, float speed, int room, std::uint32_t tag) { // vehBreakableMgr::Eject: a hit instance from the ring in the car's room, // attached, then pushed off. MM2 writes the random "velocity" into the // body's momentum (phInertialCS's) and adds the random spin to its @@ -1035,6 +1047,8 @@ std::size_t BangerSet::ejectPart(const BangerData& data, const std::string& mode inst.paint = paint; inst.ground = frame; inst.everHit = true; + inst.source = -1; + inst.tag = tag; m_props[h]->audioId = data.colliderId; inst.matrix = frame; // SetMatrix if (!attachEntity(h)) @@ -1079,6 +1093,128 @@ std::size_t BangerSet::ejectPart(const BangerData& data, const std::string& mode return h; } +// --- OpenMM2: network games ------------------------------------------------------------------ + +bool BangerSet::moving(std::size_t i) const { + if (i >= m_instances.size() || m_instances[i].active < 0) + return false; + return inWorld(*m_active[static_cast(m_instances[i].active)]); +} + +bool BangerSet::isActiveBody(const phys::Instance* i) const { + for (const auto& a : m_active) + if (&a->body == i) + return true; + return false; +} + +void BangerSet::setReplica(std::function localToucher) { + m_replica = true; + m_localToucher = std::move(localToucher); +} + +bool BangerSet::acceptsFrom(const phys::Instance& other) const { + if (!m_replica) + return true; + // The props this machine simulates (its predictions) go on hitting + // others as they would. + return isActiveBody(&other) || (m_localToucher && m_localToucher(other)); +} + +void BangerSet::breakPlaced(std::size_t i) { + // As dgUnhitBangerInstance::Impact leaves the prop: out of the room + // lists, remembering its room, no longer a banger (flag 1). + if (i >= m_instances.size() || m_instances[i].everHit || !m_props[i]->banger) + return; + if (Active* a = activeOf(i)) + detachMe(*a); + Prop& prop = *m_props[i]; + const int room = m_instances[i].room; + moveToRoom(i, 0); + prop.banger = false; + prop.room = room; + m_instances[i].room = room; + m_instances[i].state = State::Gone; +} + +void BangerSet::restoreStanding(std::size_t i) { + if (i >= m_instances.size() || m_instances[i].everHit) + return; + // The pieces it became on this machine (its own ring) disappear. + for (const std::size_t h : m_ring) + if (m_instances[h].source == static_cast(i) && m_instances[h].state != State::Gone) + detachHit(h); + Prop& prop = *m_props[i]; + if (prop.banger) + return; + // dgUnhitBangerInstance::Reset: back in its room, standing. + Instance& inst = m_instances[i]; + if (Active* a = activeOf(i)) + detachMe(*a); + const int room = inst.room; + if (prop.listed) + moveToRoom(i, 0); + moveToRoom(i, room); + prop.banger = true; + prop.collidable = true; + inst.state = State::Unhit; +} + +std::size_t BangerSet::mirror(std::size_t slot) { + constexpr std::size_t none = static_cast(-1); + if (slot >= m_mirrors.size()) + m_mirrors.resize(slot + 1, none); + if (m_mirrors[slot] == none) { + const std::size_t i = newInstance(); + m_instances[i].everHit = true; + m_instances[i].mirror = true; + m_instances[i].state = State::Gone; + m_props[i]->collidable = false; + m_mirrors[slot] = i; + } + return m_mirrors[slot]; +} + +void BangerSet::showMirror(std::size_t slot, const MirrorSpec& spec, const Mat34& matrix, const Mat34& drawn, + bool collidable) { + const std::size_t i = mirror(slot); + Instance& inst = m_instances[i]; + if (inst.active >= 0 || !spec.data) + return; + boundsOf(*spec.data); + inst.data = spec.data; + inst.model = spec.model; + inst.part = spec.part; + inst.mesh = spec.mesh; + inst.paint = spec.paint; + inst.source = spec.source; + inst.tag = spec.tag; + inst.matrix = matrix; + inst.ground = matrix; + inst.drawn = drawn; + inst.state = State::Hit; + Prop& prop = *m_props[i]; + prop.audioId = spec.data->colliderId; + prop.collidable = collidable; + // Its room, as an active's body finds its own after each sample. + const int room = roomsTracked() ? findRoom(matrix.m3, inst.room) : inst.room; + if (room != inst.room || (room > 0 && !prop.listed)) + moveToRoom(i, room); +} + +void BangerSet::hideMirror(std::size_t slot) { + if (slot >= m_mirrors.size() || m_mirrors[slot] == static_cast(-1)) + return; + const std::size_t i = m_mirrors[slot]; + if (Active* a = activeOf(i)) + detachMe(*a); + if (m_instances[i].room != 0 || m_props[i]->listed) + moveToRoom(i, 0); + m_instances[i].state = State::Gone; + m_instances[i].drawn.reset(); + m_props[i]->collidable = false; +} + // --- Drawing --------------------------------------------------------------------------------- void BangerSet::draw(render::Device& device, ModelLibrary& models, TextureLibrary& textures, fx::ParticleRenderer& cards, @@ -1113,8 +1249,9 @@ void BangerSet::draw(render::Device& device, ModelLibrary& models, TextureLibrar if (!model) continue; const float radius = (model->bounds.max - model->bounds.min).mag() * 0.5f; - const Mat34 matrix = - inst.active >= 0 && params.drawnMatrix ? params.drawnMatrix(i, inst.matrix) : inst.matrix; + const Mat34 matrix = inst.drawn ? *inst.drawn + : inst.active >= 0 && params.drawnMatrix ? params.drawnMatrix(i, inst.matrix) + : inst.matrix; // lvlInstance::IsVisible with the dynamic objects' NoDraw limit. const auto lod = objectLod(viewDepth(cam, matrix.m3), radius, params.detail, params.detail.noDraw); const bool visible = lod && frustum.intersectsSphere(matrix.m3, radius); diff --git a/src/game/bangers/BangerSet.h b/src/game/bangers/BangerSet.h index 749f728..0a7ee73 100644 --- a/src/game/bangers/BangerSet.h +++ b/src/game/bangers/BangerSet.h @@ -28,6 +28,7 @@ #include #include #include +#include #include namespace mm2::game::bangers { @@ -122,6 +123,15 @@ class BangerSet final : public InstanceSource { int active = -1; // index into the active pool int room = 0; int roomHint = 0; // with room 0: where FindRoomId starts (an xref's parent room) + // OpenMM2 network games (game/net/PropSync). A hit instance: the + // placed prop it came from (whole, or its BREAKnn `part`), else -1; + // the caller's tag of an ejected car part (0: none). + int source = -1; + std::uint32_t tag = 0; + // One of the host's ring slots shown on a network client + // (showMirror), and where it is drawn while the host drives it. + bool mirror = false; + std::optional drawn; }; // The placed props (in add() order) and the hit instances (created as // the ring first hands them out). @@ -138,6 +148,7 @@ class BangerSet final : public InstanceSource { const phys::Bound* boundOf(const BangerData& data, int which) const; float boundRadius(const BangerData& data) const; std::size_t skipped() const { return m_skipped; } // props without banger data + const BangerDataLibrary& dataLibrary() const { return m_data; } int activeCount() const { return m_attached; } // dgBangerDataManager's age mode: actives declared by age rather than by // CollisionType. mmGame::Init turns it off; kept for completeness. @@ -151,8 +162,10 @@ class BangerSet final : public InstanceSource { // `speed` +- 1 in a random upward direction and an angular impulse of // 1-3 (as momentum, not velocity: see the .cpp). Returns the hit // instance it became (vehBreakable +0x44 keeps it for Reset). + // `tag` (OpenMM2): the caller's name for the part across machines + // (Instance::tag), 0 for none. std::size_t ejectPart(const BangerData& data, const std::string& model, const std::string& mesh, int paint, - const Mat34& frame, float speed, int room = -1); + const Mat34& frame, float speed, int room = -1, std::uint32_t tag = 0); // dgHitBangerInstance::Detach of instance i, as vehBreakableMgr::Reset // calls it for an ejected car part when the car's damage is cleared: its @@ -175,6 +188,79 @@ class BangerSet final : public InstanceSource { // Impact clears the flag). dgBangerInstance::DrawGlow tests it. bool standing(std::size_t i) const; + // --- OpenMM2: network games (game/net/PropSync) --------------------------------------- + // + // MM2 sends nothing about props: every machine knocks its own with its + // own simulation of every car. OpenMM2's host simulates them for + // everyone; its clients show the host's and simulate only what their + // own car touches (a prediction the host confirms or corrects). + + // A placed prop that broke loose (dgUnhitBangerInstance::Impact) and + // what broke it (the instance whose impact did; null when unknown, and + // only valid until the next physics step). + struct Knock { + std::size_t prop = 0; + const phys::Instance* by = nullptr; + }; + // Whether knocks are kept for takeKnocks (off by default: a + // single-player race never takes them). + void recordKnocks(bool on) { m_recordKnocks = on; } + // The knocks since the last call, oldest first. + std::vector takeKnocks() { return std::exchange(m_knocks, {}); } + // dgBangerManager's ring as handed out so far: slot k's hit instance, + // and how many times slot k has been handed out (a new prop in the slot + // has a new generation). + const std::vector& ring() const { return m_ring; } + std::uint32_t generation(std::size_t slot) const { + return slot < m_ringGeneration.size() ? m_ringGeneration[slot] : 0; + } + // Whether instance i has an active whose body is moving in the world. + bool moving(std::size_t i) const; + // Whether `i` is the body of one of this set's actives. + bool isActiveBody(const phys::Instance* i) const; + + // A network client: only this machine's own car (`localToucher`) and the + // props this set simulates itself may touch a prop (phys::Instance:: + // acceptsContact); everything else passes through them, since the host + // decides what the other cars do to them. + void setReplica(std::function localToucher); + // Back to simulating every contact (a client that cannot follow the + // host's props). + void clearReplica() { + m_replica = false; + m_localToucher = nullptr; + } + bool replica() const { return m_replica; } + // Client: placed prop i broke loose on the host. It leaves its room as + // dgUnhitBangerInstance::Impact makes it leave, without a body (its + // pieces are the host's ring slots, showMirror). + void breakPlaced(std::size_t i); + // Client: a knock this machine predicted that the host did not make. + // Placed prop i stands in its room again and the hit instances it became + // here disappear (dgUnhitBangerInstance::Reset for one prop). + void restoreStanding(std::size_t i); + // Client: what one of the host's ring slots holds. + struct MirrorSpec { + const BangerData* data = nullptr; + std::string model; + int part = -1; + std::string mesh; + int paint = 0; + int source = -1; + std::uint32_t tag = 0; + }; + // The instance showing host ring slot `slot` (made on first use, hidden). + std::size_t mirror(std::size_t slot); + // Shows host ring slot `slot` at `matrix` (the frame at the CG), drawn at + // `drawn`; `collidable` while the host has it at rest (this machine's car + // may then push it, simulating it here until it rests again). Not while + // the instance has an active: the local simulation drives it then. + void showMirror(std::size_t slot, const MirrorSpec& spec, const Mat34& matrix, const Mat34& drawn, + bool collidable); + // Takes it out of the world (not drawn, not collidable, no body). + void hideMirror(std::size_t slot); + std::size_t mirrorCount() const { return m_mirrors.size(); } + private: struct Active; struct ActiveBody; @@ -201,8 +287,9 @@ class BangerSet final : public InstanceSource { void declare(Active& a, float dt); void directUpdate(Active& a, float dt); bool inWorld(const Active& a) const; - void unhitImpact(std::size_t i); + void unhitImpact(std::size_t i, const phys::Instance* by); void syncActiveList(); + bool acceptsFrom(const phys::Instance& other) const; const BangerDataLibrary& m_data; phys::World* m_world = nullptr; @@ -219,7 +306,14 @@ class BangerSet final : public InstanceSource { int m_attached = 0; std::vector m_activeList; // the attached ones (the list's head), for drawing std::vector m_ring; // dgBangerManager's hit instances by slot + std::vector m_ringGeneration; // times each slot was handed out int m_ringNext = 0; + // OpenMM2 network games. + std::vector m_knocks; + bool m_recordKnocks = false; + bool m_replica = false; + std::function m_localToucher; + std::vector m_mirrors; // a client's instance per host ring slot (npos: none yet) std::size_t m_skipped = 0; bool m_ageMode = false; // dgBangerDataManager +0x2a8a8 (cleared by mmGame::Init) fx::FixedTicker m_ticker; diff --git a/src/game/net/NetGame.cpp b/src/game/net/NetGame.cpp index 4f580d9..76cf42d 100644 --- a/src/game/net/NetGame.cpp +++ b/src/game/net/NetGame.cpp @@ -803,6 +803,14 @@ std::vector NetGame::takeAmbientStates() { return m_impl->session ? m_impl->session->takeAmbientStates() : std::vector{}; } +std::size_t NetGame::sendPropState(std::uint8_t playerId, const net::PropStateMsg& msg) { + return m_impl->session ? m_impl->session->sendPropState(playerId, msg) : 0; +} + +std::vector NetGame::takePropStates() { + return m_impl->session ? m_impl->session->takePropStates() : std::vector{}; +} + net::PeerStats NetGame::peerStats(std::uint8_t playerId) const { return m_impl->session ? m_impl->session->peerStats(playerId) : net::PeerStats{}; } diff --git a/src/game/net/NetGame.h b/src/game/net/NetGame.h index 37af7af..55791bd 100644 --- a/src/game/net/NetGame.h +++ b/src/game/net/NetGame.h @@ -11,6 +11,7 @@ #include "core/Math.h" #include "game/RaceConfig.h" #include "net/AmbientState.h" +#include "net/PropState.h" #include "net/Discovery.h" #include "net/Protocol.h" #include "net/Snapshot.h" @@ -304,6 +305,12 @@ class NetGame { // Connection statistics to a player (host) or to the host (client). net::PeerStats peerStats(std::uint8_t playerId) const; + // --- The host's props (OpenMM2, net/PropState.h, game::PropSync) ------------------ + // Host: sends one player the props' state (unreliable); returns the bytes sent. + std::size_t sendPropState(std::uint8_t playerId, const net::PropStateMsg& msg); + // Client: the host's messages received since the last call. + std::vector takePropStates(); + // 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/NetProps.cpp b/src/game/net/NetProps.cpp new file mode 100644 index 0000000..9179810 --- /dev/null +++ b/src/game/net/NetProps.cpp @@ -0,0 +1,236 @@ +// A network race's props as the race screen runs them (OpenMM2; see +// NetProps.h). + +#include "game/net/NetProps.h" + +#include "core/Log.h" +#include "game/net/NetGame.h" +#include "net/PropState.h" + +#include +#include +#include + +namespace mm2::game { + +// --- Trace --------------------------------------------------------------------------------------- + +std::unique_ptr PropTrace::fromEnvironment() { + const char* path = std::getenv("OPENMM2_DEBUG_NETPROPS"); + if (!path || !*path) + return nullptr; + std::FILE* f = std::fopen(path, "w"); + if (!f) { + log::warn("netprops: cannot write the trace {}", path); + return nullptr; + } + std::fputs("# OpenMM2 network props trace 1\n", f); + return std::make_unique(f); +} + +PropTrace::~PropTrace() { + if (m_file) + std::fclose(m_file); +} + +std::string describe(const net::PropDescriptor& what) { + switch (what.source) { + case net::PropSource::Prop: return std::format("p{}", what.prop); + case net::PropSource::PropPart: return std::format("p{}.{}", what.prop, what.part); + case net::PropSource::CarPart: + return std::format("c{}{}.{}", what.owner == net::PropOwner::Player ? "p" : "m", what.ownerId, + what.part); + } + return "?"; +} + +void PropTrace::knock(double t, std::size_t prop, std::string_view model, std::string_view cause) { + std::fprintf(m_file, "K %.0f %zu %.*s %.*s\n", t, prop, static_cast(model.size()), model.data(), + static_cast(cause.size()), cause.data()); +} + +void PropTrace::undo(double t, std::size_t prop) { std::fprintf(m_file, "X %.0f %zu\n", t, prop); } + +void PropTrace::impact(double t, std::string_view cause, float value, float damage) { + std::fprintf(m_file, "I %.0f %.*s %.1f %.1f\n", t, static_cast(cause.size()), cause.data(), value, + damage); +} + +bool PropTrace::tick(double t) { + constexpr double kTick = 250.0; + if (m_next < 0.0) + m_next = std::ceil(t / kTick) * kTick; + if (t < m_next) + return false; + std::fprintf(m_file, "T %.0f %.1f\n", m_next, t); + m_next += kTick * (std::floor((t - m_next) / kTick) + 1.0); + std::fflush(m_file); + return true; +} + +void PropTrace::broken(const bangers::BangerSet& set) { + std::fputs("B", m_file); + const std::size_t n = placedProps(set); + for (std::size_t i = 0; i < n; ++i) + if (!set.standing(i)) + std::fprintf(m_file, " %zu", i); + std::fputs("\n", m_file); +} + +void PropTrace::shown(const net::PropDescriptor& what, const Mat34& m, bool moving, bool local) { + std::fprintf(m_file, "H %s %.3f %.3f %.3f %d %d\n", describe(what).c_str(), m.m3.x, m.m3.y, m.m3.z, + moving ? 1 : 0, local ? 1 : 0); +} + +void PropTrace::car(std::string_view name, float level, float dents) { + std::fprintf(m_file, "D %.*s %.4f %.0f\n", static_cast(name.size()), name.data(), level, dents); +} + +// --- NetProps ------------------------------------------------------------------------------------ + +NetProps::NetProps() = default; +NetProps::~NetProps() = default; + +void NetProps::setup(NetGame& net, bangers::BangerSet& set, + std::function localToucher) { + m_set = &set; + m_catalog = propCatalog(set); + m_trace = PropTrace::fromEnvironment(); + set.recordKnocks(true); + const std::size_t placed = placedProps(set); + if (const char* mode = std::getenv("OPENMM2_NETPROPS"); mode && std::string_view(mode) == "local") { + log::info("race: props, each machine its own (OPENMM2_NETPROPS=local; {} placed, catalog {:08x})", + placed, m_catalog); + return; + } + if (net.isHost()) { + m_host.emplace(); + } else { + m_client.emplace(m_catalog); + set.setReplica(std::move(localToucher)); + } + log::info("race: props, {} ({} placed, catalog {:08x})", net.isHost() ? "host" : "client", placed, + m_catalog); +} + +void NetProps::beforeStep(NetGame& net, std::span events, double now, + const PropClient::CarPartResolver& resolve) { + auto messages = net.takePropStates(); + if (!m_client || !m_set) + return; + for (const auto& msg : messages) + m_client->receive(msg, net.frameTime()); + for (const auto& ev : events) { + if (static_cast(ev.type) != net::kPropKnocksEvent || ev.from != net::kHostPlayerId) + continue; + if (const auto knocks = ev.as()) + m_client->receiveKnocks(*knocks); + } + m_client->update(*m_set, now, resolve); +} + +void NetProps::sendCatchUps(NetGame& net, std::uint32_t time) { + for (const auto& p : net.players()) { + if (p.id == net.localId() || !net.playerLoaded(p.id) || m_caughtUp.contains(p.id)) + continue; + m_caughtUp.insert(p.id); + for (auto& payload : PropHost::catchUp(*m_set, time)) { + m_eventBytes += payload.size(); + ++m_sentEvents; + net.sendEvent(net::kPropKnocksEvent, std::move(payload), p.id); + } + } +} + +void NetProps::afterStep(NetGame& net, double now, std::uint64_t nowMs, const Classifier& classify) { + m_traced = false; + if (!m_set) + return; + const auto knocks = m_set->takeKnocks(); + if (m_trace) + for (const auto& k : knocks) + m_trace->knock(now, k.prop, m_set->instances()[k.prop].model, classify ? classify(k.by) : "?"); + const auto time = static_cast(std::max(0.0, now)); + if (m_host) { + m_host->knocked(knocks, time); + for (auto& payload : m_host->takeKnockEvents()) { + m_eventBytes += payload.size(); + ++m_sentEvents; + net.sendEvent(net::kPropKnocksEvent, std::move(payload)); + } + sendCatchUps(net, time); + if (const auto msg = m_host->build(*m_set, time, nowMs, m_catalog)) { + for (const auto& p : net.players()) { + if (p.id == net.localId() || !net.playerLoaded(p.id)) + continue; + const std::size_t bytes = net.sendPropState(p.id, *msg); + m_sentBytes += bytes; + m_host->stats().bytes += bytes; + ++m_sentMessages; + } + } + } + if (m_client) { + m_client->predicted(knocks, now); + for (const auto& [prop, undone] : m_client->takeApplied()) { + if (!m_trace) + continue; + if (undone) + m_trace->undo(now, prop); + else + m_trace->knock(now, prop, m_set->instances()[prop].model, "host"); + } + } + if (m_trace && m_trace->tick(now)) { + m_traced = true; + m_trace->broken(*m_set); + if (m_client) { + for (const auto& s : m_client->shown(*m_set)) + m_trace->shown(s.what, s.matrix, s.moving, s.local); + } else { + const auto& instances = m_set->instances(); + for (std::size_t i = 0; i < instances.size(); ++i) { + const auto& inst = instances[i]; + if (!inst.everHit || inst.state == bangers::BangerSet::State::Gone) + continue; + if (const auto d = describeHit(inst)) + m_trace->shown(*d, inst.matrix, m_set->moving(i), true); + } + } + } + logStats(net, nowMs); +} + +void NetProps::logStats(NetGame& net, std::uint64_t nowMs) { + if (m_statsAt == 0) { + m_statsAt = nowMs; + return; + } + if (nowMs - m_statsAt < 10000) + return; + const double seconds = static_cast(nowMs - m_statsAt) / 1000.0; + m_statsAt = nowMs; + if (m_host) { + auto& s = m_host->stats(); + const std::size_t clients = std::max(1, net.players().size() - 1); + if (s.messages > 0 || m_sentEvents > 0) + log::info("netprops: host sent {:.1f} messages/s ({:.0f} bytes/s to each of {} clients, {:.1f} " + "slots and {:.1f} states a message), {} knocks in {} events ({} bytes)", + static_cast(s.messages) / seconds, + static_cast(m_sentBytes) / seconds / static_cast(clients), clients, + s.messages ? static_cast(s.slots) / static_cast(s.messages) : 0.0, + s.messages ? static_cast(s.fullStates) / static_cast(s.messages) : 0.0, + s.knocks, m_sentEvents, m_eventBytes); + s = {}; + m_sentBytes = m_sentMessages = m_sentEvents = m_eventBytes = 0; + } + if (m_client) { + const auto& s = m_client->stats(); + log::info("netprops: client {} messages ({} late, {} refused), host knocks {}, predicted {} " + "(confirmed {}, undone {}), hand-overs {}, orphans {}, delay {:.0f} ms", + s.messages, s.outdated, s.refused, s.knocks, s.predicted, s.confirmed, s.undone, + s.handovers, s.orphans, m_client->delayMs()); + } +} + +} // namespace mm2::game diff --git a/src/game/net/NetProps.h b/src/game/net/NetProps.h new file mode 100644 index 0000000..62533d5 --- /dev/null +++ b/src/game/net/NetProps.h @@ -0,0 +1,114 @@ +#pragma once + +// A network race's props as the race screen runs them (OpenMM2; see +// PropSync.h and docs/multiplayer.md, "Props"): the host's PropHost or a +// client's PropClient over the race's BangerSet, the messages and events +// through NetGame, and an opt-in trace for measuring how the machines' +// props differ. + +#include "game/bangers/BangerSet.h" +#include "game/net/PropSync.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace mm2::game { + +class NetGame; +struct NetGameEvent; + +// Development aid: OPENMM2_DEBUG_NETPROPS= writes, one line each, every +// placed prop that broke loose on this machine ("K +// "), every knock this machine predicted and undid ("X ..."), +// and every 250 ms of session time ("T ") the props not standing +// ("B "), the knocked-over props and thrown parts shown ("H +// "), the cars' damage ("D ") +// and this machine's car's damaging impacts ("I +// "). `netprobe propdiff` compares two machines' files. +class PropTrace { +public: + static std::unique_ptr fromEnvironment(); + explicit PropTrace(std::FILE* file) : m_file(file) {} + ~PropTrace(); + PropTrace(const PropTrace&) = delete; + PropTrace& operator=(const PropTrace&) = delete; + + void knock(double t, std::size_t prop, std::string_view model, std::string_view cause); + void undo(double t, std::size_t prop); + void impact(double t, std::string_view cause, float value, float damage); + // Starts a tick when `t` passed the next 250 ms boundary. + bool tick(double t); + void broken(const bangers::BangerSet& set); + void shown(const net::PropDescriptor& what, const Mat34& m, bool moving, bool local); + // A car's damage level (0..1, as replicated) and its dents. + void car(std::string_view name, float level, float dents); + +private: + std::FILE* m_file = nullptr; + double m_next = -1.0; +}; + +std::string describe(const net::PropDescriptor& what); + +class NetProps { +public: + NetProps(); + ~NetProps(); + NetProps(const NetProps&) = delete; + NetProps& operator=(const NetProps&) = delete; + + // A network race, once its props are placed: the host simulates them + // for everyone; a client follows it, its props touched only by + // `localToucher` (its own car). OPENMM2_NETPROPS=local leaves every + // machine with its own props, as MM2 and OpenMM2 0.3 did (for + // comparison). + void setup(NetGame& net, bangers::BangerSet& set, + std::function localToucher); + bool host() const { return m_host.has_value(); } + bool client() const { return m_client.has_value(); } + // A client takes the cars' thrown parts from the host's ring (except + // its own car's, which it throws at once and hands over). + bool hostThrowsParts() const { return m_client && !m_client->catalogMismatch(); } + + // Client, before the frame's physics steps: the host's messages and + // knocks applied, the host's props placed for session time `now` (the + // time the simulation will have reached after the steps). + void beforeStep(NetGame& net, std::span events, double now, + const PropClient::CarPartResolver& resolve); + // Every machine, after the props' update (BangerSet::update): the + // frame's knocks (the host sends them, a client takes them as its + // predictions), the host's message, the trace. `classify` names what + // broke a prop loose (for the trace). + using Classifier = std::function; + void afterStep(NetGame& net, double now, std::uint64_t nowMs, const Classifier& classify); + + // The trace, when OPENMM2_DEBUG_NETPROPS is set; `traced()` tells whether + // afterStep began a tick this frame (the race adds its cars' lines). + PropTrace* trace() { return m_trace.get(); } + bool traced() const { return m_traced; } + + const std::optional& clientState() const { return m_client; } + +private: + void sendCatchUps(NetGame& net, std::uint32_t time); + void logStats(NetGame& net, std::uint64_t nowMs); + + bangers::BangerSet* m_set = nullptr; + std::optional m_host; + std::optional m_client; + std::uint32_t m_catalog = 0; + std::set m_caughtUp; // host: players sent every knock so far + std::unique_ptr m_trace; + bool m_traced = false; + std::uint64_t m_statsAt = 0; + std::uint64_t m_sentBytes = 0, m_sentMessages = 0, m_sentEvents = 0, m_eventBytes = 0; +}; + +} // namespace mm2::game diff --git a/src/game/net/PropSync.cpp b/src/game/net/PropSync.cpp new file mode 100644 index 0000000..bf512f0 --- /dev/null +++ b/src/game/net/PropSync.cpp @@ -0,0 +1,633 @@ +// The host's props in a network race (OpenMM2; see PropSync.h and +// docs/multiplayer.md, "Props"). + +#include "game/net/PropSync.h" + +#include "core/Log.h" +#include "core/StringUtil.h" + +#include +#include + +namespace mm2::game { +namespace { + +using bangers::BangerSet; + +constexpr std::uint32_t kFnvBasis = 2166136261u; +constexpr std::uint32_t kFnvPrime = 16777619u; + +void fnv(std::uint32_t& h, std::uint32_t v) { + for (int i = 0; i < 4; ++i) { + h ^= (v >> (i * 8)) & 0xFFu; + h *= kFnvPrime; + } +} + +std::int32_t centimetres(float v) { + if (!std::isfinite(v)) + return 0; + return static_cast(std::lround(std::clamp(v, -2.0e7f, 2.0e7f) * 100.0f)); +} + +// VehicleSnapshot::flags bit for a prop the host simulates. +constexpr std::uint8_t kMovingFlag = 1; + +Mat34 matrixOf(const net::VehicleSnapshot& s) { return s.orientation.toMatrix(s.position); } + +Mat34 blendMatrix(const Mat34& from, const Mat34& to, float t) { + const Quat q = Quat::slerp(Quat::fromMatrix(from), Quat::fromMatrix(to), t); + return q.toMatrix(lerp(from.m3, to.m3, t)); +} + +constexpr std::uint32_t kTagMarker = 0x80000000u; + +} // namespace + +std::size_t placedProps(const BangerSet& set) { + std::size_t n = 0; + for (const auto& inst : set.instances()) { + if (inst.everHit) + break; + ++n; + } + return n; +} + +std::uint32_t propCatalog(const BangerSet& set) { + std::uint32_t h = kFnvBasis; + const std::size_t n = placedProps(set); + for (std::size_t i = 0; i < n; ++i) { + const auto& inst = set.instances()[i]; + for (const char c : str::lower(inst.model)) { + h ^= static_cast(c); + h *= kFnvPrime; + } + fnv(h, static_cast(inst.paint)); + fnv(h, static_cast(centimetres(inst.ground.m3.x))); + fnv(h, static_cast(centimetres(inst.ground.m3.y))); + fnv(h, static_cast(centimetres(inst.ground.m3.z))); + } + fnv(h, static_cast(n)); + return h; +} + +std::uint32_t carPartTag(net::PropOwner owner, int ownerId, int part, int paint) { + const auto o = static_cast(owner) & 0x1u; + const auto id = static_cast(std::clamp(ownerId, 0, 255)); + const auto p = static_cast(std::clamp(part, 0, 255)); + const auto c = static_cast(std::clamp(paint, 0, 255)); + return kTagMarker | (o << 24) | (id << 16) | (p << 8) | c; +} + +std::optional carPartOfTag(std::uint32_t tag) { + if (!(tag & kTagMarker)) + return std::nullopt; + net::PropDescriptor d; + d.source = net::PropSource::CarPart; + d.owner = static_cast((tag >> 24) & 0x1u); + const auto id = (tag >> 16) & 0xFFu, part = (tag >> 8) & 0xFFu, paint = tag & 0xFFu; + if (id >= net::kMaxPropOwners || part >= net::kMaxPropCarParts || paint > net::kMaxPropPaint) + return std::nullopt; + d.ownerId = static_cast(id); + d.part = static_cast(part); + d.paint = static_cast(paint); + return d; +} + +std::optional describeHit(const BangerSet::Instance& inst) { + if (inst.source >= 0) { + if (static_cast(inst.source) >= net::kMaxPropIds) + return std::nullopt; + net::PropDescriptor d; + d.prop = static_cast(inst.source); + if (inst.part < 0) { + d.source = net::PropSource::Prop; + } else { + if (static_cast(inst.part) >= net::kMaxPropParts) + return std::nullopt; + d.source = net::PropSource::PropPart; + d.part = static_cast(inst.part); + } + return d; + } + if (inst.tag != 0) + return carPartOfTag(inst.tag); + return std::nullopt; +} + +// --- Host ---------------------------------------------------------------------------------------- + +void PropHost::knocked(std::span knocks, std::uint32_t time) { + for (const auto& k : knocks) { + if (k.prop >= net::kMaxPropIds) + continue; // beyond what the protocol names (no retail city has that many) + if (m_pending.empty()) + m_pendingTime = time; + const std::uint32_t delay = time >= m_pendingTime ? time - m_pendingTime : 0; + m_pending.push_back( + {static_cast(k.prop), static_cast(std::min(delay, 65535u))}); + ++m_stats.knocks; + } +} + +std::vector> PropHost::takeKnockEvents() { + // 31 bits a knock: 250 stay well within the 1 KiB payload. + constexpr std::size_t kPerEvent = 250; + std::vector> out; + for (std::size_t first = 0; first < m_pending.size(); first += kPerEvent) { + net::PropKnocksEvent e; + e.time = m_pendingTime; + const std::size_t last = std::min(m_pending.size(), first + kPerEvent); + e.knocks.assign(m_pending.begin() + static_cast(first), + m_pending.begin() + static_cast(last)); + out.push_back(net::encodePayload(e)); + } + m_pending.clear(); + return out; +} + +std::vector> PropHost::catchUp(const BangerSet& set, std::uint32_t time) { + // 15 bits a knock: 500 stay within the 1 KiB payload. + constexpr std::size_t kPerEvent = 500; + std::vector all; + const std::size_t n = std::min(placedProps(set), net::kMaxPropIds); + for (std::size_t i = 0; i < n; ++i) + if (!set.standing(i)) + all.push_back({static_cast(i), 0}); + std::vector> out; + for (std::size_t first = 0; first < all.size(); first += kPerEvent) { + net::PropKnocksEvent e; + e.time = time; + e.catchUp = true; + const std::size_t last = std::min(all.size(), first + kPerEvent); + e.knocks.assign(all.begin() + static_cast(first), + all.begin() + static_cast(last)); + out.push_back(net::encodePayload(e)); + } + return out; +} + +std::optional PropHost::build(const BangerSet& set, std::uint32_t time, + std::uint64_t nowMs, std::uint32_t catalog) { + const auto& ring = set.ring(); + const std::size_t slots = std::min(ring.size(), net::kMaxPropSlots); + if (m_seen.size() < slots) + m_seen.resize(slots); + bool busy = !m_sentAny, creeping = false; + struct Current { + std::optional what; + bool moving = false; + bool slow = false; + }; + std::vector current(slots); + for (std::size_t k = 0; k < slots; ++k) { + const auto& inst = set.instances()[ring[k]]; + Current& c = current[k]; + if (inst.state != BangerSet::State::Gone) + c.what = describeHit(inst); + c.moving = c.what && set.moving(ring[k]); + if (c.moving) + if (const phys::Body* body = set.body(ring[k])) + c.slow = body->ics.linearVelocity.mag2() < m_options.slowSpeed * m_options.slowSpeed && + body->ics.angularVelocity.mag2() < m_options.slowSpin * m_options.slowSpin; + SlotSeen& seen = m_seen[k]; + const bool occupied = c.what.has_value(); + const std::uint32_t generation = set.generation(k); + if (seen.generation != generation || seen.occupied != occupied || seen.moving != c.moving) { + seen.generation = generation; + seen.occupied = occupied; + seen.moving = c.moving; + seen.fresh = m_options.freshMessages; + } + if ((c.moving && !c.slow) || (occupied && seen.fresh > 0)) + busy = true; + creeping = creeping || c.slow; + } + const std::uint32_t interval = busy ? m_options.intervalMs + : creeping ? m_options.slowIntervalMs + : m_options.idleIntervalMs; + if (m_sentAny && nowMs - m_lastSent < interval) + return std::nullopt; + + net::PropStateMsg msg; + msg.time = time; + msg.catalog = catalog; + const int rotation = std::max(1, m_options.restRotation); + for (std::size_t k = 0; k < slots; ++k) { + const Current& c = current[k]; + if (!c.what) + continue; + SlotSeen& seen = m_seen[k]; + const auto& inst = set.instances()[ring[k]]; + net::PropSlot p; + p.slot = static_cast(k); + p.generation = static_cast(seen.generation % net::kPropGenerations); + // A creeping prop's state every fourth message while others move + // fast (every one at the slow interval). + const bool slowDue = !busy || (k + m_sequence) % 4 == 0; + p.hasState = (c.moving && (!c.slow || slowDue)) || seen.fresh > 0 || + static_cast(k % static_cast(rotation)) == + static_cast(m_sequence % static_cast(rotation)); + if (seen.fresh > 0) + --seen.fresh; + if (p.hasState) { + p.what = *c.what; + p.moving = c.moving; + p.position = inst.matrix.m3; + p.orientation = Quat::fromMatrix(inst.matrix); + if (c.moving) + if (const phys::Body* body = set.body(ring[k])) { + p.velocity = body->ics.linearVelocity; + p.angularVelocity = body->ics.angularVelocity; + } + ++m_stats.fullStates; + } + msg.slots.push_back(p); + } + m_lastSent = nowMs; + m_sentAny = true; + ++m_sequence; + ++m_stats.messages; + m_stats.slots += msg.slots.size(); + return msg; +} + +// --- Client -------------------------------------------------------------------------------------- + +PropClient::PropClient(std::uint32_t catalog, const Options& options) + : m_options(options), m_catalog(catalog), m_delay(options.initialDelayMs) {} + +void PropClient::receive(const net::PropStateMsg& msg, double arrival) { + ++m_stats.messages; + if (msg.catalog != m_catalog && !m_mismatch) { + m_mismatch = true; + log::warn("netprops: the host's props differ from this machine's (catalog {:08x}, ours {:08x}): only " + "the cars' parts follow the host", + msg.catalog, m_catalog); + } + m_lateness.emplace_back(arrival, arrival - static_cast(msg.time)); + while (m_lateness.size() > 256) + m_lateness.pop_front(); + const bool newer = !m_any || msg.time > m_latest; + if (!newer) + ++m_stats.outdated; + std::set listed; + for (const net::PropSlot& p : msg.slots) { + const int k = p.slot; + if (!listed.insert(k).second) { + ++m_stats.refused; // a repeated slot + continue; + } + if (p.hasState && m_mismatch && p.what.source != net::PropSource::CarPart) { + ++m_stats.refused; + continue; + } + Generation* g = nullptr; + if (newer) { + Slot& slot = m_slots[k]; + Generation* last = slot.generations.empty() ? nullptr : &slot.generations.back(); + if (!last || last->generation != p.generation || last->goneAt) { + if (last && !last->goneAt) + last->goneAt = msg.time; + slot.generations.emplace_back(); + last = &slot.generations.back(); + last->generation = p.generation; + last->firstTime = msg.time; + while (slot.generations.size() > 4) + slot.generations.pop_front(); + } + g = last; + } else { + // An older message only adds states to the generation it saw. + const auto it = m_slots.find(k); + if (it == m_slots.end()) + continue; + for (auto& gen : it->second.generations) + if (gen.generation == p.generation && (!gen.goneAt || msg.time < *gen.goneAt)) + g = &gen; + if (!g) + continue; + g->firstTime = std::min(g->firstTime, msg.time); + } + if (!p.hasState) + continue; + if (g->described && !(g->what == p.what)) { + ++m_stats.refused; // the same generation holds something else: not from an honest host + continue; + } + g->what = p.what; + g->described = true; + net::VehicleSnapshot s; + s.time = msg.time; + s.position = p.position; + s.orientation = p.orientation; + s.linearVelocity = p.velocity; + s.angularVelocity = p.angularVelocity; + s.flags = p.moving ? kMovingFlag : 0; + g->buffer.push(s); + } + if (newer) { + for (auto& [k, slot] : m_slots) + if (!listed.contains(k) && !slot.generations.empty() && !slot.generations.back().goneAt) + slot.generations.back().goneAt = msg.time; + m_latest = msg.time; + m_any = true; + } +} + +void PropClient::receiveKnocks(const net::PropKnocksEvent& event) { + if (m_mismatch) + return; + for (const auto& k : event.knocks) { + if (m_knocks.size() >= net::kMaxPropIds) + m_knocks.pop_front(); + m_knocks.push_back({k.prop, event.time + k.delay, event.catchUp}); + } +} + +void PropClient::predicted(std::span knocks, double now) { + // This machine's car broke props loose: shown at once, until the host + // confirms or corrects it. + for (const auto& k : knocks) { + if (m_mismatch || m_hostBroken.contains(k.prop)) + continue; + if (m_predicted.emplace(k.prop, Prediction{now, false}).second) + ++m_stats.predicted; + } +} + +void PropClient::updateDelay(double now) { + while (!m_lateness.empty() && m_lateness.front().first < now - 3000.0) + m_lateness.pop_front(); + double target = m_options.initialDelayMs; + if (!m_lateness.empty()) { + double worst = 0.0; + for (const auto& [at, late] : m_lateness) + worst = std::max(worst, late); + // A moving prop's next state comes a send interval later. + target = worst + 55.0; + } + target = std::clamp(target, m_options.minDelayMs, m_options.maxDelayMs); + if (m_lastUpdate < 0.0) { + m_delay = target; + } else { + const double dt = std::max(0.0, now - m_lastUpdate); + m_delay = target > m_delay ? std::min(target, m_delay + dt * 0.25) + : std::max(target, m_delay - dt * 0.02); + } + m_lastUpdate = now; +} + +const PropClient::Generation* PropClient::shownAt(const Slot& slot, double time) { + for (auto it = slot.generations.rbegin(); it != slot.generations.rend(); ++it) { + if (static_cast(it->firstTime) > time) + continue; + if (it->goneAt && time >= static_cast(*it->goneAt)) + return nullptr; // the newest one begun by then is gone by then + return it->described ? &*it : nullptr; + } + return nullptr; +} + +std::optional PropClient::resolve(const BangerSet& set, + const net::PropDescriptor& what, + const CarPartResolver& carParts) const { + if (what.source == net::PropSource::CarPart) + return carParts ? carParts(what) : std::nullopt; + if (what.prop >= placedProps(set)) + return std::nullopt; + const auto& placed = set.instances()[what.prop]; + BangerSet::MirrorSpec spec; + spec.model = placed.model; + spec.paint = placed.paint; + spec.source = what.prop; + if (what.source == net::PropSource::PropPart) { + spec.data = set.dataLibrary().part(placed.model, what.part); + spec.part = what.part; + } else { + spec.data = placed.data; + } + if (!spec.data) + return std::nullopt; + return spec; +} + +std::optional PropClient::findStandIn(const BangerSet& set, + const net::PropDescriptor& what) const { + for (const std::size_t i : set.ring()) { + const auto& inst = set.instances()[i]; + if (inst.state == BangerSet::State::Gone) + continue; + if (const auto d = describeHit(inst); d && *d == what) + return i; + } + return std::nullopt; +} + +void PropClient::applyKnock(BangerSet& set, std::size_t prop) { + if (prop >= placedProps(set)) + return; + if (m_hostBroken.insert(prop).second) { + ++m_stats.knocks; + m_applied.emplace_back(prop, false); + } + set.breakPlaced(prop); + if (const auto it = m_predicted.find(prop); it != m_predicted.end()) { + ++m_stats.confirmed; + m_predicted.erase(it); + } +} + +void PropClient::update(BangerSet& set, double now, const CarPartResolver& carParts) { + updateDelay(now); + const double render = now - m_delay; + const double drawn = render - m_options.drawBehindMs; + if (m_mismatch && set.replica()) + set.clearReplica(); // simulate every contact here, as MM2 does + + // The host's knocks, as the props are shown at their time (at once for + // a catch-up, or when the prop's pieces already show). + std::set inSlots; + for (const auto& [k, slot] : m_slots) + if (const Generation* g = shownAt(slot, render); g && g->what.source != net::PropSource::CarPart) + inSlots.insert(g->what.prop); + for (auto it = m_knocks.begin(); it != m_knocks.end();) { + if (it->now || static_cast(it->time) <= render || inSlots.contains(it->prop) || + static_cast(it->time) > now + 10000.0) { + applyKnock(set, it->prop); + it = m_knocks.erase(it); + } else { + ++it; + } + } + + // Predictions the host has not made in time are undone. + std::set described; + for (const auto& [k, slot] : m_slots) + for (const auto& g : slot.generations) + if (g.described && g.what.source != net::PropSource::CarPart) + described.insert(g.what.prop); + for (auto it = m_predicted.begin(); it != m_predicted.end();) { + const bool coming = + described.contains(it->first) || + std::ranges::any_of(m_knocks, [&](const PendingKnock& k) { return k.prop == it->first; }); + if (coming) { + it->second.confirmed = true; + ++it; + continue; + } + if (!it->second.confirmed && now - it->second.at >= m_options.predictTimeoutMs) { + set.restoreStanding(it->first); + ++m_stats.undone; + m_applied.emplace_back(it->first, true); + log::info("netprops: the host did not knock prop {} ({}): standing again", it->first, + set.instances()[it->first].model); + it = m_predicted.erase(it); + continue; + } + ++it; + } + + // This machine's own pieces (its ring): when each was made and came to rest. + const auto& ring = set.ring(); + for (std::size_t k = 0; k < ring.size(); ++k) { + LocalPiece& piece = m_pieces[k]; + const std::uint32_t generation = set.generation(k); + if (piece.generation != generation) + piece = {generation, now, std::nullopt}; + const bool resting = set.instances()[ring[k]].state != BangerSet::State::Gone && !set.body(ring[k]); + if (!resting) + piece.restedAt.reset(); + else if (!piece.restedAt) + piece.restedAt = now; + } + + // The host's ring. + std::set standIns; + for (auto& [k, slot] : m_slots) { + const auto index = static_cast(k); + while (slot.generations.size() > 1 && slot.generations.front().goneAt && + static_cast(*slot.generations.front().goneAt) <= render) + slot.generations.pop_front(); + Generation* g = shownAt(slot, render); + const std::size_t mirror = set.mirror(index); + const auto spec = g ? resolve(set, g->what, carParts) : std::nullopt; + if (!g || !spec) { + set.hideMirror(index); + slot.local = false; + slot.blendFrom.reset(); + slot.standIn.reset(); + continue; + } + net::VehicleSnapshot s, sd; + if (g->buffer.sample(render, s) == net::SnapshotBuffer::Result::Empty) { + set.hideMirror(index); + continue; + } + if (g->buffer.sample(std::max(drawn, static_cast(g->firstTime)), sd) == + net::SnapshotBuffer::Result::Empty) + sd = s; + const bool hostResting = (s.flags & kMovingFlag) == 0; + // A piece this machine simulates (its car knocked the prop, or threw + // the part, first) stands in for the host's until both are at rest. + if (slot.standIn) { + const auto& inst = set.instances()[*slot.standIn]; + const auto d = describeHit(inst); + if (inst.state == BangerSet::State::Gone || !d || !(*d == g->what)) + slot.standIn.reset(); + } + if (!slot.standIn) + if (const auto i = findStandIn(set, g->what); i && !standIns.contains(*i)) { + slot.standIn = i; + slot.standInSince = now; + } + if (slot.standIn) { + standIns.insert(*slot.standIn); + const bool resting = !set.body(*slot.standIn); + if ((!resting || !hostResting) && now - slot.standInSince < m_options.handoverMs) { + set.hideMirror(index); + continue; + } + const auto& inst = set.instances()[*slot.standIn]; + slot.blendFrom = inst.drawn ? *inst.drawn : inst.matrix; + slot.blendStart = now; + set.detachHit(*slot.standIn); + standIns.erase(*slot.standIn); + slot.standIn.reset(); + ++m_stats.handovers; + } + // This machine's car pushed the host's piece: simulated here, then + // left where it stopped until the host has pushed it too and it rests + // there (or the prediction's time is up). + const auto& mine = set.instances()[mirror]; + if (mine.active >= 0) { + slot.local = true; + slot.holdUntil = now + m_options.predictTimeoutMs; + slot.hostMoved = false; + continue; + } + if (slot.local) { + if (!hostResting) + slot.hostMoved = true; + const bool settled = slot.hostMoved && hostResting; + if (mine.state != BangerSet::State::Gone && now < slot.holdUntil && !settled) + continue; + slot.local = false; + if (mine.state != BangerSet::State::Gone) { + slot.blendFrom = mine.matrix; + slot.blendStart = now; + ++m_stats.handovers; + } + } + Mat34 matrix = matrixOf(s); + Mat34 draw = matrixOf(sd); + if (slot.blendFrom) { + const double t = (now - slot.blendStart) / m_options.blendMs; + if (t >= 1.0) { + slot.blendFrom.reset(); + } else { + const float w = static_cast(std::max(0.0, t)); + matrix = blendMatrix(*slot.blendFrom, matrix, w); + draw = blendMatrix(*slot.blendFrom, draw, w); + } + } + set.showMirror(index, *spec, matrix, draw, hostResting); + g->buffer.prune(std::min(render, drawn)); + } + + // Pieces simulated here that the host's ring does not hold: gone once at + // rest (the host threw them differently, or its ring has moved on). + for (std::size_t k = 0; k < ring.size(); ++k) { + const std::size_t i = ring[k]; + const auto& inst = set.instances()[i]; + if (inst.state == BangerSet::State::Gone || standIns.contains(i)) + continue; + const LocalPiece& piece = m_pieces[k]; + if (!piece.restedAt || now - piece.since < m_options.orphanMs) + continue; + if (inst.source >= 0 && m_predicted.contains(static_cast(inst.source)) && + !m_predicted[static_cast(inst.source)].confirmed) + continue; // undone with its prop, or confirmed later + set.detachHit(i); + ++m_stats.orphans; + } +} + +std::vector PropClient::shown(const BangerSet& set) const { + std::vector out; + const auto& instances = set.instances(); + for (std::size_t i = 0; i < instances.size(); ++i) { + const auto& inst = instances[i]; + if (!inst.everHit || inst.state == BangerSet::State::Gone) + continue; + const auto d = describeHit(inst); + if (!d) + continue; + out.push_back( + {*d, inst.drawn ? *inst.drawn : inst.matrix, set.moving(i), !inst.mirror || inst.active >= 0}); + } + return out; +} + +} // namespace mm2::game diff --git a/src/game/net/PropSync.h b/src/game/net/PropSync.h new file mode 100644 index 0000000..1d5c40d --- /dev/null +++ b/src/game/net/PropSync.h @@ -0,0 +1,262 @@ +#pragma once + +// The props of a network race, simulated by the host for everyone (OpenMM2; +// docs/multiplayer.md, "Props", and net/PropState.h). +// +// MM2 sends nothing about props (mmGameMulti, mmNetObject): every machine +// knocks its own props with its own simulation of every car, so the props +// knocked, where they rest and the parts thrown off the cars differ from +// screen to screen. In OpenMM2 the host is the authority: +// +// PropHost host side: the placed props that broke loose (reliable +// events, and every one so far to a machine that has just +// loaded the race) and the ring of knocked-over props, sent to +// every client about 20 times a second while any moves +// PropClient client side: the host's knocks applied when the props are +// shown at their time, the host's ring shown in mirror slots +// (interpolated like the other players' cars), and this +// machine's own predictions: a prop its car knocks moves at +// once and hands over to the host's when it comes to rest, and a +// knock the host never confirms is undone +// +// Nothing here touches the network: the race screen sends and feeds in the +// messages. + +#include "core/Math.h" +#include "game/bangers/BangerSet.h" +#include "net/PropState.h" +#include "net/Snapshot.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace mm2::game { + +// A checksum of a set's placed props (index, model, paint job and placement +// to the centimetre): every machine places the same props in the same order, +// so a prop's index names it on every machine. The host sends its checksum +// with every message; a client whose own differs cannot follow the host's +// props. +std::uint32_t propCatalog(const bangers::BangerSet& set); +// The number of placed props (the instances before the first hit instance). +std::size_t placedProps(const bangers::BangerSet& set); + +// The BangerSet tag (Instance::tag) of a car part thrown off a car: whose car +// (a player's, by id; a shared traffic or police car, by catalog index), the +// part (damagePartIndex) and the car's paint job. Never 0. +std::uint32_t carPartTag(net::PropOwner owner, int ownerId, int part, int paint); +std::optional carPartOfTag(std::uint32_t tag); +// What a hit instance of the set is across machines; nullopt for one that +// has no name there (a single-player opponent's part). +std::optional describeHit(const bangers::BangerSet::Instance& inst); + +class PropHost { +public: + struct Options { + // A message at most this often, while any prop moves fast or a slot + // changed; every slowIntervalMs while props only creep (slower than + // slowSpeed and slowSpin: a prop sliding down a hill, which MM2's + // phSleep may never put to sleep); else every idleIntervalMs. + std::uint32_t intervalMs = 50; + std::uint32_t slowIntervalMs = 200; + std::uint32_t idleIntervalMs = 500; + float slowSpeed = 0.5f; // m/s + float slowSpin = 1.0f; // rad/s + // A slot at rest carries its state in the first messages after it + // changed, then in every tenth one (by slot number). + int freshMessages = 3; + int restRotation = 10; + }; + + PropHost() : PropHost(Options{}) {} + explicit PropHost(const Options& options) : m_options(options) {} + + // Once a frame after the props' update (BangerSet::update): the placed + // props that broke loose this frame (BangerSet::takeKnocks, which the + // caller passes on) at session time `time`. + void knocked(std::span knocks, std::uint32_t time); + // The knocks to send to everyone since the last call (PropKnocksEvent + // payloads, each within the protocol's 1 KiB). + std::vector> takeKnockEvents(); + // Every placed prop not standing, for a machine that has just loaded the + // race (catch-up events). + static std::vector> catchUp(const bangers::BangerSet& set, std::uint32_t time); + + // The ring at session time `time` (the state after this frame's + // simulation steps), when a message is due at monotonic time `nowMs`. + // The same message goes to every client. + std::optional build(const bangers::BangerSet& set, std::uint32_t time, + std::uint64_t nowMs, std::uint32_t catalog); + + struct Stats { + std::uint64_t messages = 0, slots = 0, fullStates = 0, bytes = 0, knocks = 0; + }; + Stats& stats() { return m_stats; } + +private: + struct SlotSeen { + std::uint32_t generation = 0; + bool moving = false; + bool occupied = false; + int fresh = 0; + }; + Options m_options; + std::vector m_pending; + std::uint32_t m_pendingTime = 0; + std::vector m_seen; + std::uint64_t m_lastSent = 0; + bool m_sentAny = false; + std::uint32_t m_sequence = 0; + Stats m_stats; +}; + +class PropClient { +public: + struct Options { + // How far in the past the host's props are shown: what the messages + // needed to arrive in time over the last 3 s, plus a send interval, + // within these bounds (like the other players' cars, net::Session). + double minDelayMs = 50.0; + double maxDelayMs = 500.0; + double initialDelayMs = 150.0; + // A knock this machine predicted is undone when the host has not + // made it by then. + double predictTimeoutMs = 2000.0; + // A piece simulated here hands over to the host's when it rests, or + // after this long; the hand-over blends over blendMs. + double handoverMs = 4000.0; + double blendMs = 400.0; + // Drawn this much before the state the physics sees (the scene is + // drawn a simulation step behind, game::StepHistory). + double drawBehindMs = 1000.0 / 60.0; + // A piece simulated here that the host's ring does not hold (it threw + // it differently, or reused its slot) disappears once at rest this + // long after it was made. + double orphanMs = 2000.0; + }; + + // `catalog`: this machine's propCatalog. + explicit PropClient(std::uint32_t catalog) : PropClient(catalog, Options{}) {} + PropClient(std::uint32_t catalog, const Options& options); + + // A message from the host (untrusted: checked as it is read) that arrived + // at session time `arrival`. + void receive(const net::PropStateMsg& msg, double arrival); + // A knocks event from the host. + void receiveKnocks(const net::PropKnocksEvent& event); + // The placed props this machine's own car broke loose + // (BangerSet::takeKnocks after its physics steps), at session time + // `now`: predictions the host confirms or corrects. + void predicted(std::span knocks, double now); + // What update() did to the placed props since the last call: the host's + // knocks applied (false) and this machine's predictions undone (true). + std::vector> takeApplied() { return std::exchange(m_applied, {}); } + + // Resolves a car part's descriptor to what the mirror draws (its banger + // data, the car's model and the part's mesh); nullopt when this machine + // cannot (the part is then not shown). + using CarPartResolver = + std::function(const net::PropDescriptor&)>; + // Once a frame before the physics steps: the props as the host had them + // at session time `now` less the delay. `now` is the session time the + // simulation will have reached after this frame's steps. + void update(bangers::BangerSet& set, double now, const CarPartResolver& resolve); + + double delayMs() const { return m_delay; } + bool catalogMismatch() const { return m_mismatch; } + // A placed prop the host has broken loose (known so far). + bool hostBroken(std::size_t prop) const { return m_hostBroken.contains(prop); } + + struct Stats { + std::uint64_t messages = 0, outdated = 0, refused = 0; + std::uint64_t knocks = 0; // the host's knocks applied + std::uint64_t predicted = 0; // knocks this machine's car made first + std::uint64_t confirmed = 0; // ... that the host made too + std::uint64_t undone = 0; // ... that it did not (the prop stood again) + std::uint64_t handovers = 0; // pieces simulated here handed to the host's + std::uint64_t orphans = 0; // pieces simulated here that the host's ring did not hold + }; + const Stats& stats() const { return m_stats; } + + // Every hit instance shown (diagnostics): descriptor and drawn frame. + struct Shown { + net::PropDescriptor what; + Mat34 matrix; + bool moving = false; + bool local = false; // simulated here + }; + std::vector shown(const bangers::BangerSet& set) const; + +private: + struct Generation { + std::uint8_t generation = 0; + net::PropDescriptor what; + bool described = false; + net::SnapshotBuffer buffer{16}; + std::uint32_t firstTime = 0; + std::optional goneAt; + }; + struct Slot { + std::deque generations; // oldest first + // Presentation. + bool local = false; // the mirror was pushed by this machine's car + double holdUntil = 0.0; // ... and is left where it stopped until then + bool hostMoved = false; // ... or until the host's has moved and rests again + std::optional blendFrom; // hand-over from what was shown here + double blendStart = 0.0; + std::optional standIn; // the piece simulated here shown instead + double standInSince = 0.0; + }; + struct Prediction { + double at = 0.0; + bool confirmed = false; + }; + struct LocalPiece { + std::uint32_t generation = 0; + double since = 0.0; + std::optional restedAt; + }; + + void applyKnock(bangers::BangerSet& set, std::size_t prop); + static const Generation* shownAt(const Slot& slot, double time); + static Generation* shownAt(Slot& slot, double time) { + return const_cast(shownAt(static_cast(slot), time)); + } + std::optional resolve(const bangers::BangerSet& set, + const net::PropDescriptor& what, + const CarPartResolver& carParts) const; + std::optional findStandIn(const bangers::BangerSet& set, + const net::PropDescriptor& what) const; + void updateDelay(double now); + + Options m_options; + std::uint32_t m_catalog = 0; + bool m_mismatch = false; + bool m_any = false; + std::uint32_t m_latest = 0; + double m_delay = 0.0; + double m_lastUpdate = -1.0; + std::deque> m_lateness; // (arrival, lateness) + std::map m_slots; + struct PendingKnock { + std::size_t prop = 0; + std::uint32_t time = 0; + bool now = false; // a catch-up: at once + }; + std::deque m_knocks; // the host's, waiting for their time + std::set m_hostBroken; + std::map m_predicted; + std::map m_pieces; // this machine's ring slots, by slot + std::vector> m_applied; + Stats m_stats; +}; + +} // namespace mm2::game diff --git a/src/net/CMakeLists.txt b/src/net/CMakeLists.txt index 001dd05..355d9ac 100644 --- a/src/net/CMakeLists.txt +++ b/src/net/CMakeLists.txt @@ -9,6 +9,7 @@ add_library(mm2_net STATIC Discovery.cpp Session.cpp AmbientState.cpp + PropState.cpp NatPmp.cpp NatPmpBackend.cpp UpnpBackend.cpp diff --git a/src/net/PropState.cpp b/src/net/PropState.cpp new file mode 100644 index 0000000..4c86fe1 --- /dev/null +++ b/src/net/PropState.cpp @@ -0,0 +1,12 @@ +#include "net/PropState.h" + +namespace mm2::net { + +std::size_t propSlotBits(const PropSlot& p) { + WriteStream s; + PropSlot copy = p; + serializePropSlot(s, copy); + return s.writer().bitCount(); +} + +} // namespace mm2::net diff --git a/src/net/PropState.h b/src/net/PropState.h new file mode 100644 index 0000000..207e386 --- /dev/null +++ b/src/net/PropState.h @@ -0,0 +1,215 @@ +#pragma once + +// The props of a network race, simulated by the host (OpenMM2; see +// docs/multiplayer.md, "Props"). MM2 sends nothing about props: every machine +// knocks its own with its own simulation of every car. OpenMM2's host is the +// authority for them, as for everything else in a race. +// +// What a prop can be on every machine: +// +// a placed prop standing where the city placed it, or broken loose. Every +// machine places the same props in the same order, so its +// index (`prop`) names it everywhere; a checksum of the +// placement travels with every message. +// a hit instance one of the host's ring of knocked-over props +// (dgBangerManager, 40 slots): a placed prop broken loose, +// one of its BREAKnn pieces, or a car part thrown off +// (vehBreakableMgr::Eject). A slot's generation changes +// whenever the host hands it out again. +// +// Two messages carry them: +// +// PropStateMsg host -> client, unreliable, about 20 a second while +// anything moves (every 0.5 s otherwise): every occupied +// ring slot, complete, so a slot missing from a newer +// message is empty from that message's time. A moving +// slot carries its state every time, one at rest now and +// then (in between only its slot and generation travel). +// PropKnocksEvent host -> everyone, a reliable game event: the placed props +// that broke loose and when. Every machine thus knows every +// knocked prop of the race, wherever it is; a machine that +// reports the race loaded gets every knock so far at once. + +#include "net/Protocol.h" + +#include +#include + +namespace mm2::net { + +inline constexpr std::uint32_t kMaxPropIds = 32768; // placed props 0..32767 +inline constexpr std::uint32_t kMaxPropSlots = 64; // ring slots 0..63 (MM2's ring has 40) +inline constexpr std::uint32_t kPropGenerations = 16; // a slot's generation, modulo +inline constexpr std::uint32_t kMaxPropParts = 16; // a prop's BREAK01..BREAK16 +inline constexpr std::uint32_t kMaxPropOwners = 64; // a car part's owner id +inline constexpr std::uint32_t kMaxPropCarParts = 20; // game::damagePartIndex's parts +inline constexpr std::uint32_t kMaxPropPaint = 15; +// The frame at a prop's centre of gravity: +-8 km at 4 mm. +inline constexpr float kPropPositionRange = 8192.0f; +inline constexpr int kPropPositionBits = 22; +inline constexpr float kPropVelocityRange = 128.0f; // m/s +inline constexpr int kPropVelocityBits = 13; +inline constexpr float kPropSpinRange = 64.0f; // rad/s +inline constexpr int kPropSpinBits = 12; + +enum class PropSource : std::uint8_t { + Prop, // a placed prop, whole + PropPart, // one of a placed prop's BREAKnn pieces + CarPart, // a part thrown off a car + Last = CarPart, +}; +// Whose car a thrown part came from: a player's (by player id), or a shared +// traffic or police car (by the session's model catalog index, +// game::TrafficCatalog). +enum class PropOwner : std::uint8_t { Player, Catalog, Last = Catalog }; + +struct PropDescriptor { + PropSource source = PropSource::Prop; + std::uint16_t prop = 0; // Prop, PropPart: the placed prop + std::uint8_t part = 0; // PropPart: BREAK; CarPart: game::damagePartIndex + PropOwner owner = PropOwner::Player; // CarPart + std::uint8_t ownerId = 0; // CarPart + std::uint8_t paint = 0; // CarPart: the car's paint job + bool operator==(const PropDescriptor&) const = default; +}; + +struct PropSlot { + std::uint8_t slot = 0; + std::uint8_t generation = 0; // modulo kPropGenerations + // False: only the slot and generation travel (it still holds the same + // prop, at rest where a previous message put it). + bool hasState = true; + PropDescriptor what; + bool moving = false; // simulated on the host (an active), else at rest + Vec3 position; // the frame at the CG + Quat orientation; + Vec3 velocity; // moving only + Vec3 angularVelocity; // moving only +}; + +// Host -> one client. +struct PropStateMsg { + static constexpr MsgType kType = MsgType::PropState; + std::uint32_t time = 0; // session time the states belong to + std::uint32_t catalog = 0; // checksum of the host's placed props (game::propCatalog) + std::vector slots; +}; + +template +bool serializePropDescriptor(S& s, PropDescriptor& d) { + s.enumeration(d.source, PropSource::Last); + if (d.source == PropSource::CarPart) { + std::int32_t ownerId = d.ownerId, part = d.part, paint = d.paint; + s.enumeration(d.owner, PropOwner::Last); + s.ranged(ownerId, 0, static_cast(kMaxPropOwners) - 1); + s.ranged(part, 0, static_cast(kMaxPropCarParts) - 1); + s.ranged(paint, 0, static_cast(kMaxPropPaint)); + d.ownerId = static_cast(ownerId); + d.part = static_cast(part); + d.paint = static_cast(paint); + if constexpr (S::kReading) + d.prop = 0; + return s.ok(); + } + std::int32_t prop = d.prop; + s.ranged(prop, 0, static_cast(kMaxPropIds) - 1); + d.prop = static_cast(prop); + if (d.source == PropSource::PropPart) { + std::int32_t part = d.part; + s.ranged(part, 0, static_cast(kMaxPropParts) - 1); + d.part = static_cast(part); + } else if constexpr (S::kReading) { + d.part = 0; + } + if constexpr (S::kReading) { + d.owner = PropOwner::Player; + d.ownerId = d.paint = 0; + } + return s.ok(); +} + +template +bool serializePropSlot(S& s, PropSlot& p) { + std::int32_t slot = p.slot, generation = p.generation; + s.ranged(slot, 0, static_cast(kMaxPropSlots) - 1); + s.ranged(generation, 0, static_cast(kPropGenerations) - 1); + p.slot = static_cast(slot); + p.generation = static_cast(generation); + s.boolean(p.hasState); + if (!p.hasState) + return s.ok(); + if (!serializePropDescriptor(s, p.what)) + return s.fail(); + s.boolean(p.moving); + s.vec3Quantized(p.position, kPropPositionRange, kPropPositionBits); + s.quat(p.orientation); + if (p.moving) { + s.vec3Quantized(p.velocity, kPropVelocityRange, kPropVelocityBits); + s.vec3Quantized(p.angularVelocity, kPropSpinRange, kPropSpinBits); + } else if constexpr (S::kReading) { + p.velocity = {}; + p.angularVelocity = {}; + } + return s.ok(); +} + +template +bool serialize(S& s, PropStateMsg& m) { + s.u32(m.time); + s.u32(m.catalog); + auto count = static_cast(std::min(m.slots.size(), kMaxPropSlots)); + s.varU32(count); + if (count > kMaxPropSlots) + return s.fail(); + if constexpr (S::kReading) + m.slots.resize(count); + for (std::uint32_t i = 0; i < count; ++i) + if (!serializePropSlot(s, m.slots[i])) + return s.fail(); + return s.ok(); +} + +// Bits one slot takes on the wire. +std::size_t propSlotBits(const PropSlot& p); + +// Host -> everyone, a game event (reliable, ordered): placed props that broke +// loose on the host. Each knock's time is `time` plus its delay; a catch-up +// (every knock of the race so far, to a machine that has just reported the +// race loaded) applies at once. +inline constexpr std::uint16_t kPropKnocksEvent = static_cast(GameEventType::Custom) + 48; +inline constexpr std::size_t kMaxPropKnocksPerEvent = 512; +struct PropKnock { + std::uint16_t prop = 0; + std::uint16_t delay = 0; // ms after the event's time +}; +struct PropKnocksEvent { + std::uint32_t time = 0; + bool catchUp = false; + std::vector knocks; +}; + +template +bool serialize(S& s, PropKnocksEvent& e) { + s.u32(e.time); + s.boolean(e.catchUp); + auto count = static_cast(std::min(e.knocks.size(), kMaxPropKnocksPerEvent)); + s.varU32(count); + if (count > kMaxPropKnocksPerEvent) + return s.fail(); + if constexpr (S::kReading) + e.knocks.resize(count); + for (std::uint32_t i = 0; i < count; ++i) { + std::int32_t prop = e.knocks[i].prop; + s.ranged(prop, 0, static_cast(kMaxPropIds) - 1); + e.knocks[i].prop = static_cast(prop); + if (e.catchUp) { + if constexpr (S::kReading) + e.knocks[i].delay = 0; + } else { + s.u16(e.knocks[i].delay); + } + } + return s.ok(); +} + +} // namespace mm2::net diff --git a/src/net/Protocol.h b/src/net/Protocol.h index 8d0fbed..cdbde25 100644 --- a/src/net/Protocol.h +++ b/src/net/Protocol.h @@ -30,7 +30,8 @@ inline constexpr std::uint16_t kProtocolMagic = 0x4D32; // "M2" // RaceStart; the race in Welcome). // 4: the cars' damage (net/VehicleDamage.h events; in AmbientState the // police's damage at 10 bits and the knocked traffic cars' wheels). -inline constexpr std::uint16_t kProtocolVersion = 4; +// 5: the host's props (net/PropState.h: PropState, the PropKnocks event). +inline constexpr std::uint16_t kProtocolVersion = 5; inline constexpr std::uint32_t kConnectData = (std::uint32_t{kProtocolMagic} << 16) | kProtocolVersion; // ENet channels. @@ -93,7 +94,8 @@ enum class MsgType : std::uint8_t { AmbientState, // net/AmbientState.h RaceLoaded, RaceStart, - Last = RaceStart, + PropState, // net/PropState.h + Last = PropState, }; // Sent as ENet disconnect data and in Reject/PlayerLeft messages. diff --git a/src/net/Session.cpp b/src/net/Session.cpp index 0a320d0..6304da5 100644 --- a/src/net/Session.cpp +++ b/src/net/Session.cpp @@ -1109,6 +1109,16 @@ void Session::clientHandle(MsgType type, std::span data) { m_ambientStates.push_back(std::move(m)); return; } + case MsgType::PropState: { + // As the cars' states: only during a race. + PropStateMsg m; + if (m_phase == SessionPhase::Lobby || !decodeMessage(data, m)) + return; + if (m_propStates.size() >= kMaxQueuedPropStates) + m_propStates.erase(m_propStates.begin()); + m_propStates.push_back(std::move(m)); + return; + } default: return; } } @@ -1301,6 +1311,19 @@ std::size_t Session::sendAmbientState(std::uint8_t playerId, const AmbientStateM return m_transport->send(r->peer, Channel::Ambient, packet) ? packet.size() : 0; } +std::size_t Session::sendPropState(std::uint8_t playerId, const PropStateMsg& msg) { + if (m_role != Role::Host || !m_transport || m_state != State::Active || m_phase == SessionPhase::Lobby || + playerId == m_localId) + return 0; + Remote* r = remoteForPlayer(playerId); + if (!r) + return 0; + const auto packet = encodeMessage(msg); + // Unsequenced: a late message still fills the client's buffers (it + // orders them by their time). + return m_transport->send(r->peer, Channel::State, packet) ? packet.size() : 0; +} + 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 45b2e6a..18092e6 100644 --- a/src/net/Session.h +++ b/src/net/Session.h @@ -15,6 +15,7 @@ // install an event filter to validate or rewrite events before relaying. #include "net/AmbientState.h" +#include "net/PropState.h" #include "net/ClockSync.h" #include "net/Discovery.h" #include "net/Protocol.h" @@ -254,6 +255,15 @@ class Session { std::vector takeAmbientStates() { return std::exchange(m_ambientStates, {}); } static constexpr std::size_t kMaxQueuedAmbientStates = 16; + // --- The host's props (OpenMM2, net/PropState.h) --- + // Host: sends one player the props' state on the unreliable State + // channel. Returns the encoded size in bytes (0 when nothing was sent). + std::size_t sendPropState(std::uint8_t playerId, const PropStateMsg& msg); + // Client: the messages received since the last call, oldest first (at + // most kMaxQueuedPropStates; older ones are dropped). + std::vector takePropStates() { return std::exchange(m_propStates, {}); } + static constexpr std::size_t kMaxQueuedPropStates = 16; + 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). @@ -360,6 +370,7 @@ class Session { std::uint64_t m_lastSnapshotSent = 0; double m_lastPlayoutUpdate = -1.0; std::vector m_ambientStates; // client: received, not yet taken + std::vector m_propStates; // client: received, not yet taken }; // Proof sent in Hello: SHA-256(nonce || password); all zeros when empty. diff --git a/src/phys/Impact.cpp b/src/phys/Impact.cpp index edfa43e..21971f4 100644 --- a/src/phys/Impact.cpp +++ b/src/phys/Impact.cpp @@ -10,6 +10,7 @@ #include "phys/Collider.h" #include "phys/Geometry.h" #include "phys/InertialCS.h" +#include "phys/World.h" #include #include @@ -461,20 +462,38 @@ bool calcBangerImpact(Impact& impact, float weight, float impulseLimit2) { Vec3 j; bool broke = false; impact.findFrictionAndElasticity(); + // OpenMM2: a kinematic body that moves (a network car on this machine) + // has no ICS in its collider, so no impulse could stop it and the banger + // would hold forever. It breaks the banger as a body of its own mass + // would (Body::ics), keeps its motion and gives the banger all of the + // relative velocity. MM2's network cars are simulated vehCars. + const Body* kinematic = + !impact.colliderA->ics && impact.colliderA->moving ? impact.colliderA->body : nullptr; + if (kinematic && !(kinematic->ics.mass > 0.0f)) + kinematic = nullptr; if (n.z * relVel.z + n.y * relVel.y + n.x * relVel.x <= 0.01f) { - j = age::solveSVD(ma, {-relVel.x, -relVel.y, -relVel.z}); + Mat34 test = ma; + if (kinematic) + kinematic->ics.calcCMatrix(test, impact.position); + j = age::solveSVD(test, {-relVel.x, -relVel.y, -relVel.z}); const float j2 = j.z * j.z + j.y * j.y + j.x * j.x; if (impulseLimit2 < j2) { const float s = std::sqrt(impulseLimit2 / j2); j = {j.x * s, j.y * s, j.z * s}; // A's velocity after the limited impulse, relative to B. - const Vec3 after = transformed(ma, j); - const Vec3 remaining{after.x + relVel.x, after.y + relVel.y, after.z + relVel.z}; + const Vec3 after = transformed(test, j); + Vec3 remaining{after.x + relVel.x, after.y + relVel.y, after.z + relVel.z}; impulseA = j; + if (kinematic) { + remaining = relVel; + impulseA = {}; + } mb = invMass(impact.colliderB, impact.position); m = added(m, mb); j = age::solveSVD(m, {-remaining.x, -remaining.y, -remaining.z}); broke = true; + } else if (kinematic) { + j = age::solveSVD(ma, {-relVel.x, -relVel.y, -relVel.z}); } j = frictionLimited(j, n, impact.friction, m, relVel, true); const float bounce = impact.elasticity + 1.0f; diff --git a/src/phys/Level.h b/src/phys/Level.h index 5fd5d68..a048ecc 100644 --- a/src/phys/Level.h +++ b/src/phys/Level.h @@ -68,6 +68,11 @@ class Instance { // 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; } + // OpenMM2 (no MM2 counterpart): whether `other` may collide with this + // instance at all. A network client's props take contacts only from what + // this machine simulates itself (game::PropSync); MM2's answer is always + // yes. + virtual bool acceptsContact(const Instance& /*other*/) const { return true; } // 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 diff --git a/src/phys/World.cpp b/src/phys/World.cpp index a131e13..8f8f5fc 100644 --- a/src/phys/World.cpp +++ b/src/phys/World.cpp @@ -727,6 +727,8 @@ void World::collideTerrain(Body& body) { bool World::collideInstances(Instance& a, Instance& b) { // dgPhysManager::CollideInstances. + if (!a.acceptsContact(b) || !b.acceptsContact(a)) + return false; // OpenMM2: a network client's props (Instance::acceptsContact) const Bound* boundA = a.bound(0); const Bound* boundB = b.bound(0); if (!boundA || !boundB) diff --git a/tools/netprobe/CMakeLists.txt b/tools/netprobe/CMakeLists.txt index 446d76b..f98d44e 100644 --- a/tools/netprobe/CMakeLists.txt +++ b/tools/netprobe/CMakeLists.txt @@ -1,4 +1,4 @@ if(TARGET mm2_net) - add_executable(netprobe main.cpp) + add_executable(netprobe main.cpp PropDiff.cpp) target_link_libraries(netprobe PRIVATE OpenMM2::net) endif() diff --git a/tools/netprobe/PropDiff.cpp b/tools/netprobe/PropDiff.cpp new file mode 100644 index 0000000..d9db172 --- /dev/null +++ b/tools/netprobe/PropDiff.cpp @@ -0,0 +1,298 @@ +// netprobe propdiff : how two machines' props differ in a +// network race, from their OPENMM2_DEBUG_NETPROPS traces (game::PropTrace, +// docs/multiplayer.md "Props"). Same-named session times ("T" ticks every +// 250 ms) are compared: +// +// knocks the placed props knocked by the end on both, on one only, and +// how far apart in session time both knocked them +// standing at every common tick, the props knocked on one machine but +// standing on the other +// resting at the last common tick, how far apart the same knocked-over +// prop or thrown part rests (both at rest), and the ones only +// one machine shows +// damage each car's damage at the common ticks +// impacts the damaging impacts on each machine's own car, by cause + +#include "PropDiff.h" + +#include "core/StringUtil.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace mm2::netprobe { +namespace { + +struct Shown { + double x = 0, y = 0, z = 0; + bool moving = false; + bool local = false; +}; +struct Tick { + std::set broken; + std::map shown; + std::map damage; // level, 0..1 + std::map dents; +}; +struct Knock { + double time = 0; + std::string model, cause; +}; +struct Trace { + std::string name; + std::map knocks; // first knock of each prop + std::map undone; + std::map ticks; + std::map impacts; + std::map impactValue; +}; + +bool load(const std::string& path, Trace& t) { + std::ifstream in(path); + if (!in) + return false; + t.name = path; + std::string line; + Tick* tick = nullptr; + while (std::getline(in, line)) { + std::istringstream s(line); + std::string kind; + s >> kind; + if (kind == "K") { + double time; + long prop; + Knock k; + s >> time >> prop >> k.model >> k.cause; + k.time = time; + t.knocks.try_emplace(prop, k); + } else if (kind == "X") { + double time; + long prop; + s >> time >> prop; + t.undone[prop] = time; + } else if (kind == "T") { + double at; + s >> at; + tick = &t.ticks[static_cast(at)]; + } else if (kind == "B" && tick) { + long id; + while (s >> id) + tick->broken.insert(id); + } else if (kind == "H" && tick) { + std::string what; + Shown h; + int moving = 0, local = 0; + s >> what >> h.x >> h.y >> h.z >> moving >> local; + h.moving = moving != 0; + h.local = local != 0; + tick->shown[what] = h; + } else if (kind == "D" && tick) { + std::string car; + double level = 0, dents = 0; + s >> car >> level >> dents; + tick->damage[car] = level; + tick->dents[car] = dents; + } else if (kind == "I") { + double time, value, damage; + std::string cause; + s >> time >> cause >> value >> damage; + ++t.impacts[cause]; + t.impactValue[cause] += value; + } + } + return true; +} + +double percentile(std::vector v, double p) { + if (v.empty()) + return 0.0; + std::ranges::sort(v); + const double at = std::clamp(p, 0.0, 1.0) * static_cast(v.size() - 1); + const auto i = static_cast(at + 0.5); + return v[i]; +} + +std::string kindOf(const std::string& model) { + if (model.starts_with("giz_pcar")) + return "parked car"; + if (model.starts_with("sp_traflit")) + return "traffic light"; + return "prop"; +} + +} // namespace + +int propDiff(const std::string& pathA, const std::string& pathB) { + Trace a, b; + if (!load(pathA, a) || !load(pathB, b)) { + std::println(stderr, "propdiff: cannot read the traces"); + return 1; + } + std::vector common; + for (const auto& [at, tick] : a.ticks) + if (b.ticks.contains(at)) + common.push_back(at); + if (common.empty()) { + std::println(stderr, "propdiff: the traces share no session time"); + return 1; + } + std::println("A {}\nB {}\n{} common ticks, session {} .. {} ms", pathA, pathB, common.size(), + common.front(), common.back()); + + // Knocks by the end (the last common tick's standing lists). + const Tick& lastA = a.ticks[common.back()]; + const Tick& lastB = b.ticks[common.back()]; + std::map> byKind; // both, A only, B only + std::vector offsets; + auto modelOf = [&](long id) { + if (const auto it = a.knocks.find(id); it != a.knocks.end()) + return it->second.model; + if (const auto it = b.knocks.find(id); it != b.knocks.end()) + return it->second.model; + return std::string("?"); + }; + std::set all = lastA.broken; + all.insert(lastB.broken.begin(), lastB.broken.end()); + std::map causesOnlyA, causesOnlyB; + for (const long id : all) { + const bool inA = lastA.broken.contains(id), inB = lastB.broken.contains(id); + auto& k = byKind[kindOf(modelOf(id))]; + if (inA && inB) { + ++k[0]; + if (a.knocks.contains(id) && b.knocks.contains(id)) + offsets.push_back(std::abs(a.knocks[id].time - b.knocks[id].time)); + } else if (inA) { + ++k[1]; + ++causesOnlyA[a.knocks.contains(id) ? a.knocks[id].cause : "?"]; + } else { + ++k[2]; + ++causesOnlyB[b.knocks.contains(id) ? b.knocks[id].cause : "?"]; + } + } + std::println("\nknocked by the end both A only B only"); + for (const auto& [kind, k] : byKind) + std::println(" {:<20} {:>6} {:>8} {:>8}", kind, k[0], k[1], k[2]); + auto causes = [](const std::map& m) { + std::string s; + for (const auto& [c, n] : m) + s += std::format(" {}:{}", c, n); + return s.empty() ? std::string(" -") : s; + }; + std::println(" knocked on A only, by (A's view):{}", causes(causesOnlyA)); + std::println(" knocked on B only, by (B's view):{}", causes(causesOnlyB)); + if (!offsets.empty()) + std::println(" knock time apart (both): median {:.0f} ms, 90% {:.0f} ms, max {:.0f} ms", + percentile(offsets, 0.5), percentile(offsets, 0.9), percentile(offsets, 1.0)); + std::println(" undone predictions: A {}, B {}", a.undone.size(), b.undone.size()); + + // Standing on one, knocked on the other, over time. + std::vector mismatch; + int ticksWith = 0; + double worst = 0; + long worstAt = 0; + for (const long at : common) { + const Tick& ta = a.ticks[at]; + const Tick& tb = b.ticks[at]; + int n = 0; + for (const long id : ta.broken) + n += tb.broken.contains(id) ? 0 : 1; + for (const long id : tb.broken) + n += ta.broken.contains(id) ? 0 : 1; + mismatch.push_back(n); + ticksWith += n > 0 ? 1 : 0; + if (n > worst) { + worst = n; + worstAt = at; + } + } + double sum = 0; + for (const double m : mismatch) + sum += m; + std::println("\nknocked on one, standing on the other: mean {:.2f}, max {:.0f} (at {} ms), at the end " + "{:.0f}; {:.1f}% of the ticks", + sum / static_cast(mismatch.size()), worst, worstAt, mismatch.back(), + 100.0 * ticksWith / static_cast(mismatch.size())); + + // Where the knocked-over props and parts rest. + std::vector apart; + std::map> apartByKind; + int onlyA = 0, onlyB = 0, moving = 0; + for (const auto& [what, ha] : lastA.shown) { + const auto it = lastB.shown.find(what); + if (it == lastB.shown.end()) { + ++onlyA; + continue; + } + const Shown& hb = it->second; + if (ha.moving || hb.moving) { + ++moving; + continue; + } + const double d = std::sqrt((ha.x - hb.x) * (ha.x - hb.x) + (ha.y - hb.y) * (ha.y - hb.y) + + (ha.z - hb.z) * (ha.z - hb.z)); + apart.push_back(d); + const long id = + what.starts_with('p') ? str::parseInt(what.substr(1, what.find('.') - 1)).value_or(-1) : -1; + apartByKind[what.starts_with('c') ? "car part" : kindOf(modelOf(id))].push_back(d); + } + for (const auto& [what, hb] : lastB.shown) + if (!lastA.shown.contains(what)) + ++onlyB; + std::println("\nknocked-over props and parts at the end: {} on both ({} still moving), {} on A only, " + "{} on B only", + apart.size() + static_cast(moving), moving, onlyA, onlyB); + if (!apart.empty()) { + const auto within = std::ranges::count_if(apart, [](double d) { return d <= 0.05; }); + std::println(" resting apart: median {:.3f} m, 90% {:.3f} m, max {:.3f} m; {} of {} within 5 cm", + percentile(apart, 0.5), percentile(apart, 0.9), percentile(apart, 1.0), within, + apart.size()); + for (const auto& [kind, v] : apartByKind) + std::println(" {:<14} {:>3}: median {:.3f} m, max {:.3f} m", kind, v.size(), + percentile(v, 0.5), percentile(v, 1.0)); + } + + // The cars' damage: the level (0..1 between MedDamage and MaxDamage) + // and the dents each machine shows. + std::map> damage, dents; + std::map> lastDamage; + for (const long at : common) + for (const auto& [car, da] : a.ticks[at].damage) + if (const auto it = b.ticks[at].damage.find(car); it != b.ticks[at].damage.end()) { + damage[car].push_back(std::abs(da - it->second)); + const double na = a.ticks[at].dents[car], nb = b.ticks[at].dents[car]; + dents[car].push_back(std::abs(na - nb)); + lastDamage[car] = {da, it->second, na, nb}; + } + std::println("\ndamage (A vs B): level 0..1, dents"); + for (const auto& [car, v] : damage) { + const auto& l = lastDamage[car]; + const auto& d = dents[car]; + const double meanLevel = std::accumulate(v.begin(), v.end(), 0.0) / static_cast(v.size()); + const double meanDents = std::accumulate(d.begin(), d.end(), 0.0) / static_cast(d.size()); + std::println(" {:<10} level apart: mean {:.3f}, max {:.3f}; dents apart: mean {:.1f}, max {:.0f}; " + "at the end A {:.3f} / {:.0f} dents, B {:.3f} / {:.0f} dents", + car, meanLevel, percentile(v, 1.0), meanDents, percentile(d, 1.0), l[0], l[2], l[1], + l[3]); + } + auto impacts = [](const Trace& t) { + std::string s; + for (const auto& [c, n] : t.impacts) + s += std::format(" {}:{} ({:.0f})", c, n, t.impactValue.at(c)); + return s.empty() ? std::string(" -") : s; + }; + std::println("damaging impacts on A's own car:{}", impacts(a)); + std::println("damaging impacts on B's own car:{}", impacts(b)); + return 0; +} + +} // namespace mm2::netprobe diff --git a/tools/netprobe/PropDiff.h b/tools/netprobe/PropDiff.h new file mode 100644 index 0000000..f647039 --- /dev/null +++ b/tools/netprobe/PropDiff.h @@ -0,0 +1,11 @@ +#pragma once + +#include + +namespace mm2::netprobe { + +// netprobe propdiff: compares two machines' OPENMM2_DEBUG_NETPROPS traces +// (see PropDiff.cpp). Returns the process exit code. +int propDiff(const std::string& pathA, const std::string& pathB); + +} // namespace mm2::netprobe diff --git a/tools/netprobe/main.cpp b/tools/netprobe/main.cpp index b53fafd..5d53957 100644 --- a/tools/netprobe/main.cpp +++ b/tools/netprobe/main.cpp @@ -7,8 +7,10 @@ // 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] +// netprobe propdiff how two machines' props differ (OPENMM2_DEBUG_NETPROPS) // // --seconds 0 runs until Ctrl-C. Port mappings are removed on exit. +#include "PropDiff.h" #include "core/Log.h" #include "core/StringUtil.h" #include "net/Discovery.h" @@ -378,14 +380,15 @@ int cmdRelay(const Args& a) { } 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]\n" " relay [--port N] [--delay MS] [--jitter MS] [--loss PERCENT] [--reorder]\n" - " [--seed N] [--seconds N]"); + " [--seed N] [--seconds N]\n" + " propdiff "); return 2; } @@ -415,5 +418,7 @@ int main(int argc, char** argv) { return cmdPortmap(args); if (cmd == "relay") return cmdRelay(args); + if (cmd == "propdiff" && args.positional.size() == 2) + return netprobe::propDiff(args.positional[0], args.positional[1]); return usage(); } From 28683d39d964aa9d3a2a629631de9c817f92a8e8 Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Fri, 9 Oct 2026 13:12:03 -0700 Subject: [PATCH 007/129] Add mm2tool nettrace to measure the shared traffic's divergence It reads a host's and a client's OPENMM2_NET_TRACE files and prints how far the client's traffic and police were from the host's at the session time of the client's car, the cars near the client one machine had and the other did not, the police targets, the hit reports and knocks, and the cars the client knocked loose itself. Co-Authored-By: Claude Opus 5.5 (1M context) --- tools/mm2tool/cmd_nettrace.cpp | 313 +++++++++++++++++++++++++++++++++ 1 file changed, 313 insertions(+) create mode 100644 tools/mm2tool/cmd_nettrace.cpp diff --git a/tools/mm2tool/cmd_nettrace.cpp b/tools/mm2tool/cmd_nettrace.cpp new file mode 100644 index 0000000..9a04f0e --- /dev/null +++ b/tools/mm2tool/cmd_nettrace.cpp @@ -0,0 +1,313 @@ +// nettrace: how far a client's shared traffic is from the host's. +// +// Reads two OPENMM2_NET_TRACE files of one network cruise with shared +// traffic (game/net/TrafficTrace.h): the host's, whose TH lines have every +// car near a player at the session time its state belongs to, and a +// client's, whose TC lines have the cars where its car met them each frame +// and whose TV lines give that frame's session time of its car. Prints the +// error of the client's cars against the host's at the same session time, +// the cars near the client that one machine had and the other did not, the +// police targets, and the hits and knocks (docs/review/ +// multiplayer-desync-traffic.md, docs/multiplayer.md "Diagnosing +// replication"). + +#include "Command.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace mm2::tool { +namespace { + +// The whitespace-separated fields of a line. +std::vector fields(std::string_view line) { + std::vector out; + std::size_t i = 0; + while (i < line.size()) { + while (i < line.size() && line[i] == ' ') + ++i; + const std::size_t start = i; + while (i < line.size() && line[i] != ' ') + ++i; + if (i > start) + out.push_back(line.substr(start, i - start)); + } + return out; +} + +double number(std::string_view s) { + double v = 0.0; + std::from_chars(s.data(), s.data() + s.size(), v); + return v; +} +int integer(std::string_view s) { return static_cast(number(s)); } + +// `n` of `of`, in percent. +double share(std::size_t n, std::size_t of) { + return 100.0 * static_cast(n) / static_cast(std::max(1, of)); +} + +struct Sample { + double time = 0.0; + double x = 0.0, y = 0.0, z = 0.0; + double speed = 0.0; + int flags = 0; + int target = -1; + int kind = 0; +}; + +using Key = std::pair; // id, generation + +struct ClientCar { + Key key; + int kind = 0; + double x = 0.0, z = 0.0; + double time = 0.0; + int flags = 0; + int target = -1; + int mode = 0; +}; + +struct Frame { + double frame = 0.0, own = 0.0; + double x = 0.0, z = 0.0; + std::vector cars; +}; + +struct Event { + double time = 0.0; + int id = 0, generation = 0, player = 0; + double value = 0.0; +}; + +struct HostTrace { + std::map> cars; + std::vector knocks, refused; +}; + +struct ClientTrace { + std::vector frames; + std::vector hits, handovers; +}; + +bool readHost(const char* path, HostTrace& out) { + std::ifstream in(path); + if (!in) + return false; + std::string line; + while (std::getline(in, line)) { + const auto f = fields(line); + if (f.empty()) + continue; + if (f[0] == "TH" && f.size() >= 13) { + Sample s{number(f[1]), + number(f[5]), + number(f[6]), + number(f[7]), + number(f[10]), + integer(f[11]), + integer(f[12]), + integer(f[4])}; + out.cars[{integer(f[2]), integer(f[3])}].push_back(s); + } else if (f[0] == "TK" && f.size() >= 5) { + out.knocks.push_back({number(f[1]), integer(f[2]), integer(f[3]), integer(f[4])}); + } else if (f[0] == "TR" && f.size() >= 5) { + out.refused.push_back({number(f[1]), integer(f[3]), integer(f[4]), integer(f[2])}); + } + } + for (auto& [key, samples] : out.cars) + std::ranges::stable_sort(samples, {}, &Sample::time); + return true; +} + +bool readClient(const char* path, ClientTrace& out) { + std::ifstream in(path); + if (!in) + return false; + std::string line; + while (std::getline(in, line)) { + const auto f = fields(line); + if (f.empty()) + continue; + if (f[0] == "TV" && f.size() >= 6) { + out.frames.push_back({number(f[1]), number(f[2]), number(f[3]), number(f[5]), {}}); + } else if (f[0] == "TC" && f.size() >= 15 && !out.frames.empty()) { + ClientCar c; + c.key = {integer(f[3]), integer(f[4])}; + c.kind = integer(f[5]); + c.x = number(f[6]); + c.z = number(f[8]); + c.time = number(f[2]); + c.flags = integer(f[12]); + c.target = integer(f[13]); + c.mode = integer(f[14]); + out.frames.back().cars.push_back(c); + } else if (f[0] == "TX" && f.size() >= 4) { + out.hits.push_back({number(f[1]), integer(f[2]), integer(f[3]), 0}); + } else if (f[0] == "TL" && f.size() >= 5) { + out.handovers.push_back({number(f[1]), integer(f[2]), 0, integer(f[3]), number(f[4])}); + } + } + return true; +} + +// A host car at `time`: between its two samples around it; none in a gap +// (it left and came back) or more than `slack` ms beyond its first or last. +std::optional at(const std::vector& s, double time, double slack = 60.0) { + if (s.empty()) + return std::nullopt; + const auto hi = std::ranges::upper_bound(s, time, {}, &Sample::time); + if (hi == s.begin()) + return s.front().time - time > slack ? std::nullopt : std::optional(s.front()); + if (hi == s.end()) + return time - s.back().time > slack ? std::nullopt : std::optional(s.back()); + const Sample& a = *(hi - 1); + const Sample& b = *hi; + if (b.time - a.time > 400.0) + return std::nullopt; + const double t = (time - a.time) / (b.time - a.time); + Sample out = a; + out.time = time; + out.x = a.x + (b.x - a.x) * t; + out.y = a.y + (b.y - a.y) * t; + out.z = a.z + (b.z - a.z) * t; + return out; +} + +double percentile(std::vector v, double q) { + if (v.empty()) + return 0.0; + std::ranges::sort(v); + return v[std::min(v.size() - 1, static_cast(static_cast(v.size()) * q))]; +} + +void row(const char* name, const std::vector& v) { + if (v.empty()) + return; + std::println(" {:<26} n={:7} median {:6.2f} 90% {:6.2f} 99% {:6.2f} max {:7.2f} m", name, v.size(), + percentile(v, 0.5), percentile(v, 0.9), percentile(v, 0.99), percentile(v, 1.0)); +} + +int cmdNettrace(std::span args) { + if (args.size() < 2) { + std::println(stderr, "usage: mm2tool nettrace [radius]"); + return 2; + } + const double radius = args.size() > 2 ? number(args[2]) : 150.0; + HostTrace host; + ClientTrace client; + if (!readHost(args[0], host) || !readClient(args[1], client)) { + std::println(stderr, "nettrace: cannot read the traces"); + return 1; + } + constexpr int kOffRail = 0x10; + std::vector rail, moving, offRail, police, near, local; + std::size_t present = 0, extra = 0, missing = 0, copFrames = 0, copTargets = 0; + for (std::size_t i = 0; i < client.frames.size(); ++i) { + const Frame& fr = client.frames[i]; + std::vector listed; + for (const ClientCar& c : fr.cars) { + const double fromMe = std::hypot(c.x - fr.x, c.z - fr.z); + if (fromMe > radius) + continue; + listed.push_back(c.key); + const auto it = host.cars.find(c.key); + const auto h = it != host.cars.end() ? at(it->second, fr.own) : std::nullopt; + if (!h) { + ++extra; + continue; + } + ++present; + const double e = std::hypot(c.x - h->x, c.z - h->z); + if (c.mode == 3) + local.push_back(e); + if (c.kind == 1) { + police.push_back(e); + ++copFrames; + copTargets += h->target != c.target ? 1 : 0; + } else if ((c.flags & kOffRail) != 0) { + offRail.push_back(e); + } else { + rail.push_back(e); + if (std::abs(h->speed) > 2.0) + moving.push_back(e); + } + if (fromMe < 40.0) + near.push_back(e); + } + // The host's cars near this client's car it did not have (every + // fourth frame). + if (i % 4 != 0) + continue; + for (const auto& [key, samples] : host.cars) { + if (std::ranges::find(listed, key) != listed.end()) + continue; + const auto h = at(samples, fr.own, 20.0); + if (h && std::hypot(h->x - fr.x, h->z - fr.z) < radius - 20.0) + missing += 4; + } + } + std::println("frames {}, car-frames compared {}", client.frames.size(), present); + std::println("error against the host at the client car's session time (on the ground plane):"); + row("rail cars", rail); + row("rail cars moving > 2 m/s", moving); + row("off-rail (knocked) cars", offRail); + row("police", police); + row("any car within 40 m", near); + row("knocked by the client", local); + std::println("cars within {:.0f} m: on the host only {} car-frames ({:.2f}%), on the client only {} " + "({:.2f}%)", + radius - 20.0, missing, share(missing, present + missing), extra, + share(extra, present + extra)); + if (copFrames) + std::println("police targets differ in {} of {} car-frames", copTargets, copFrames); + std::size_t applied = 0, refused = 0, unanswered = 0; + for (const Event& hit : client.hits) { + const auto same = [&](const Event& e) { + return e.id == hit.id && e.generation == hit.generation && std::abs(e.time - hit.time) < 2500.0; + }; + if (std::ranges::any_of(host.knocks, [&](const Event& e) { return same(e) && e.player != 255; })) + ++applied; + else if (std::ranges::any_of(host.refused, same)) + ++refused; + else + ++unanswered; + } + // The host's own knocks: those no report made (a report's knock is + // listed twice, with the player and as the car leaving its rail). + const auto native = std::ranges::count_if(host.knocks, [&](const Event& e) { + return e.player == 255 && std::ranges::none_of(host.knocks, [&](const Event& r) { + return r.player != 255 && r.id == e.id && r.generation == e.generation && + std::abs(r.time - e.time) < 200.0; + }); + }); + std::println("client hit reports {}: applied {}, refused {}, unanswered {}; cars the host knocked off " + "their rails {}", + client.hits.size(), applied, refused, unanswered, native); + if (!client.handovers.empty()) { + std::vector confirmed, withdrawn; + for (const Event& h : client.handovers) + (h.player ? confirmed : withdrawn).push_back(h.value); + std::println("cars the client knocked loose: {} confirmed by the host, {} withdrawn", + confirmed.size(), withdrawn.size()); + row("drawing jump when confirmed", confirmed); + row("drawing jump when withdrawn", withdrawn); + } + return 0; +} + +const Registrar reg({"nettrace", " [radius]", + "compare a client's shared traffic with the host's (OPENMM2_NET_TRACE files)", + &cmdNettrace}); + +} // namespace +} // namespace mm2::tool From 9cbb2d6fce4e60eabac6dc696be0635556d35ccb Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Fri, 9 Oct 2026 13:18:45 -0700 Subject: [PATCH 008/129] Let a client's car meet the host's flying props, and test the props' sync A piece the host is simulating was a ghost on a client: its car drove through a flying cone that hit the host's copy of it. A client's mirror of a host piece is now an object its car can touch at any time; the active that takes it over starts from the host's motion (dgBangerActive::Attach starts a placed or resting prop at rest), and the piece stays where the client left it until the host's has moved and come to rest too. Creeping props (a meter sliding down a hill that phSleep never stops) go out every 200 ms instead of 50. OPENMM2_DEBUG_FOCUS=prop: frames a placed prop, or its first piece once knocked, on every machine alike. Tests: the messages and their limits (test_prop_state.cpp, the fuzz corpus and decoders), and a host and a client in one process through a lossy link (test_prop_sync.cpp): the host's knocks and pieces on the client, a predicted knock handed over and one undone, other cars passing through a client's props, moving kinematic bodies breaking props, loss and reordering, the catch-up, thrown car parts, refused input, and on retail data the same placement on every machine. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/app/RaceScreen.cpp | 18 +- src/game/bangers/BangerSet.cpp | 19 +- src/game/bangers/BangerSet.h | 13 +- src/game/net/PropSync.cpp | 2 +- tests/game/test_prop_sync.cpp | 617 +++++++++++++++++++++++++++++++++ tests/net/test_fuzz.cpp | 88 +++++ tests/net/test_prop_state.cpp | 153 ++++++++ 7 files changed, 901 insertions(+), 9 deletions(-) create mode 100644 tests/game/test_prop_sync.cpp create mode 100644 tests/net/test_prop_state.cpp diff --git a/src/app/RaceScreen.cpp b/src/app/RaceScreen.cpp index 63b455e..4a6ac9c 100644 --- a/src/app/RaceScreen.cpp +++ b/src/app/RaceScreen.cpp @@ -426,7 +426,8 @@ class RaceScreen final : public Screen { // pedestrian or traffic car (for screenshots); net: a // network player's car (this machine's own, or another's as drawn), // police: a shared-traffic police car (400 + its place), - // knocked: the knocked traffic car nearest that player. + // knocked: the knocked traffic car nearest that player, + // prop: a placed prop or its first piece. if (const char* focus = std::getenv("OPENMM2_DEBUG_FOCUS"); focus && (m_ai || multiplayer(ctx))) { const Vec3 ref = m_player ? m_pose.body.m3 : m_camera.position(); std::optional target; @@ -483,6 +484,21 @@ class RaceScreen final : public Screen { const auto physical = physicalTrafficCar(c.id); target = physical ? physical->transform : c.transform; } + } else if (what.starts_with("prop:") && m_bangers) { + // A placed prop by index (the same on every machine), or + // once knocked over its first piece shown. + const auto id = static_cast(str::parseInt(what.substr(5)).value_or(-1)); + const auto& instances = m_bangers->instances(); + if (id < instances.size()) { + Vec3 at = instances[id].ground.m3; + for (const auto& inst : instances) + if (!m_bangers->standing(id) && inst.source == static_cast(id) && + inst.state != game::bangers::BangerSet::State::Gone) { + at = inst.drawn.value_or(inst.matrix).m3; + break; + } + target = Mat34::translation(at); // seen from the same side on every machine + } } else if (what == "ped" && m_ai) { for (const auto& p : m_ai->peds()) consider(p.transform); diff --git a/src/game/bangers/BangerSet.cpp b/src/game/bangers/BangerSet.cpp index 7ee25f9..104f920 100644 --- a/src/game/bangers/BangerSet.cpp +++ b/src/game/bangers/BangerSet.cpp @@ -574,12 +574,25 @@ bool BangerSet::inWorld(const Active& a) const { return m_world && m_world->cont phys::Body* BangerSet::attachEntity(std::size_t i) { // dgBangerInstance::AttachEntity. Active* a = activeOf(i); + const bool attached = a == nullptr; if (!a) a = managerAttach(i); if (!a) { log::error("bangers: AttachEntity failed for {}", m_instances[i].model); return nullptr; } + // OpenMM2: a network client's mirror of a host's piece that its car + // touched goes on from the host's motion (dgBangerActive::Attach starts a + // prop at rest: a placed or resting prop is). + if (const Instance& inst = m_instances[i]; attached && inst.mirror) { + phys::InertialCS& ics = a->body.ics; + ics.linearVelocity = inst.mirrorVelocity; + const float m = ics.mass; + const Vec3& v = inst.mirrorVelocity; + ics.linearMomentum = {m * v.x, m * v.y, m * v.z}; + ics.angularVelocity = inst.mirrorSpin; + ics.angularMomentum = rowTimes(inst.mirrorSpin, ics.worldInertia()); + } return &a->body; } @@ -1176,7 +1189,7 @@ std::size_t BangerSet::mirror(std::size_t slot) { } void BangerSet::showMirror(std::size_t slot, const MirrorSpec& spec, const Mat34& matrix, const Mat34& drawn, - bool collidable) { + const Vec3& velocity, const Vec3& spin) { const std::size_t i = mirror(slot); Instance& inst = m_instances[i]; if (inst.active >= 0 || !spec.data) @@ -1192,10 +1205,12 @@ void BangerSet::showMirror(std::size_t slot, const MirrorSpec& spec, const Mat34 inst.matrix = matrix; inst.ground = matrix; inst.drawn = drawn; + inst.mirrorVelocity = velocity; + inst.mirrorSpin = spin; inst.state = State::Hit; Prop& prop = *m_props[i]; prop.audioId = spec.data->colliderId; - prop.collidable = collidable; + prop.collidable = true; // Its room, as an active's body finds its own after each sample. const int room = roomsTracked() ? findRoom(matrix.m3, inst.room) : inst.room; if (room != inst.room || (room > 0 && !prop.listed)) diff --git a/src/game/bangers/BangerSet.h b/src/game/bangers/BangerSet.h index 0a7ee73..d104678 100644 --- a/src/game/bangers/BangerSet.h +++ b/src/game/bangers/BangerSet.h @@ -129,9 +129,11 @@ class BangerSet final : public InstanceSource { int source = -1; std::uint32_t tag = 0; // One of the host's ring slots shown on a network client - // (showMirror), and where it is drawn while the host drives it. + // (showMirror), where it is drawn while the host drives it and its + // motion there (an active attached to it here starts with it). bool mirror = false; std::optional drawn; + Vec3 mirrorVelocity, mirrorSpin; }; // The placed props (in add() order) and the hit instances (created as // the ring first hands them out). @@ -252,11 +254,12 @@ class BangerSet final : public InstanceSource { // The instance showing host ring slot `slot` (made on first use, hidden). std::size_t mirror(std::size_t slot); // Shows host ring slot `slot` at `matrix` (the frame at the CG), drawn at - // `drawn`; `collidable` while the host has it at rest (this machine's car - // may then push it, simulating it here until it rests again). Not while - // the instance has an active: the local simulation drives it then. + // `drawn`, moving at `velocity` and `spin` there. This machine's car may + // touch it: an active then simulates it here from that motion until it + // rests again. Not while the instance has an active: the local + // simulation drives it then. void showMirror(std::size_t slot, const MirrorSpec& spec, const Mat34& matrix, const Mat34& drawn, - bool collidable); + const Vec3& velocity, const Vec3& spin); // Takes it out of the world (not drawn, not collidable, no body). void hideMirror(std::size_t slot); std::size_t mirrorCount() const { return m_mirrors.size(); } diff --git a/src/game/net/PropSync.cpp b/src/game/net/PropSync.cpp index bf512f0..6b6c623 100644 --- a/src/game/net/PropSync.cpp +++ b/src/game/net/PropSync.cpp @@ -592,7 +592,7 @@ void PropClient::update(BangerSet& set, double now, const CarPartResolver& carPa draw = blendMatrix(*slot.blendFrom, draw, w); } } - set.showMirror(index, *spec, matrix, draw, hostResting); + set.showMirror(index, *spec, matrix, draw, s.linearVelocity, s.angularVelocity); g->buffer.prune(std::min(render, drawn)); } diff --git a/tests/game/test_prop_sync.cpp b/tests/game/test_prop_sync.cpp new file mode 100644 index 0000000..b8dcd20 --- /dev/null +++ b/tests/game/test_prop_sync.cpp @@ -0,0 +1,617 @@ +// The host's props in a network race (game/net/PropSync, net/PropState.h): +// a host and a client machine in one process, each with its own world and +// props, the messages through an in-process link with latency and loss. + +#include "TestData.h" + +#include "ai/World.h" +#include "city/CityData.h" +#include "game/CityLevel.h" +#include "game/RaceConfig.h" +#include "game/VehicleRenderer.h" +#include "game/bangers/BangerData.h" +#include "game/bangers/BangerSet.h" +#include "game/bangers/PropPlacement.h" +#include "game/net/PropSync.h" +#include "game/world/Gizmos.h" +#include "net/PropState.h" +#include "phys/Bound.h" +#include "phys/World.h" +#include "vfs/DirectoryFs.h" + +#include + +#include +#include +#include +#include +#include + +using namespace mm2; +using namespace mm2::game; +using bangers::BangerSet; +using bangers::PlacedProp; + +namespace { + +constexpr float kDt = 1.0f / 60.0f; +constexpr double kStepMs = 1000.0 / 60.0; + +// A temporary game folder with a few banger files (as test_bangers.cpp). +struct TempBangers { + std::filesystem::path dir = std::filesystem::temp_directory_path() / + (std::string("openmm2_propsync_") + + ::testing::UnitTest::GetInstance()->current_test_info()->name()); + vfs::Vfs vfs; + TempBangers() { + std::filesystem::remove_all(dir); + std::filesystem::create_directories(dir / "tune" / "banger"); + write("light", 80.0f, 100.0f * 100.0f, 0); // breaks above 100 N s + write("heavy", 50000.0f, 1e15f, 0); + write("split", 40.0f, 100.0f * 100.0f, 2); + write("split_break01", 20.0f, 1e6f, 0, {0.5f, 1.0f, 0.5f}, {0, 0.5f, 0}); + write("split_break02", 20.0f, 1e6f, 0, {0.5f, 1.0f, 0.5f}, {0, 1.5f, 0}); + write("car_break0", 15.0f, 1e6f, 0, {0.6f, 0.3f, 0.6f}, {0, 0.15f, 0}); + vfs.mount(std::make_shared(dir)); + } + ~TempBangers() { + std::error_code ec; + std::filesystem::remove_all(dir, ec); + } + void write(const char* name, float mass, float limit2, int parts, Vec3 size = {0.5f, 2.0f, 0.5f}, + Vec3 cg = {0, 1, 0}) { + std::ofstream f(dir / "tune" / "banger" / (std::string(name) + ".dgbangerdata")); + f << "type: a\ndgBangerData {\n Size " << size.x << " " << size.y << " " << size.z; + f << "\n CG " << cg.x << " " << cg.y << " " << cg.z << "\n Mass " << mass; + f << "\n Elasticity 0.5\n Friction 0.9"; + f << "\n ImpulseLimit2 " << limit2 << "\n NumParts " << parts; + f << "\n BirthRule {\n InitialBlast 0\n }\n TexNumber 0\n CollisionPrim 1\n}\n"; + } +}; + +// One room with a floor whose objects are the set's props. +class PropLevel final : public phys::Level { +public: + explicit PropLevel(const InstanceSource& source) : m_source(source) {} + int findRoom(const Vec3&, int) const override { return 1; } + int touchedNeighbors(int*, int, int, const Vec3&, float) const override { return 0; } + void collect(const int*, int, const Vec3&, float, phys::LevelBound& out) const override { + out.clear(); + const Vec3 floor[4] = {{-500, 0, -500}, {-500, 0, 500}, {500, 0, 500}, {500, 0, -500}}; + out.addPolygon(floor, 4, {0, 1, 0}, 0); + } + void instances(int room, std::vector& out) const override { + m_source.instancesIn(room, out); + } + +private: + const InstanceSource& m_source; +}; + +// A box "car" 1.8 x 1.2 x 4 m of 1000 kg moving level (no gravity). +struct Car { + std::unique_ptr bound = std::make_unique(Vec3{1.8f, 1.2f, 4.0f}); + phys::Body body; + Car(const Vec3& pos, const Vec3& vel) { + bound->makeOwnMaterial(); + body.ics.setMass(1.8f, 1.2f, 4.0f, 1000.0f); + body.collisionBound = bound.get(); + body.place(Mat34::translation(pos)); + body.ics.gravity = {0, 0, 0}; + body.ics.linearVelocity = vel; + body.ics.linearMomentum = vel * 1000.0f; + } +}; + +// One machine: its world and its props. +struct Machine { + phys::World world; + BangerSet set; + PropLevel level{set}; + explicit Machine(const bangers::BangerDataLibrary& lib) : set(lib) { + world.setLevel(&level); + set.setWorld(&world); + set.recordKnocks(true); + } + ~Machine() { set.setWorld(nullptr); } + void place(const std::vector& props) { set.add(props); } + void step() { + world.step(kDt); + set.update(kDt); + } +}; + +std::vector layout() { + auto at = [](const char* model, const Vec3& p) { + return PlacedProp{model, Mat34::translation(p), 1, PlacedProp::Source::Instance, true}; + }; + // A row of props across the cars' path at z = 0, and one far away. + return {at("light", {0, 0, 0}), at("light", {6, 0, 0}), at("split", {12, 0, 0}), at("heavy", {18, 0, 0}), + at("light", {200, 0, 0})}; +} + +// Host -> client through a link of `latencyMs` (+ up to `jitterMs`) that +// loses `loss` of the unreliable messages (the knocks are reliable). +struct Network { + double latencyMs = 60.0, jitterMs = 0.0, loss = 0.0; + std::mt19937 rng{7}; + struct Packet { + double at = 0; + bool event = false; + std::vector bytes; + }; + std::deque inFlight; + void send(double now, std::vector bytes, bool event) { + std::uniform_real_distribution u(0.0, 1.0); + if (!event && u(rng) < loss) + return; + inFlight.push_back({now + latencyMs + u(rng) * jitterMs, event, std::move(bytes)}); + } + template + void deliver(double now, F&& f) { + std::stable_sort(inFlight.begin(), inFlight.end(), + [](const Packet& a, const Packet& b) { return a.at < b.at; }); + while (!inFlight.empty() && inFlight.front().at <= now) { + f(inFlight.front()); + inFlight.pop_front(); + } + } +}; + +// The host and one client of a network race on the same props. +struct Race { + TempBangers files; + bangers::BangerDataLibrary lib{files.vfs}; + Machine host{lib}, client{lib}; + PropHost propHost; + std::optional propClient; + Network net; + double now = 10000.0; // session ms + PropClient::CarPartResolver resolver; + const phys::Instance* clientCar = nullptr; + + Race() { + host.place(layout()); + client.place(layout()); + propClient.emplace(propCatalog(client.set)); + client.set.setReplica([this](const phys::Instance& other) { return &other == clientCar; }); + } + // One frame on both machines, in the race screen's order. + void frame() { + now += kStepMs; + // Host: the step, the knocks and the ring to the client. + host.step(); + const auto knocks = host.set.takeKnocks(); + const auto time = static_cast(now); + propHost.knocked(knocks, time); + for (auto& e : propHost.takeKnockEvents()) + net.send(now, std::move(e), true); + if (auto msg = propHost.build(host.set, time, static_cast(now), propCatalog(host.set))) + net.send(now, net::encodeMessage(*msg), false); + // Client: what arrived, the host's props placed, its step, its own + // knocks. + net.deliver(now, [&](const Network::Packet& p) { + if (p.event) { + net::PropKnocksEvent e; + ASSERT_TRUE(net::decodePayload(p.bytes, e)); + propClient->receiveKnocks(e); + } else { + net::PropStateMsg m; + ASSERT_TRUE(net::decodeMessage(p.bytes, m)); + propClient->receive(m, now); + } + }); + propClient->update(client.set, now, resolver); + client.step(); + propClient->predicted(client.set.takeKnocks(), now); + } + void run(double seconds) { + for (int i = 0; i < static_cast(seconds * 60.0); ++i) + frame(); + } +}; + +// Where each machine shows the pieces of placed prop `prop` (by part; -1 +// the whole prop), at rest or not. +std::map piecesOf(const BangerSet& set, std::size_t prop) { + std::map out; + for (const auto& inst : set.instances()) + if (inst.everHit && inst.source == static_cast(prop) && inst.state != BangerSet::State::Gone) + out[inst.part] = inst.drawn.value_or(inst.matrix).m3; + return out; +} + +void expectSamePieces(const Race& r, std::size_t prop, float tolerance) { + const auto host = piecesOf(r.host.set, prop); + const auto client = piecesOf(r.client.set, prop); + ASSERT_FALSE(host.empty()) << "prop " << prop; + ASSERT_EQ(host.size(), client.size()) << "prop " << prop; + for (const auto& [part, at] : host) { + ASSERT_TRUE(client.contains(part)) << "prop " << prop << " part " << part; + EXPECT_LT(at.dist(client.at(part)), tolerance) << "prop " << prop << " part " << part; + } +} + +} // namespace + +// Every machine places the same props in the same order: the same catalog. +TEST(PropSync, CatalogNamesThePlacement) { + TempBangers files; + bangers::BangerDataLibrary lib(files.vfs); + Machine a(lib), b(lib); + a.place(layout()); + b.place(layout()); + EXPECT_EQ(propCatalog(a.set), propCatalog(b.set)); + EXPECT_EQ(placedProps(a.set), 5u); + auto moved = layout(); + moved[2].transform.m3.x += 0.05f; + Machine c(lib); + c.place(moved); + EXPECT_NE(propCatalog(a.set), propCatalog(c.set)); +} + +// A prop the host's car knocks over is knocked over on the client when it +// shows the host's props at that time, and rests where the host's does. +TEST(PropSync, HostKnocksReachTheClient) { + Race r; + Car car({0, 1, 3.0f}, {0, 0, -10}); + r.host.world.add(&car.body); + double knockedAt = 0, shownAt = 0; + for (int i = 0; i < 6 * 60; ++i) { + r.frame(); + if (knockedAt == 0 && !r.host.set.standing(0)) + knockedAt = r.now; + if (shownAt == 0 && !r.client.set.standing(0)) + shownAt = r.now; + if (i == 60) + r.host.world.remove(&car.body); // gone past the props + } + ASSERT_GT(knockedAt, 0); + ASSERT_GT(shownAt, 0); + // Shown a playout delay (the link's latency and a send interval) after + // the host's knock. + EXPECT_GE(shownAt - knockedAt, r.net.latencyMs); + EXPECT_LE(shownAt - knockedAt, 400.0); + EXPECT_TRUE(r.client.set.standing(1)); // the others stand on both + EXPECT_TRUE(r.host.set.standing(1)); + expectSamePieces(r, 0, 0.01f); + EXPECT_EQ(r.propClient->stats().knocks, 1u); + EXPECT_EQ(r.propClient->stats().predicted, 0u); + // The client's car can hit the piece at rest: it is an object of its world. + std::vector listed; + r.client.set.instancesIn(1, listed); + EXPECT_EQ(listed.size(), 5u); // the four props still standing and the host's piece +} + +// The host's car breaks a prop into its BREAKnn pieces: the client shows each +// piece where the host has it. +TEST(PropSync, PiecesFollowTheHost) { + Race r; + Car car({12, 1, 3.0f}, {0, 0, -12}); + r.host.world.add(&car.body); + r.run(1.0); + r.host.world.remove(&car.body); + r.run(5.0); + ASSERT_FALSE(r.host.set.standing(2)); + EXPECT_FALSE(r.client.set.standing(2)); + expectSamePieces(r, 2, 0.01f); + EXPECT_EQ(piecesOf(r.client.set, 2).size(), 2u); +} + +// The client's own car knocks a prop: it moves at once on the client, the +// host makes the same knock with its copy of the car (here 150 ms later), +// and once both rest the client shows the host's. +TEST(PropSync, ClientKnockIsPredictedThenHandedOver) { + Race r; + Car mine({6, 1, 3.0f}, {0, 0, -10}); + r.clientCar = &mine.body; + r.client.world.add(&mine.body); + Car copy({6, 1, 4.5f}, {0, 0, -10}); // the host's view of it, behind in time + r.host.world.add(©.body); + double predictedAt = 0; + for (int i = 0; i < 7 * 60; ++i) { + r.frame(); + if (predictedAt == 0 && !r.client.set.standing(1)) + predictedAt = r.now; + if (i == 60) { + r.client.world.remove(&mine.body); + r.host.world.remove(©.body); + } + } + ASSERT_GT(predictedAt, 0); + EXPECT_FALSE(r.host.set.standing(1)); + const auto& s = r.propClient->stats(); + EXPECT_EQ(s.predicted, 1u); + EXPECT_EQ(s.confirmed, 1u); + EXPECT_EQ(s.undone, 0u); + EXPECT_GE(s.handovers, 1u); + // One piece on each machine, where the host's rests. + expectSamePieces(r, 1, 0.01f); + // The client's own simulated piece is gone: only the host's is shown. + for (const auto& inst : r.client.set.instances()) { + if (inst.everHit && inst.state != BangerSet::State::Gone) { + EXPECT_TRUE(inst.mirror); + } + } +} + +// A knock the host never makes (its copy of the car missed the prop) is +// undone: the prop stands again on the client. +TEST(PropSync, UnconfirmedKnockIsUndone) { + Race r; + Car mine({0, 1, 3.0f}, {0, 0, -10}); + r.clientCar = &mine.body; + r.client.world.add(&mine.body); + r.run(1.0); + r.client.world.remove(&mine.body); + EXPECT_FALSE(r.client.set.standing(0)); // predicted + r.run(2.5); + EXPECT_TRUE(r.client.set.standing(0)); + EXPECT_TRUE(piecesOf(r.client.set, 0).empty()); + EXPECT_EQ(r.propClient->stats().undone, 1u); + EXPECT_TRUE(r.host.set.standing(0)); +} + +// On a client only its own car touches props: the other cars (kinematic +// network cars, the shared traffic) pass through them, since the host +// decides what they do. +TEST(PropSync, OtherCarsPassThroughAClientsProps) { + Race r; + Car other({0, 1, 3.0f}, {0, 0, -10}); + r.client.world.add(&other.body); + r.run(1.5); + EXPECT_TRUE(r.client.set.standing(0)); + EXPECT_NEAR(other.body.ics.linearVelocity.z, -10.0f, 1e-3f); // not slowed + EXPECT_EQ(r.propClient->stats().predicted, 0u); + r.client.world.remove(&other.body); +} + +// The host's copy of another player's car is a moving kinematic body: it +// breaks a prop as a car of its mass would and keeps going; a still one +// does not, nor one that would not break an unbreakable prop. +TEST(PropSync, MovingKinematicBodiesBreakProps) { + TempBangers files; + bangers::BangerDataLibrary lib(files.vfs); + Machine m(lib); + m.place(layout()); + auto kinematic = [](Car& c, const Vec3& v) { + c.body.kinematic = true; + c.body.kinematicMoves = true; + c.body.kinematicVelocity = v; + c.body.resetCollider(); + }; + Car moving({0, 1, 3.0f}, {0, 0, -10}); + kinematic(moving, {0, 0, -10}); + Car still({6, 1, 2.0f}, {0, 0, 0}); + kinematic(still, {0, 0, 0}); + Car against({18, 1, 3.0f}, {0, 0, -10}); + kinematic(against, {0, 0, -10}); + for (Car* c : {&moving, &still, &against}) + m.world.add(&c->body); + for (int i = 0; i < 40; ++i) { + // Placed each frame as a network car is (RaceScreen::updateRemoteCars). + for (Car* c : {&moving, &against}) { + Mat34 at = c->body.ics.matrix; + at.m3 = at.m3 + c->body.kinematicVelocity * kDt; + c->body.place(at); + } + m.step(); + } + EXPECT_FALSE(m.set.standing(0)); + const auto pieces = piecesOf(m.set, 0); + ASSERT_EQ(pieces.size(), 1u); + EXPECT_LT(pieces.begin()->second.z, -3.0f); // thrown ahead of the car + EXPECT_TRUE(m.set.standing(1)); + EXPECT_TRUE(m.set.standing(3)); // ImpulseLimit2 1e15: a car does not break it + for (Car* c : {&moving, &still, &against}) + m.world.remove(&c->body); +} + +// Lost, late and reordered messages: the client still ends with the host's +// props, and every knock is applied once. +TEST(PropSync, LossyLinkConverges) { + Race r; + r.net.latencyMs = 120.0; + r.net.jitterMs = 60.0; // reorders + r.net.loss = 0.3; + Car car({0, 1, 3.0f}, {2.4f, 0, -10}); // across the row + r.host.world.add(&car.body); + r.run(1.2); + r.host.world.remove(&car.body); + r.run(6.0); + std::size_t knocked = 0; + for (std::size_t i = 0; i < placedProps(r.host.set); ++i) { + EXPECT_EQ(r.host.set.standing(i), r.client.set.standing(i)) << i; + if (!r.host.set.standing(i)) { + ++knocked; + expectSamePieces(r, i, 0.01f); + } + } + EXPECT_GE(knocked, 1u); + EXPECT_EQ(r.propClient->stats().knocks, knocked); + EXPECT_EQ(r.propClient->stats().refused, 0u); +} + +// A machine that loads the race late (or a prop knocked far from it) gets +// every knock so far at once: PropHost::catchUp. +TEST(PropSync, LateMachineCatchesUp) { + Race r; + Car car({0, 1, 3.0f}, {2.4f, 0, -10}); + r.host.world.add(&car.body); + r.run(1.0); + r.host.world.remove(&car.body); + r.run(3.0); + Machine late(r.lib); + late.place(layout()); + PropClient client(propCatalog(late.set)); + late.set.setReplica([](const phys::Instance&) { return false; }); + for (const auto& payload : PropHost::catchUp(r.host.set, static_cast(r.now))) { + net::PropKnocksEvent e; + ASSERT_TRUE(net::decodePayload(payload, e)); + EXPECT_TRUE(e.catchUp); + client.receiveKnocks(e); + } + client.update(late.set, r.now, nullptr); + for (std::size_t i = 0; i < placedProps(r.host.set); ++i) + EXPECT_EQ(late.set.standing(i), r.host.set.standing(i)) << i; +} + +// A part thrown off a car is a host prop like the others: the client draws +// it from the car's model, and the client's own car's part, thrown at once, +// hands over to the host's. +TEST(PropSync, ThrownCarPartsFollowTheHost) { + Race r; + const auto* data = r.lib.find("car_break0"); + ASSERT_TRUE(data); + const std::uint32_t tag = carPartTag(net::PropOwner::Player, 1, 0, 2); + int resolved = 0; + r.resolver = [&](const net::PropDescriptor& d) -> std::optional { + ++resolved; + if (d.source != net::PropSource::CarPart || d.ownerId != 1 || d.part != 0) + return std::nullopt; + BangerSet::MirrorSpec spec; + spec.data = data; + spec.model = "car"; + spec.mesh = "BREAK0"; + spec.paint = d.paint; + spec.tag = carPartTag(d.owner, d.ownerId, d.part, d.paint); + return spec; + }; + const Mat34 at = Mat34::translation({50, 1, 0}); + r.client.set.ejectPart(*data, "car", "BREAK0", 2, at, 4.0f, 1, tag); // the client's own, at once + r.run(0.1); + r.host.set.ejectPart(*data, "car", "BREAK0", 2, at, 4.0f, 1, tag); // the host's + r.run(6.0); + EXPECT_GT(resolved, 0); + std::vector hostParts, clientParts; + for (const auto& inst : r.host.set.instances()) + if (inst.tag == tag && inst.state != BangerSet::State::Gone) + hostParts.push_back(inst.matrix.m3); + for (const auto& inst : r.client.set.instances()) + if (inst.tag == tag && inst.state != BangerSet::State::Gone) { + EXPECT_TRUE(inst.mirror); + EXPECT_EQ(inst.mesh, "BREAK0"); + clientParts.push_back(inst.drawn.value_or(inst.matrix).m3); + } + ASSERT_EQ(hostParts.size(), 1u); + ASSERT_EQ(clientParts.size(), 1u); + EXPECT_LT(hostParts[0].dist(clientParts[0]), 0.01f); +} + +// What a dishonest or different host sends: another placement (only the cars' +// parts are followed, the client simulates its props itself), props it does +// not have, a slot that changes what it holds within a generation. +TEST(PropSync, RefusesWhatItCannotFollow) { + Race r; + net::PropStateMsg m; + m.time = 10100; + m.catalog = propCatalog(r.client.set); + net::PropSlot p; + p.slot = 0; + p.generation = 1; + p.what = {net::PropSource::Prop, 30000, 0, net::PropOwner::Player, 0, 0}; // not placed here + p.position = {0, 1, 0}; + m.slots.push_back(p); + p.slot = 1; + p.what.prop = 4; + m.slots.push_back(p); + p.slot = 1; // the same slot again + m.slots.push_back(p); + r.propClient->receive(m, 10100); + EXPECT_EQ(r.propClient->stats().refused, 1u); + m.time = 10200; + m.slots.resize(2); + m.slots[1].what.prop = 3; // slot 1, generation 1 held prop 4 + r.propClient->receive(m, 10200); + EXPECT_EQ(r.propClient->stats().refused, 2u); + net::PropKnocksEvent e; + e.time = 10000; + e.knocks = {{30000, 0}, {4, 0}}; + r.propClient->receiveKnocks(e); + r.propClient->update(r.client.set, 11000, nullptr); + EXPECT_TRUE(r.client.set.standing(0)); + EXPECT_FALSE(r.client.set.standing(4)); // the honest knock + EXPECT_EQ(r.propClient->stats().knocks, 1u); + + // A host with other props: the client follows none of them and goes + // back to simulating every contact itself. + m.catalog ^= 1u; + m.time = 10300; + r.propClient->receive(m, 10300); + EXPECT_TRUE(r.propClient->catalogMismatch()); + r.propClient->update(r.client.set, 11100, nullptr); + EXPECT_FALSE(r.client.set.replica()); + e.knocks = {{1, 0}}; + r.propClient->receiveKnocks(e); + r.propClient->update(r.client.set, 11200, nullptr); + EXPECT_TRUE(r.client.set.standing(1)); +} + +// Retail data: every machine of a network race places the same props with +// the same indices. The street props restart the random generator per road, +// the parked cars (network races only) draw from the stream they leave, and +// the traffic lights come from the AI map whatever the traffic density (the +// host runs the shared traffic, a client none). +TEST(PropSyncRetail, EveryMachinePlacesTheSameProps) { + MM2_REQUIRE_GAME_DATA(); + const auto& v = *test::gameData(); + struct Placement { + std::uint32_t catalog = 0; + std::size_t placed = 0; + }; + auto place = [&](const char* name, GameMode mode, int raceIndex, float traffic) -> Placement { + auto city = city::loadCity(v, name); + if (!city) + return {}; + bangers::BangerDataLibrary data(v); + CityLevel level(*city, v, [&](std::string_view n) { return data.has(n); }); + phys::World physics(level.takeMaterials()); + physics.setLevel(&level); + BangerSet set(data); + std::uint32_t state = 1; + set.add(bangers::placeCityProps(*city, v, data, bangers::racePropsName(mode, raceIndex), &state)); + ai::Random random(state); + takeVehCarInitDraws(random); + RaceConfig config; + config.city = name; + config.mode = mode; + config.raceIndex = raceIndex; + auto gizmos = world::initGizmos(v, *city, config, true, set, data, &level, random); + if (mode == GameMode::Cruise) { + ai::Settings settings; + settings.trafficDensity = traffic; + settings.pedestrianDensity = 0.0f; + settings.random = &random; + const auto ai = ai::World::create(*city, v, settings); + if (!ai) + return {}; + for (const ai::Signal& s : ai->signals()) { + PlacedProp p; + p.model = s.model; + p.transform = s.transform; + p.room = level.findRoom(s.position(), 0); + p.ownerDrawn = true; + set.addOne(p); + } + } + set.setWorld(&physics); + Placement out{propCatalog(set), placedProps(set)}; + set.setWorld(nullptr); + return out; + }; + for (const char* name : {"sf", "london"}) { + // Cruise: the host at the cruise menu's traffic, a client at none. + const Placement host = place(name, GameMode::Cruise, -1, 0.5f); + const Placement client = place(name, GameMode::Cruise, -1, 0.0f); + ASSERT_GT(host.placed, 1000u) << name; + EXPECT_LT(host.placed, net::kMaxPropIds) << name; + EXPECT_EQ(host.placed, client.placed) << name; + EXPECT_EQ(host.catalog, client.catalog) << name; + // A checkpoint race: parked cars, no traffic lights. + const Placement a = place(name, GameMode::Checkpoint, 0, 0.0f); + const Placement b = place(name, GameMode::Checkpoint, 0, 0.0f); + EXPECT_EQ(a.placed, b.placed) << name; + EXPECT_EQ(a.catalog, b.catalog) << name; + EXPECT_NE(a.catalog, host.catalog) << name; + } +} diff --git a/tests/net/test_fuzz.cpp b/tests/net/test_fuzz.cpp index c8ca8e4..8dd1f31 100644 --- a/tests/net/test_fuzz.cpp +++ b/tests/net/test_fuzz.cpp @@ -4,6 +4,7 @@ // 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/PropState.h" #include "net/Discovery.h" #include "net/NatPmp.h" #include "net/Session.h" @@ -159,6 +160,49 @@ std::vector corpus() { car.gear = 3; ambient.entities.push_back(car); c.push_back(encodeMessage(ambient)); + // The host's props: a piece flying, a prop at rest, a slot only still + // there, a car's thrown part; and their knocks. + PropStateMsg props; + props.time = 6000; + props.catalog = 0xF5672068u; + PropSlot piece; + piece.slot = 3; + piece.generation = 9; + piece.what.source = PropSource::PropPart; + piece.what.prop = 2714; + piece.what.part = 1; + piece.moving = true; + piece.position = {-1144.4f, 107.8f, 20.2f}; + piece.orientation = Quat::fromAxisAngle(Vec3{1.0f, 0.0f, 0.0f}, 0.7f); + piece.velocity = {0.5f, -3.0f, 6.0f}; + piece.angularVelocity = {2.0f, 0.0f, -1.0f}; + props.slots.push_back(piece); + PropSlot rest = piece; + rest.slot = 4; + rest.what.source = PropSource::Prop; + rest.moving = false; + props.slots.push_back(rest); + PropSlot still; + still.slot = 39; + still.generation = 2; + still.hasState = false; + props.slots.push_back(still); + PropSlot part = piece; + part.slot = 0; + part.what = {PropSource::CarPart, 0, 12, PropOwner::Catalog, 7, 3}; + props.slots.push_back(part); + c.push_back(encodeMessage(props)); + PropKnocksEvent knocks; + knocks.time = 6100; + knocks.knocks = {{2714, 0}, {6906, 35}, {12, 65535}}; + c.push_back(encodePayload(knocks)); + knocks.catchUp = true; + c.push_back(encodePayload(knocks)); + GameEventMsg knocksEvent; + knocksEvent.type = kPropKnocksEvent; + knocksEvent.time = 6100; + knocksEvent.payload = encodePayload(knocks); + c.push_back(encodeMessage(knocksEvent)); LanAdvert advert; advert.sessionName = "Fuzz"; @@ -316,6 +360,35 @@ void checkAmbient(const AmbientStateMsg& m) { } } +void checkProps(const PropStateMsg& m) { + ASSERT_LE(m.slots.size(), kMaxPropSlots); + for (const PropSlot& p : m.slots) { + ASSERT_LT(p.slot, kMaxPropSlots); + ASSERT_LT(p.generation, kPropGenerations); + if (!p.hasState) + continue; + ASSERT_LE(p.what.source, PropSource::Last); + ASSERT_LT(p.what.prop, kMaxPropIds); + if (p.what.source == PropSource::CarPart) { + ASSERT_LT(p.what.ownerId, kMaxPropOwners); + ASSERT_LT(p.what.part, kMaxPropCarParts); + ASSERT_LE(p.what.paint, kMaxPropPaint); + } else { + ASSERT_LT(p.what.part, kMaxPropParts); + } + for (float v : {p.position.x, p.position.y, p.position.z, p.velocity.x, p.velocity.y, p.velocity.z, + p.angularVelocity.x, p.angularVelocity.y, p.angularVelocity.z}) + ASSERT_TRUE(std::isfinite(v)); + ASSERT_LE(std::abs(p.position.x), kPropPositionRange + 0.01f); + ASSERT_LE(std::abs(p.velocity.y), kPropVelocityRange + 0.01f); + ASSERT_LE(std::abs(p.angularVelocity.z), kPropSpinRange + 0.01f); + ASSERT_TRUE(p.moving || (p.velocity.mag2() == 0.0f && p.angularVelocity.mag2() == 0.0f)); + const float n = std::sqrt(p.orientation.x * p.orientation.x + p.orientation.y * p.orientation.y + + p.orientation.z * p.orientation.z + p.orientation.w * p.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); @@ -417,6 +490,9 @@ void decodeEverything(std::span b) { if (const auto m = decoded(b)) { checkAmbient(*m); } + if (const auto m = decoded(b)) { + checkProps(*m); + } std::uint32_t nonce = 0; (void)decodeLanQuery(b, nonce); @@ -441,6 +517,13 @@ void decodeEverything(std::span b) { if (const auto e = payload(b)) { ASSERT_TRUE(std::isfinite(e->damage)); } + if (const auto e = payload(b)) { + ASSERT_LE(e->knocks.size(), kMaxPropKnocksPerEvent); + for (const auto& k : e->knocks) { + ASSERT_LT(k.prop, kMaxPropIds); + ASSERT_TRUE(!e->catchUp || k.delay == 0); + } + } if (const auto e = payload(b)) { ASSERT_LE(e->subject, kDamageOwnCar); ASSERT_LE(e->patches.size(), kMaxDamagePatches); @@ -682,6 +765,11 @@ TEST(Fuzz, ClientSurvivesMutatedTraffic) { EXPECT_LE(ambient.size(), Session::kMaxQueuedAmbientStates); for (const auto& m : ambient) checkAmbient(m); + // And the props. + const auto props = client.takePropStates(); + EXPECT_LE(props.size(), Session::kMaxQueuedPropStates); + for (const auto& m : props) + checkProps(m); } // The race start under mutated race messages from a joiner: whatever it diff --git a/tests/net/test_prop_state.cpp b/tests/net/test_prop_state.cpp new file mode 100644 index 0000000..bd1340a --- /dev/null +++ b/tests/net/test_prop_state.cpp @@ -0,0 +1,153 @@ +// The host's props on the wire (net/PropState.h): the ring's state and the +// knocks event. + +#include "net/PropState.h" + +#include + +#include + +using namespace mm2; +using namespace mm2::net; + +namespace { + +PropSlot flying(std::uint8_t slot) { + PropSlot p; + p.slot = slot; + p.generation = 5; + p.what.source = PropSource::PropPart; + p.what.prop = 7067; + p.what.part = 2; + p.moving = true; + p.position = {-3000.25f, 120.5f, 4095.75f}; + p.orientation = Quat::fromAxisAngle(Vec3{0.0f, 0.6f, 0.8f}, 1.1f); + p.velocity = {12.0f, -30.0f, 100.0f}; + p.angularVelocity = {-40.0f, 3.0f, 0.5f}; + return p; +} + +} // namespace + +TEST(PropState, RoundTripsEverySourceAndState) { + PropStateMsg m; + m.time = 123456; + m.catalog = 0xF5672068u; + m.slots.push_back(flying(0)); + PropSlot rest = flying(1); + rest.what = {PropSource::Prop, 32767, 0, PropOwner::Player, 0, 0}; + rest.moving = false; + rest.velocity = rest.angularVelocity = {}; + m.slots.push_back(rest); + PropSlot still; + still.slot = 63; + still.generation = 15; + still.hasState = false; + m.slots.push_back(still); + PropSlot part = flying(2); + part.what = {PropSource::CarPart, 0, 19, PropOwner::Catalog, 63, 15}; + m.slots.push_back(part); + + const auto bytes = encodeMessage(m); + PropStateMsg back; + ASSERT_TRUE(decodeMessage(bytes, back)); + EXPECT_EQ(back.time, m.time); + EXPECT_EQ(back.catalog, m.catalog); + ASSERT_EQ(back.slots.size(), 4u); + for (std::size_t i = 0; i < back.slots.size(); ++i) { + const PropSlot& a = m.slots[i]; + const PropSlot& b = back.slots[i]; + EXPECT_EQ(b.slot, a.slot); + EXPECT_EQ(b.generation, a.generation); + EXPECT_EQ(b.hasState, a.hasState); + if (!a.hasState) + continue; + EXPECT_TRUE(b.what == a.what) << i; + EXPECT_EQ(b.moving, a.moving); + // 22 bits over +-8 km: 4 mm. + EXPECT_NEAR(b.position.x, a.position.x, 0.004f); + EXPECT_NEAR(b.position.y, a.position.y, 0.004f); + EXPECT_NEAR(b.position.z, a.position.z, 0.004f); + const Quat& qa = a.orientation; + const Quat& qb = b.orientation; + const float dot = qa.x * qb.x + qa.y * qb.y + qa.z * qb.z + qa.w * qb.w; + EXPECT_NEAR(std::abs(dot), 1.0f, 2e-3f); + if (a.moving) { + EXPECT_NEAR(b.velocity.z, a.velocity.z, 0.04f); + EXPECT_NEAR(b.angularVelocity.x, a.angularVelocity.x, 0.04f); + } else { + EXPECT_EQ(b.velocity.mag2(), 0.0f); + } + } +} + +// The busiest message, every slot of MM2's 40-slot ring (dgBangerManager:: +// Init(40)) flying, still fits one unfragmented datagram. +TEST(PropState, AFullRingFitsOneDatagram) { + PropStateMsg m; + for (std::uint8_t k = 0; k < 40; ++k) + m.slots.push_back(flying(k)); + EXPECT_LT(encodeMessage(m).size(), 1100u); + std::size_t bits = 0; + for (const auto& p : m.slots) + bits += propSlotBits(p); + EXPECT_LE(bits, 40u * 8u * 26u); // 26 bytes a flying piece at most +} + +TEST(PropState, RefusesMalformedMessages) { + PropStateMsg m; + m.slots.push_back(flying(0)); + auto bytes = encodeMessage(m); + PropStateMsg back; + // Every truncation fails. + for (std::size_t n = 0; n < bytes.size(); ++n) { + const std::vector cut(bytes.begin(), bytes.begin() + static_cast(n)); + EXPECT_FALSE(decodeMessage(cut, back)) << n; + } + // A count beyond the ring. + WriteStream s; + std::uint8_t type = static_cast(MsgType::PropState); + std::uint32_t time = 1, catalog = 2, count = kMaxPropSlots + 1; + s.u8(type); + s.u32(time); + s.u32(catalog); + s.varU32(count); + EXPECT_FALSE(decodeMessage(s.writer().take(), back)); +} + +TEST(PropState, KnocksEventRoundTripsAndFitsAPayload) { + PropKnocksEvent e; + e.time = 50000; + for (std::uint16_t i = 0; i < 250; ++i) + e.knocks.push_back({static_cast(32767 - i), static_cast(i * 200)}); + const auto live = encodePayload(e); + EXPECT_LE(live.size(), kMaxEventPayload); // what PropHost puts in one event + PropKnocksEvent back; + ASSERT_TRUE(decodePayload(live, back)); + ASSERT_EQ(back.knocks.size(), e.knocks.size()); + EXPECT_EQ(back.time, e.time); + EXPECT_FALSE(back.catchUp); + EXPECT_EQ(back.knocks[249].prop, 32767 - 249); + EXPECT_EQ(back.knocks[249].delay, 249 * 200); + + PropKnocksEvent all; + all.catchUp = true; + for (std::uint16_t i = 0; i < 500; ++i) + all.knocks.push_back({i, 77}); // a catch-up has no delays + const auto bytes = encodePayload(all); + EXPECT_LE(bytes.size(), kMaxEventPayload); + ASSERT_TRUE(decodePayload(bytes, back)); + EXPECT_TRUE(back.catchUp); + ASSERT_EQ(back.knocks.size(), 500u); + EXPECT_EQ(back.knocks[499].prop, 499); + EXPECT_EQ(back.knocks[499].delay, 0); + + // More knocks than an event may hold are refused. + WriteStream s; + std::uint32_t time = 1, count = kMaxPropKnocksPerEvent + 1; + bool catchUp = true; + s.u32(time); + s.boolean(catchUp); + s.varU32(count); + EXPECT_FALSE(decodePayload(s.writer().take(), back)); +} From e6802c298e676aafb2fe37a7c7c1c447cc397290 Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Fri, 9 Oct 2026 13:19:14 -0700 Subject: [PATCH 009/129] Let a client's car knock traffic loose at once, and predict cars by the ground they cover A client's car met the received traffic as kinematic instances of infinite mass: it bounced off a car as off a moving wall, while on the host the same contact gives the rail car a body and the two cars push each other (aiVehicleInstance::AttachEntity, aiVehicleActive); the car itself flew off only a round trip later, when the host's knock came back. TrafficBodies now takes its traffic through an interface (the AI's for the host and single player, unchanged), and a shared-traffic client gives it the received cars on their rails (game::NetTrafficCars). The client's own car hitting one attaches a body there as on the host, so the collision runs the host's physics; only that car knocks cars loose (the police and the other players' cars reach the client in the host's messages). The car stays the client's until a host message shows it off its rail too, or still on it half a round trip and 150 ms after the hit (the host did not knock it); the drawing then blends from the local body to the host's car. The cars off their rails on the host stay kinematic instances. The host's trace showed rail cars moving over the ground at 70-80 % of their AI speed in turns (ai::Traffic moves a car along its curves by their parameter) and not at all when held at the end of a lane at speed. The host now sends a rail car's speed over the ground when it differs (12 more bits), and the client predicts its position with it while its velocity (what its body takes when hit) stays the AI's. TrafficClient::poseAt gives a car's physics pose at any time, for a client that replays its own car's steps. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/app/RaceScreen.cpp | 76 +++++++++++++++--- src/game/Interpolation.cpp | 6 ++ src/game/Interpolation.h | 2 + src/game/TrafficBodies.cpp | 44 ++++++++-- src/game/TrafficBodies.h | 30 ++++++- src/game/net/NetTrafficCars.cpp | 81 +++++++++++++++++++ src/game/net/NetTrafficCars.h | 92 +++++++++++++++++++++ src/game/net/TrafficPrediction.cpp | 33 +++++--- src/game/net/TrafficPrediction.h | 25 ++++-- src/game/net/TrafficSync.cpp | 58 +++++++++++++- src/game/net/TrafficSync.h | 13 +++ src/game/net/TrafficTrace.cpp | 5 ++ src/game/net/TrafficTrace.h | 5 ++ src/net/AmbientState.h | 19 ++++- tests/game/test_net_traffic_cars.cpp | 106 +++++++++++++++++++++++++ tests/game/test_traffic_prediction.cpp | 33 ++++++-- tests/net/test_ambient_state.cpp | 12 ++- 17 files changed, 597 insertions(+), 43 deletions(-) create mode 100644 src/game/net/NetTrafficCars.cpp create mode 100644 src/game/net/NetTrafficCars.h create mode 100644 tests/game/test_net_traffic_cars.cpp diff --git a/src/app/RaceScreen.cpp b/src/app/RaceScreen.cpp index 4f5909c..cb34870 100644 --- a/src/app/RaceScreen.cpp +++ b/src/app/RaceScreen.cpp @@ -43,6 +43,7 @@ #include "game/fx/Weather.h" #include "game/net/DamageSync.h" #include "game/net/NetGame.h" +#include "game/net/NetTrafficCars.h" #include "game/net/RaceStart.h" #include "game/net/TrafficProxies.h" #include "game/net/TrafficSync.h" @@ -100,6 +101,11 @@ bool serialize(S& s, CrSetEvent& e) { constexpr int kNetPlayerTrackedId = 30000; constexpr int kNetPoliceId = 400; constexpr std::uint64_t kNetTrafficIntervalMs = 50; +// A shared-traffic client: how long after half a round trip past its car's +// hit a host state may still show the car on its rail before the client's +// knock is withdrawn (the hit report's way to the host, a message interval, +// two AI steps and the jitter). +constexpr double kNetKnockConfirmMs = 150.0; const char* modePrefix(game::GameMode m) { switch (m) { @@ -727,7 +733,9 @@ class RaceScreen final : public Screen { m_drawnPhys.record(drawnKey(Drawn::RemoteTrailer, id), rv.sim->trailerPose(), resets); } m_remoteStepBodies = std::move(stepBodies); - if (m_ai && m_trafficBodies) + if (m_netTrafficCars && m_trafficBodies) + game::recordTrafficBodies(m_drawnPhys, *m_trafficBodies); // a shared-traffic client's + else if (m_ai && m_trafficBodies) game::recordTrafficBodies(m_drawnPhys, *m_ai, *m_trafficBodies); if (m_bangers && m_world) game::recordProps(m_drawnPhys, *m_bangers, *m_world); @@ -859,8 +867,9 @@ class RaceScreen final : public Screen { // it has a body (aiVehicleInstance::Draw), between the physics' last two // samples. std::optional physicalTrafficCar(int id) const { - // OpenMM2: a shared-traffic client's knocked cars, as the host has them. - if (m_trafficClient) { + // OpenMM2: a shared-traffic client's knocked cars, as the host has them + // (or, until the host's messages lead, as this machine knocked them). + if (m_trafficClient && !(m_netTrafficCars && m_netTrafficCars->knocked(id))) { const auto it = m_netPhysical.find(id); return it != m_netPhysical.end() ? std::optional(it->second) : std::nullopt; } @@ -3357,7 +3366,14 @@ class RaceScreen final : public Screen { return true; }); if (m_world) { - m_trafficBodies = std::make_unique(*m_ai, *m_world); + // OpenMM2: a shared-traffic client's are the received cars, + // which its own car may knock loose ahead of the host. + if (netTrafficClient(ctx)) { + m_netTrafficCars = std::make_unique(); + m_trafficBodies = std::make_unique(*m_netTrafficCars, *m_world); + } else { + m_trafficBodies = std::make_unique(*m_ai, *m_world); + } m_trafficBodies->setWeatherFriction(weatherFriction()); // aiVehicleActive's impacts, for the ambient cars' sounds. m_trafficBodies->setImpactCallback( @@ -4152,7 +4168,12 @@ class RaceScreen final : public Screen { // 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()) { + // The cars on their rails this machine's car knocked loose + // (NetTrafficCars), and the ones off them it hit. + std::vector hits = m_trafficProxies->takeHits(); + if (m_netTrafficCars) + std::ranges::copy(m_netTrafficCars->takeKnocks(), std::back_inserter(hits)); + for (int id : hits) { const auto it = std::ranges::find_if(m_netCars, [id](const ai::AmbientCar& c) { return c.id == id; }); if (it == m_netCars.end()) @@ -4194,9 +4215,18 @@ class RaceScreen final : public Screen { // steps, and the session time that car reaches in them. const Vec3 me = m_player->sim().modelMatrix().m3; game::traceClientView(trace, frame, own, me); - for (const auto& c : m_trafficClient->cars()) - if (c.transform.m3.dist2(me) < 9e4f) - game::traceClientCar(trace, frame, renderTime, c, c.extrapolated ? 2 : 0); + for (const auto& c : m_trafficClient->cars()) { + if (c.transform.m3.dist2(me) >= 9e4f) + continue; + // A car this machine knocked loose: where its body is (mode 3). + const Mat34* local = m_netTrafficCars && m_netTrafficCars->knocked(c.id) && m_trafficBodies + ? m_trafficBodies->transformOf(c.id) + : nullptr; + game::TrafficClient::Car shown = c; + if (local) + shown.transform = *local; + game::traceClientCar(trace, frame, renderTime, shown, local ? 3 : c.extrapolated ? 2 : 0); + } } if (const auto steps = m_trafficClient->lightSteps(renderTime)) m_ai->advanceLightsTo(*steps); @@ -4204,6 +4234,7 @@ class RaceScreen final : public Screen { m_netPhysical.clear(); std::unordered_set seen; std::vector>> wheeled; // knocked cars with their wheels + std::vector received; for (const auto& c : m_trafficClient->cars()) { const std::string* model = m_trafficCatalog.name(c.model); if (c.kind != net::AmbientKind::Traffic || !model) @@ -4220,10 +4251,32 @@ class RaceScreen final : public Screen { seen.insert(c.id); if (c.wheels && m_netCars.back().data) wheeled.push_back({m_netCars.size() - 1, c.wheelOffsets}); + received.push_back({m_netCars.back(), c.stateTime}); } std::erase_if(m_netTireRotation, [&](const auto& e) { return !seen.contains(e.first); }); - if (m_trafficProxies) - m_trafficProxies->update(m_netCars); + // The cars on their rails are the level's instances in TrafficBodies, + // which this machine's car may knock loose (NetTrafficCars); the ones + // off them on the host move as it says (TrafficProxies). A knocked + // car goes back to the host's messages once they show it knocked too, + // or still on its rail a trip after the hit. + if (m_netTrafficCars) { + const double rtt = static_cast(ctx.netGame->peerStats(net::kHostPlayerId).rttMs); + m_netTrafficCars->update(received, own, rtt * 0.5 + kNetKnockConfirmMs); + for (const auto& h : m_netTrafficCars->takeHandovers()) { + const float off = m_trafficClient->setDrawn(h.id, h.pose); + game::traceHandover(ctx.netGame->traceFile(), own, h.id, h.confirmed, off); + if (m_debugNetTraffic) + log::info("nettraffic: client's knock of traffic car {} {} the host ({:.2f} m off)", h.id, + h.confirmed ? "confirmed by" : "withdrawn: not knocked on", off); + } + } + if (m_trafficProxies) { + std::vector moving; + for (const ai::AmbientCar& c : m_netCars) + if (!m_netTrafficCars || c.goal != ai::AmbientGoal::RandomDrive) + moving.push_back(c); + m_trafficProxies->update(moving); + } // Drawn where the rest of the scene is, a physics sample behind the // frame (game::StepHistory), as far from the cars' time as the scene // is from this machine's car's. @@ -5438,6 +5491,9 @@ class RaceScreen final : public Screen { std::optional m_netToGo; // its countdown (Session::setNetStart) bool multiplayer(Context& ctx) const { return m_result.config.multiplayer && ctx.netGame; } std::unique_ptr m_aiRenderer; + // A shared-traffic client's received cars as the bodies' traffic + // (outlives m_trafficBodies, which holds it). + std::unique_ptr m_netTrafficCars; std::unique_ptr m_trafficBodies; // OpenMM2's shared traffic of a network cruise (see netTraffic()). diff --git a/src/game/Interpolation.cpp b/src/game/Interpolation.cpp index aebf493..2c21153 100644 --- a/src/game/Interpolation.cpp +++ b/src/game/Interpolation.cpp @@ -222,6 +222,12 @@ void recordTrafficBodies(StepHistory& history, const ai::World& ai, const Traffi history.record(drawnKey(Drawn::TrafficCar, static_cast(c.id)), *pose); } +void recordTrafficBodies(StepHistory& history, const TrafficBodies& bodies) { + for (const ai::AmbientCar& c : bodies.source().cars()) + if (const auto pose = trafficBodyPose(bodies, c.id)) + history.record(drawnKey(Drawn::TrafficCar, static_cast(c.id)), *pose); +} + void recordProps(StepHistory& history, const bangers::BangerSet& props, const phys::World& world) { const auto& instances = props.instances(); for (std::size_t i = 0; i < instances.size(); ++i) diff --git a/src/game/Interpolation.h b/src/game/Interpolation.h index 5c42fac..456c342 100644 --- a/src/game/Interpolation.h +++ b/src/game/Interpolation.h @@ -150,6 +150,8 @@ void recordAiStep(StepHistory& history, const ai::World& world); // id) and the props an active simulates in the world (by instance), as they // stand before the sample. void recordTrafficBodies(StepHistory& history, const ai::World& ai, const TrafficBodies& bodies); +// The same for the cars of `bodies`' source (a shared-traffic client's). +void recordTrafficBodies(StepHistory& history, const TrafficBodies& bodies); void recordProps(StepHistory& history, const bangers::BangerSet& props, const phys::World& world); // A traffic car with a body as it is now: its matrix and its wheels // (aiVehicleActive's vehWheelCheaps); nullopt without a body. diff --git a/src/game/TrafficBodies.cpp b/src/game/TrafficBodies.cpp index 0fddde3..ca1bd77 100644 --- a/src/game/TrafficBodies.cpp +++ b/src/game/TrafficBodies.cpp @@ -354,8 +354,12 @@ phys::Body* TrafficBodies::RailCar::attachEntity() { // body has already taken its speed). if (active) return &active->body; + // OpenMM2: a shared-traffic client's received cars leave their rails for + // its own car only (the world marks the player's hit before it asks). + if (!m_owner.m_source.attachable(id, hitByPlayer)) + return nullptr; Active& a = m_owner.attach(*this); - m_owner.m_ai.traffic().impact(id, {}); + m_owner.m_source.impact(id); return &a.body; } @@ -425,7 +429,37 @@ void TrafficBodies::Active::onImpact(phys::Collider& self, const phys::Impact& i // --- TrafficBodies (aiVehicleManager) ---------------------------------------------------------- -TrafficBodies::TrafficBodies(ai::World& ai, phys::World& world) : m_ai(ai), m_world(world) { +namespace { + +// The AI's traffic: aiVehicleAmbient's side of the hand-over. +class AiSource final : public TrafficBodies::Source { +public: + explicit AiSource(ai::World& ai) : m_ai(ai) {} + const std::vector& cars() const override { return m_ai.cars(); } + void impact(int carId) override { m_ai.traffic().impact(carId, {}); } + void detach(int carId, const Mat34& pose, bool upright) override { + m_ai.traffic().detach(carId, pose, upright); + } + void setPhysicalTransform(int carId, const Mat34& transform) override { + m_ai.traffic().setPhysicalTransform(carId, transform); + } + +private: + ai::World& m_ai; +}; + +} // namespace + +TrafficBodies::TrafficBodies(ai::World& ai, phys::World& world) + : m_ownSource(std::make_unique(ai)), m_source(*m_ownSource), m_world(world) { + m_actives.reserve(m_order.size()); + for (std::size_t i = 0; i < m_order.size(); ++i) { + m_actives.push_back(std::make_unique(*this)); + m_order[i] = m_actives.back().get(); + } +} + +TrafficBodies::TrafficBodies(Source& source, phys::World& world) : m_source(source), m_world(world) { m_actives.reserve(m_order.size()); for (std::size_t i = 0; i < m_order.size(); ++i) { m_actives.push_back(std::make_unique(*this)); @@ -523,7 +557,7 @@ void TrafficBodies::detach(Active& active) { // when it already is one, a wreck (flag 2); the AI decides. Its matrix // is where the body last left it. const Mat34 pose = active.body.boundMatrix; - m_ai.traffic().detach(car->id, pose, upright); + m_source.detach(car->id, pose, upright); // The car is an instance again (flag 0x10) without an active. car->railMatrix = pose; car->speed = 0.0f; @@ -564,7 +598,7 @@ void TrafficBodies::reset() { } void TrafficBodies::beforeStep() { - const auto& cars = m_ai.cars(); + const auto& cars = m_source.cars(); const auto find = [&](int id) -> const ai::AmbientCar* { for (const ai::AmbientCar& c : cars) if (c.id == id) @@ -676,7 +710,7 @@ void TrafficBodies::afterStep() { continue; const float y = a.body.ics.matrix.m3.y; if (a.sleep.state != phys::Sleep::Asleep && kFallOutY <= y) { - m_ai.traffic().setPhysicalTransform(a.rail->id, a.body.boundMatrix); + m_source.setPhysicalTransform(a.rail->id, a.body.boundMatrix); continue; } detach(a); diff --git a/src/game/TrafficBodies.h b/src/game/TrafficBodies.h index 95350e2..3525def 100644 --- a/src/game/TrafficBodies.h +++ b/src/game/TrafficBodies.h @@ -56,9 +56,36 @@ class TrafficBodies final : public InstanceSource { public: using ImpactCallback = std::function; + // The traffic the cars belong to, aiVehicleAmbient's side of the + // hand-over: the AI's (ai::World), or on a network client of a + // shared-traffic cruise the cars received from the host (OpenMM2 extra, + // game::NetTrafficCars), which the client's car may knock loose ahead of + // the host. + class Source { + public: + virtual ~Source() = default; + virtual const std::vector& cars() const = 0; + // aiVehicleAmbient::Impact(1): the car left its rail for a body. + virtual void impact(int carId) = 0; + // aiVehicleActive::Detach: back from its body at `pose`, upright or + // not (aiVehicleAmbient::Impact(0) or a wreck). + virtual void detach(int carId, const Mat34& pose, bool upright) = 0; + // aiGoalCollision::Update: the car's matrix follows its body. + virtual void setPhysicalTransform(int carId, const Mat34& transform) = 0; + // Whether a collision may give the car a body; `byPlayer`: the + // local player's car is in it. The AI's cars always may. + virtual bool attachable(int carId, bool byPlayer) const { + (void)carId; + (void)byPlayer; + return true; + } + }; + // The world's level (phys::World::setLevel) gives the rail cars their // rooms; the level must list instancesIn() (CityLevel::addSource). TrafficBodies(ai::World& ai, phys::World& world); + TrafficBodies(Source& source, phys::World& world); + const Source& source() const { return m_source; } ~TrafficBodies() override; TrafficBodies(const TrafficBodies&) = delete; TrafficBodies& operator=(const TrafficBodies&) = delete; @@ -137,7 +164,8 @@ class TrafficBodies final : public InstanceSource { // The body stops being simulated without going back to the AI. void drop(Active& active); - ai::World& m_ai; + std::unique_ptr m_ownSource; // the AI's, for the ai::World constructor + Source& m_source; phys::World& m_world; // One per AI car id, created when the car first appears (stable // addresses: the level holds pointers during the step). diff --git a/src/game/net/NetTrafficCars.cpp b/src/game/net/NetTrafficCars.cpp new file mode 100644 index 0000000..443a8b2 --- /dev/null +++ b/src/game/net/NetTrafficCars.cpp @@ -0,0 +1,81 @@ +// A shared-traffic client's received cars as TrafficBodies' traffic (OpenMM2 +// extra). See NetTrafficCars.h. +#include "game/net/NetTrafficCars.h" + +#include +#include + +namespace mm2::game { + +void NetTrafficCars::update(std::span received, double now, double confirmMs) { + m_now = now; + m_cars.clear(); + std::unordered_set present; + for (const Received& r : received) { + const ai::AmbientCar& c = r.car; + present.insert(c.id); + const bool onRail = c.goal == ai::AmbientGoal::RandomDrive; + if (const auto it = m_knocks.find(c.id); it != m_knocks.end()) { + const Knock& k = it->second; + const double heard = static_cast(r.stateTime); + if (k.generation != c.spawns) { + m_knocks.erase(it); // another car in the slot + } else if (!onRail) { + // The host has knocked it too: its messages lead from now. + ++m_stats.confirmed; + handOver(c.id, k, true); + m_knocks.erase(it); + continue; + } else if (heard > k.time + confirmMs || now > k.time + kMaxLocalMs) { + // On its rail well after the hit on the host: it was not hit + // there. + ++m_stats.withdrawn; + handOver(c.id, k, false); + m_knocks.erase(it); + } else { + ai::AmbientCar a = c; + a.transform = k.pose; + a.goal = ai::AmbientGoal::Collision; + a.physical = true; + a.speed = 0.0f; + m_cars.push_back(a); + continue; + } + } + // Off its rail on the host: a moving instance (game::TrafficProxies). + if (onRail) + m_cars.push_back(c); + } + std::erase_if(m_knocks, [&](const auto& e) { return !present.contains(e.first); }); +} + +void NetTrafficCars::handOver(int id, const Knock& knock, bool confirmed) { + m_handovers.push_back({id, knock.pose, confirmed}); +} + +void NetTrafficCars::impact(int carId) { + const auto it = std::ranges::find_if(m_cars, [carId](const ai::AmbientCar& c) { return c.id == carId; }); + if (it == m_cars.end()) + return; + m_knocks[carId] = {it->spawns, m_now, it->transform}; + it->physical = true; + it->goal = ai::AmbientGoal::Collision; + m_new.push_back(carId); + ++m_stats.knocks; +} + +void NetTrafficCars::detach(int carId, const Mat34& pose, bool) { + // The body came to rest: the car stays there until the host's messages + // take it over. + if (const auto it = m_knocks.find(carId); it != m_knocks.end()) + it->second.pose = pose; +} + +void NetTrafficCars::setPhysicalTransform(int carId, const Mat34& transform) { + if (const auto it = m_knocks.find(carId); it != m_knocks.end()) + it->second.pose = transform; +} + +bool NetTrafficCars::attachable(int, bool byPlayer) const { return byPlayer; } + +} // namespace mm2::game diff --git a/src/game/net/NetTrafficCars.h b/src/game/net/NetTrafficCars.h new file mode 100644 index 0000000..6ac10f1 --- /dev/null +++ b/src/game/net/NetTrafficCars.h @@ -0,0 +1,92 @@ +#pragma once + +// A shared-traffic client's received cars as game::TrafficBodies' traffic +// (OpenMM2 extra; docs/multiplayer.md "Shared traffic", docs/review/ +// multiplayer-desync-traffic.md). +// +// The received cars on their rails are instances of this machine's level as +// the host's rail cars are of its own (aiVehicleInstance), at their +// predicted places. When this machine's car hits one, the car takes a body +// at once (aiVehicleInstance::AttachEntity, aiVehicleActive) and the +// collision runs as it does on the host: the client's car pushes a car of +// its mass instead of meeting a wall that never gives way, and the car +// flies off when it is hit rather than a round trip later. Only this +// machine's car knocks a car loose here; the host's police, the other +// players and the cars it knocked itself reach the client in its messages. +// +// The knocked car is this machine's until the host's messages either show it +// off its rail too (it then follows them, the drawing blending from where +// the local body left it) or show it still on its rail at a time well after +// the hit, when the host would have knocked it (the host did not: it goes +// back to its rail, blended the same way). + +#include "ai/Traffic.h" +#include "game/TrafficBodies.h" + +#include +#include +#include +#include + +namespace mm2::game { + +class NetTrafficCars final : public TrafficBodies::Source { +public: + // A received car: as an ambient car (game::ambientCarOf), and the session + // time of its newest state from the host. + struct Received { + ai::AmbientCar car; + std::uint32_t stateTime = 0; + }; + // Each frame before TrafficBodies::beforeStep: the received traffic cars + // at session time `now` (this machine's car's). A car this machine + // knocked loose goes back to the host's once a state of it stamped later + // than `confirmMs` after the hit still has it on its rail, or after + // kMaxLocalMs whatever the host says. + void update(std::span received, double now, double confirmMs); + static constexpr double kMaxLocalMs = 3000.0; + + // A car this machine knocked loose and handed back to the host's + // messages this frame, and where it was then. + struct Handover { + int id = 0; + Mat34 pose; + bool confirmed = false; // the host had knocked it too + }; + std::vector takeHandovers() { return std::exchange(m_handovers, {}); } + // The cars this machine's car knocked loose since the last call. + std::vector takeKnocks() { return std::exchange(m_new, {}); } + bool knocked(int id) const { return m_knocks.contains(id); } + + struct Stats { + std::uint64_t knocks = 0; // knocked loose by this machine's car + std::uint64_t confirmed = 0; // the host knocked them too + std::uint64_t withdrawn = 0; // the host kept them on their rails + }; + const Stats& stats() const { return m_stats; } + + // TrafficBodies::Source: the received cars on their rails, and the ones + // this machine knocked loose (physical, where their bodies are). + const std::vector& cars() const override { return m_cars; } + void impact(int carId) override; + void detach(int carId, const Mat34& pose, bool upright) override; + void setPhysicalTransform(int carId, const Mat34& transform) override; + bool attachable(int carId, bool byPlayer) const override; + +private: + struct Knock { + int generation = 0; + double time = 0.0; // session ms of the hit + Mat34 pose; + }; + void handOver(int id, const Knock& knock, bool confirmed); + + std::map m_knocks; + std::vector m_cars; + std::vector m_handovers; + std::vector m_new; + double m_now = 0.0; + Stats m_stats; +}; + +} // namespace mm2::game diff --git a/src/game/net/TrafficPrediction.cpp b/src/game/net/TrafficPrediction.cpp index 72c89d0..9847277 100644 --- a/src/game/net/TrafficPrediction.cpp +++ b/src/game/net/TrafficPrediction.cpp @@ -50,7 +50,7 @@ void RailMotionTracker::update(std::span cars, double time const float heading = groundHeading(forward); const Vec3& p = c.transform.m3; if (last.spawns != c.spawns) { - last = {c.spawns, p, heading, c.speed, time, {}, true}; + last = {c.spawns, p, heading, c.speed, c.speed, time, {}, true}; continue; } const double dtMs = time - last.time; @@ -63,6 +63,12 @@ void RailMotionTracker::update(std::span cars, double time // curvature stays as it was. if (ds > 0.05f) last.motion.curvature = std::clamp(wrapAngle(heading - last.heading) / ds, -2.0f, 2.0f); + // Its speed over the ground in the step (a move of more than 40 m/s + // put it somewhere else: the AI's speed stands). + const float ground = p.dist(last.position) / dt; + last.ground = ground > 40.0f ? c.speed : ground; + last.motion.groundSpeed = last.ground; + last.motion.slips = std::abs(last.ground - c.speed) > 0.25f; last.position = p; last.heading = heading; last.speed = c.speed; @@ -81,17 +87,26 @@ RailMotion RailMotionTracker::motion(int id) const { PredictedPose predictRailCar(const Mat34& transform, float speed, const RailMotion& motion, float dt) { PredictedPose out; dt = std::max(dt, 0.0f); - // The distance its speed and acceleration give, stopping at 0 (or at - // the speed it had, for a car reversing). - float v = speed + motion.accel * dt; + // The distance its speed over the ground and acceleration give (the + // acceleration scaled as the ground speed is), stopping at 0 (or at the + // speed it had, for a car reversing). + const float ground = motion.slips ? motion.groundSpeed : speed; + const bool scaled = motion.slips && std::abs(speed) > 0.5f; + const float scale = scaled ? std::clamp(ground / speed, 0.0f, 2.0f) : 1.0f; + const float a = motion.accel * scale; + float g = ground + a * dt; float s = 0.0f; - if ((speed >= 0.0f && v < 0.0f) || (speed < 0.0f && v > 0.0f)) { - const float stop = motion.accel != 0.0f ? -speed / motion.accel : 0.0f; - s = speed * stop * 0.5f; - v = 0.0f; + if ((ground >= 0.0f && g < 0.0f) || (ground < 0.0f && g > 0.0f)) { + const float stop = a != 0.0f ? -ground / a : 0.0f; + s = ground * stop * 0.5f; + g = 0.0f; } else { - s = (speed + v) * 0.5f * dt; + s = (ground + g) * 0.5f * dt; } + // Its own speed, which a body it takes when hit moves at. + float v = speed + motion.accel * dt; + if ((speed >= 0.0f && v < 0.0f) || (speed < 0.0f && v > 0.0f)) + v = 0.0f; const float turn = motion.curvature * s; const Vec3 forward = -transform.m2; out.transform.m0 = yawed(transform.m0, turn); diff --git a/src/game/net/TrafficPrediction.h b/src/game/net/TrafficPrediction.h index a1a4393..6d2e1c8 100644 --- a/src/game/net/TrafficPrediction.h +++ b/src/game/net/TrafficPrediction.h @@ -31,15 +31,22 @@ namespace mm2::game { -// How a car on its rail is moving: its speed's change and its heading's turn -// per metre (the sign of a spin about +y: from +z toward +x). +// How a car on its rail is moving: its speed's change, its heading's turn +// per metre (the sign of a spin about +y: from +z toward +x) and, where it +// is not its speed, its speed over the ground: ai::Traffic moves a car along +// its curves by their parameter, so in a turn it covers 20-30 % more or less +// ground than its speed says, and a car held at the end of its lane covers +// none. struct RailMotion { float accel = 0.0f; // m/s^2 float curvature = 0.0f; // rad/m + bool slips = false; // groundSpeed is not the speed + float groundSpeed = 0.0f; }; -// Host: each AI car's acceleration and curvature from its last two AI steps -// (a backward difference: the AI changes both smoothly from step to step). +// Host: each AI car's acceleration, curvature and speed over the ground from +// its last two AI steps (a backward difference: the AI changes them smoothly +// from step to step). class RailMotionTracker { public: // After an AI update; `time` is the session time of its last step (ms). @@ -54,6 +61,7 @@ class RailMotionTracker { Vec3 position; float heading = 0.0f; float speed = 0.0f; + float ground = 0.0f; double time = 0.0; RailMotion motion; bool seen = false; @@ -71,9 +79,12 @@ struct PredictedPose { }; // A car on its rail `dt` seconds after a state (`transform`, `speed`): -// along its heading (-m2, with its slope) for the distance its speed and -// acceleration give (stopping at 0: a rail car never reverses), its heading -// and its whole frame turned about the vertical by curvature x distance. +// along its heading (-m2, with its slope) for the distance its speed over the +// ground and its acceleration give (stopping at 0: a rail car never +// reverses), its heading and its whole frame turned about the vertical by +// curvature x distance. Its velocity is along its heading at its speed +// changed by the acceleration (what its body takes when it is hit, +// aiVehicleActive::Attach). PredictedPose predictRailCar(const Mat34& transform, float speed, const RailMotion& motion, float dt); // A physics body `dt` seconds after a state: along its velocity turned by // its yaw rate (an arc), its frame turned by its spin. diff --git a/src/game/net/TrafficSync.cpp b/src/game/net/TrafficSync.cpp index 6d5e63c..5bf9830 100644 --- a/src/game/net/TrafficSync.cpp +++ b/src/game/net/TrafficSync.cpp @@ -38,6 +38,10 @@ net::AmbientEntity toEntity(const SharedCar& c) { constexpr float kMaxCurvature = net::kAmbientCurvatureRange; if (std::abs(c.motion.curvature) >= 0.001f && (c.speed != 0.0f || e.accel > 0.0f)) e.curvature = std::clamp(c.motion.curvature, -kMaxCurvature, kMaxCurvature); + if (c.motion.slips) { + e.slips = true; + e.groundSpeed = std::clamp(c.motion.groundSpeed, -net::kAmbientSpeedRange, net::kAmbientSpeedRange); + } e.velocity = c.velocity; e.angularVelocity = c.angularVelocity; e.flags = c.flags; @@ -219,7 +223,8 @@ void TrafficClient::receive(const net::AmbientStateMsg& msg) { 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) || - !std::isfinite(e.accel) || !std::isfinite(e.curvature) || !listed.insert(id).second) { + !std::isfinite(e.accel) || !std::isfinite(e.curvature) || !std::isfinite(e.groundSpeed) || + !listed.insert(id).second) { ++m_stats.refused; continue; } @@ -276,7 +281,9 @@ void TrafficClient::receive(const net::AmbientStateMsg& msg) { entry.target = e.target; entry.rpm = e.rpm; // A rail car's (the others move on at their velocities). - entry.motion = e.fullMotion() ? RailMotion{} : RailMotion{e.accel, e.curvature}; + entry.motion = RailMotion{}; + if (!e.fullMotion()) + entry.motion = {e.accel, e.curvature, e.slips, e.groundSpeed}; } if (later(msg.time, entry.firstTime) < 0) entry.firstTime = msg.time; @@ -403,6 +410,7 @@ void TrafficClient::update(double renderTime) { c.hornStarted = horn && !entry.horn; entry.horn = horn; c.fresh = !entry.shown; + c.stateTime = newest.time; entry.shown = true; m_cars.push_back(c); entry.buffer.prune(renderTime - kDrawBehindMs); @@ -535,6 +543,52 @@ bool TrafficClient::predict(const Entry& entry, const net::VehicleSnapshot& stat return true; } +std::optional TrafficClient::poseAt(int id, double time) const { + const auto it = m_entries.find(id); + if (it == m_entries.end()) + return std::nullopt; + const Entry& entry = it->second; + const net::VehicleSnapshot* newest = entry.buffer.latest(); + if (!newest) + return std::nullopt; + PredictedPose p; + if (predict(entry, *newest, entry.motion, time, p)) + return p; + net::VehicleSnapshot s; + if (entry.buffer.sample(time, s, 0.0) == net::SnapshotBuffer::Result::Empty) + return std::nullopt; + p.transform = s.orientation.normalized().toMatrix(s.position); + p.velocity = s.linearVelocity; + p.speed = -p.transform.m2.dot(p.velocity); + return p; +} + +float TrafficClient::setDrawn(int id, const Mat34& pose) { + const auto it = m_entries.find(id); + if (it == m_entries.end()) + return 0.0f; + Entry& entry = it->second; + const net::VehicleSnapshot* newest = entry.buffer.latest(); + PredictedPose p; + if (!newest || !predict(entry, *newest, entry.motion, entry.updatedAt, p)) + return 0.0f; + const Vec3 offset = pose.m3 - p.transform.m3; + const float turn = + std::remainder(groundHeading(-pose.m2) - groundHeading(-p.transform.m2), kTwoPi); + entry.basis = *newest; + entry.basisMotion = entry.motion; + entry.shown = true; + if (offset.mag() > m_options.snapDistance || std::abs(turn) > m_options.snapTurn) { + ++m_stats.snaps; + entry.offset = {}; + entry.offsetTurn = 0.0f; + } else { + entry.offset = offset; + entry.offsetTurn = turn; + } + return offset.mag(); +} + std::optional TrafficClient::transformAt(int id, double time) const { const auto it = m_entries.find(id); if (it == m_entries.end()) diff --git a/src/game/net/TrafficSync.h b/src/game/net/TrafficSync.h index 70974b3..c6b7e35 100644 --- a/src/game/net/TrafficSync.h +++ b/src/game/net/TrafficSync.h @@ -190,6 +190,7 @@ class TrafficClient { bool extrapolated = false; // beyond the newest message (predicted) bool hornStarted = false; // the horn sounded since the previous update bool fresh = false; // shown for the first time (or put somewhere new) this update + std::uint32_t stateTime = 0; // the session time of its newest state from the host }; const std::vector& cars() const { return m_cars; } // The host's traffic light steps at `renderTime`, once a message came. @@ -200,6 +201,18 @@ class TrafficClient { // else (game::StepHistory). Before the car's first state, that state. A // predicted car is drawn with the correction still being blended away. std::optional transformAt(int id, double time) const; + // Car `id` where the collisions meet it at `time` (predicted from its + // newest state, or between the messages), without the drawing's + // correction: for placing the received cars at another time than + // update()'s, as a client replaying its own car's steps from a host + // state would. + std::optional poseAt(int id, double time) const; + // OpenMM2 presentation: car `id` is drawn at `pose` now (a car this + // machine knocked loose, handed back to the host's messages: game:: + // NetTrafficCars), the difference blended away as a correction is. + // Returns how far the drawing is from the newest prediction there (0 when + // unknown). + float setDrawn(int id, const Mat34& pose); static constexpr double kDrawBehindMs = 100.0; // The host's catalog differs from this client's. bool catalogMismatch() const { return m_mismatch; } diff --git a/src/game/net/TrafficTrace.cpp b/src/game/net/TrafficTrace.cpp index 98f2b92..f8d88c6 100644 --- a/src/game/net/TrafficTrace.cpp +++ b/src/game/net/TrafficTrace.cpp @@ -52,4 +52,9 @@ void traceRefusedHit(std::FILE* f, double time, int from, int id, int generation std::fprintf(f, "TR %.3f %d %d %d %s\n", time, from, id, generation, std::string(why).c_str()); } +void traceHandover(std::FILE* f, double time, int id, bool confirmed, float off) { + if (f) + std::fprintf(f, "TL %.3f %d %d %.3f\n", time, id, confirmed ? 1 : 0, off); +} + } // namespace mm2::game diff --git a/src/game/net/TrafficTrace.h b/src/game/net/TrafficTrace.h index a5389da..5fb560e 100644 --- a/src/game/net/TrafficTrace.h +++ b/src/game/net/TrafficTrace.h @@ -19,6 +19,10 @@ // TK time id gen player host: knocked car id off its rail (player 255: // its own simulation's collision) // TR time from id gen why host: a client's hit report it did not apply +// TL time id confirmed off +// client: a car it knocked loose went back to the +// host's messages (confirmed 1: the host knocked it +// too), its drawing `off` metres from them then // // (fx, fz) is the car's forward direction (-m2) on the ground plane. @@ -36,5 +40,6 @@ void traceClientView(std::FILE* f, double frame, double time, const Vec3& positi void traceHit(std::FILE* f, double time, int id, int generation); void traceKnock(std::FILE* f, double time, int id, int generation, int player); void traceRefusedHit(std::FILE* f, double time, int from, int id, int generation, std::string_view why); +void traceHandover(std::FILE* f, double time, int id, bool confirmed, float off); } // namespace mm2::game diff --git a/src/net/AmbientState.h b/src/net/AmbientState.h index 9491728..d12bede 100644 --- a/src/net/AmbientState.h +++ b/src/net/AmbientState.h @@ -82,6 +82,12 @@ struct AmbientEntity { // driven (rad/m, positive turning from +z toward +x: a spin about +y). float accel = 0.0f; float curvature = 0.0f; + // Rail cars: how fast it really moves over the ground when that is not + // its speed (ai::Traffic moves a car along its curves by their parameter, + // so in a turn it covers more or less ground than its speed; a car held + // at the end of its lane covers none). Unset: its speed. + bool slips = false; + float groundSpeed = 0.0f; Vec3 velocity; // off-rail cars and police Vec3 angularVelocity; // off-rail cars and police std::uint8_t flags = 0; // AmbientFlags @@ -147,17 +153,24 @@ bool serializeAmbientEntity(S& s, AmbientEntity& e, const Vec3& origin) { e.speed = -(e.orientation.toMatrix().m2.dot(e.velocity)); } else { s.quantized(e.speed, -kAmbientSpeedRange, kAmbientSpeedRange, 11); - // Its acceleration and curvature, unless both are 0 (most cars stand - // or drive straight on at their speed: 1 bit). - bool changing = e.accel != 0.0f || e.curvature != 0.0f; + // Its acceleration and curvature and its speed over the ground, + // unless they are 0, 0 and its speed (most cars stand or drive + // straight on at their speed: 1 bit). + bool changing = e.accel != 0.0f || e.curvature != 0.0f || e.slips; s.boolean(changing); if (changing) { s.quantized(e.accel, -kAmbientAccelRange, kAmbientAccelRange, 7); s.quantized(e.curvature, -kAmbientCurvatureRange, kAmbientCurvatureRange, 9); + s.boolean(e.slips); + if (e.slips) + s.quantized(e.groundSpeed, -kAmbientSpeedRange, kAmbientSpeedRange, 11); } else if constexpr (S::kReading) { e.accel = e.curvature = 0.0f; + e.slips = false; } if constexpr (S::kReading) { + if (!e.slips) + e.groundSpeed = e.speed; // ai::Traffic's published velocity of a rail car. e.velocity = -e.orientation.toMatrix().m2 * e.speed; e.angularVelocity = {}; diff --git a/tests/game/test_net_traffic_cars.cpp b/tests/game/test_net_traffic_cars.cpp new file mode 100644 index 0000000..93c429d --- /dev/null +++ b/tests/game/test_net_traffic_cars.cpp @@ -0,0 +1,106 @@ +// A shared-traffic client's received cars as TrafficBodies' traffic +// (game/net/NetTrafficCars): the cars its own car knocks loose ahead of the +// host, and how they go back to the host's messages. +#include "game/net/NetTrafficCars.h" + +#include + +using namespace mm2; +using game::NetTrafficCars; + +namespace { + +NetTrafficCars::Received car(int id, int spawns, std::uint32_t stateTime, bool onRail = true) { + NetTrafficCars::Received r; + r.car.id = id; + r.car.spawns = spawns; + r.car.transform = Mat34::translation({static_cast(id), 0, 0}); + r.car.speed = 10.0f; + r.car.goal = onRail ? ai::AmbientGoal::RandomDrive : ai::AmbientGoal::Collision; + r.car.physical = !onRail; + r.stateTime = stateTime; + return r; +} + +const ai::AmbientCar* listed(const NetTrafficCars& cars, int id) { + for (const auto& c : cars.cars()) + if (c.id == id) + return &c; + return nullptr; +} + +} // namespace + +TEST(NetTrafficCars, OnlyTheLocalCarKnocksACarLoose) { + NetTrafficCars cars; + EXPECT_TRUE(cars.attachable(1, true)); + EXPECT_FALSE(cars.attachable(1, false)); // the police, the other players: the host's +} + +TEST(NetTrafficCars, TheCarsOffTheirRailsAreLeftToTheHostsMessages) { + NetTrafficCars cars; + const std::vector received{car(1, 0, 1000), car(2, 0, 1000, false)}; + cars.update(received, 1100.0, 200.0); + ASSERT_EQ(cars.cars().size(), 1u); + EXPECT_EQ(cars.cars()[0].id, 1); +} + +TEST(NetTrafficCars, AKnockIsConfirmedWhenTheHostKnocksTheCarToo) { + NetTrafficCars cars; + cars.update(std::vector{car(1, 3, 1000)}, 1100.0, 200.0); + cars.impact(1); // the local car hit it in the step + EXPECT_TRUE(cars.knocked(1)); + EXPECT_EQ(cars.takeKnocks(), std::vector{1}); + Mat34 rest = Mat34::translation({5, 0, 5}); + cars.setPhysicalTransform(1, rest); + // Still on its rail in the host's older messages: the local body leads. + cars.update(std::vector{car(1, 3, 1150)}, 1200.0, 200.0); + const ai::AmbientCar* c = listed(cars, 1); + ASSERT_NE(c, nullptr); + EXPECT_TRUE(c->physical); + EXPECT_EQ(c->transform.m3, rest.m3); + EXPECT_TRUE(cars.takeHandovers().empty()); + // The host knocked it too: its messages lead from now. + cars.update(std::vector{car(1, 3, 1250, false)}, 1350.0, 200.0); + EXPECT_FALSE(cars.knocked(1)); + EXPECT_EQ(listed(cars, 1), nullptr); + const auto h = cars.takeHandovers(); + ASSERT_EQ(h.size(), 1u); + EXPECT_TRUE(h[0].confirmed); + EXPECT_EQ(h[0].pose.m3, rest.m3); + EXPECT_EQ(cars.stats().confirmed, 1u); +} + +TEST(NetTrafficCars, AKnockTheHostNeverMakesIsWithdrawn) { + NetTrafficCars cars; + cars.update(std::vector{car(1, 3, 1000)}, 1100.0, 200.0); + cars.impact(1); + cars.update(std::vector{car(1, 3, 1290)}, 1400.0, 200.0); + EXPECT_TRUE(cars.knocked(1)); // 190 ms after the hit: maybe not there yet + cars.update(std::vector{car(1, 3, 1310)}, 1420.0, 200.0); + EXPECT_FALSE(cars.knocked(1)); // on its rail 210 ms after it on the host + const ai::AmbientCar* c = listed(cars, 1); + ASSERT_NE(c, nullptr); + EXPECT_FALSE(c->physical); + const auto h = cars.takeHandovers(); + ASSERT_EQ(h.size(), 1u); + EXPECT_FALSE(h[0].confirmed); + EXPECT_EQ(cars.stats().withdrawn, 1u); +} + +TEST(NetTrafficCars, ARecycledOrVanishedCarIsForgotten) { + NetTrafficCars cars; + cars.update(std::vector{car(1, 3, 1000), car(2, 0, 1000)}, 1100.0, 200.0); + cars.impact(1); + cars.impact(2); + cars.update(std::vector{car(1, 4, 1050)}, 1150.0, 200.0); // a new car in slot 1, car 2 gone + EXPECT_FALSE(cars.knocked(1)); + EXPECT_FALSE(cars.knocked(2)); + const ai::AmbientCar* c = listed(cars, 1); + ASSERT_NE(c, nullptr); + EXPECT_FALSE(c->physical); + EXPECT_TRUE(cars.takeHandovers().empty()); + // A car that is not listed cannot be knocked. + cars.impact(7); + EXPECT_FALSE(cars.knocked(7)); +} diff --git a/tests/game/test_traffic_prediction.cpp b/tests/game/test_traffic_prediction.cpp index 4ce5f0f..e5de558 100644 --- a/tests/game/test_traffic_prediction.cpp +++ b/tests/game/test_traffic_prediction.cpp @@ -71,12 +71,14 @@ struct Path { } }; -ai::AmbientCar aiCar(int id, const Path::State& s) { +// As ai::Traffic publishes it; `nominal`: its speed over its true speed (in +// a turn the AI's speed is not the ground it covers). +ai::AmbientCar aiCar(int id, const Path::State& s, float nominal = 1.0f) { ai::AmbientCar c; c.id = id; c.transform = facing(s.heading, s.position); - c.speed = s.speed; - c.velocity = -c.transform.m2 * s.speed; + c.speed = s.speed * nominal; + c.velocity = -c.transform.m2 * c.speed; return c; } @@ -120,6 +122,19 @@ TEST(TrafficPrediction, ABrakingRailCarStopsAndNeverReverses) { EXPECT_NEAR(q.speed, 2.0f, 1e-5f); } +// The ground it covers is its speed over the ground's; its velocity (what a +// body it takes when hit moves at) stays its own speed's. +TEST(TrafficPrediction, ARailCarCoversTheGroundItReallyCovers) { + const Mat34 start = facing(0.0f, {0, 0, 0}); + const PredictedPose p = game::predictRailCar(start, 10.0f, {0.0f, 0.0f, true, 12.0f}, 0.5f); + EXPECT_NEAR(p.transform.m3.z, 6.0f, 1e-4f); + EXPECT_NEAR(p.speed, 10.0f, 1e-5f); + // Held at the end of its lane: going nowhere at its speed. + const PredictedPose held = game::predictRailCar(start, 14.0f, {0.0f, 0.0f, true, 0.0f}, 0.5f); + EXPECT_NEAR(held.transform.m3.z, 0.0f, 1e-5f); + EXPECT_NEAR(held.velocity.z, 14.0f, 1e-4f); +} + TEST(TrafficPrediction, ABodyTurnsWithItsYawRate) { // A police car at 20 m/s turning at 0.5 rad/s: a 40 m radius. const Mat34 start = facing(0.0f, {0, 0, 0}); @@ -138,13 +153,18 @@ TEST(TrafficPrediction, TheHostMeasuresTheRailMotionFromItsSteps) { game::RailMotionTracker tracker; for (int step = 0; step < 60; ++step) { const double t = step * kAiStepMs; - const std::vector cars{aiCar(3, path.at(t))}; + // Car 5 covers more ground than its speed says (a turn's curve). + const std::vector cars{aiCar(3, path.at(t)), aiCar(5, path.at(t), 0.8f)}; tracker.update(cars, t); tracker.update(cars, t); // a frame without an AI step changes nothing } const RailMotion m = tracker.motion(3); EXPECT_NEAR(m.accel, 2.0f, 0.01f); EXPECT_NEAR(m.curvature, 0.05f, 0.001f); + EXPECT_FALSE(m.slips); + const RailMotion slipping = tracker.motion(5); + EXPECT_TRUE(slipping.slips); + EXPECT_NEAR(slipping.groundSpeed, speed(58.5 * kAiStepMs), 0.02f); // over the last step EXPECT_EQ(tracker.motion(4).accel, 0.0f); // A recycled slot starts again. ai::AmbientCar again = aiCar(3, path.at(0.0)); @@ -180,6 +200,9 @@ TEST(TrafficPrediction, CorrectionsAreBlendedInTheDrawingOnly) { send(1050, 0.0f); client.update(1110.0); EXPECT_NEAR(client.cars()[0].transform.m3.z, -0.6f, 0.03f); // the physics: at once + const auto raw = client.poseAt(5, 1110.0); + ASSERT_TRUE(raw); + EXPECT_NEAR(raw->transform.m3.z, client.cars()[0].transform.m3.z, 1e-4f); const auto drawn = client.transformAt(5, 1110.0); ASSERT_TRUE(drawn); EXPECT_NEAR(drawn->m3.z, -1.1f, 0.05f); // the drawing: where it was going @@ -241,7 +264,7 @@ TEST(TrafficPrediction, OverALossyLinkTheClientSeesTheHostsPresent) { if (aiTime != lastAi) { std::vector cars; for (std::size_t i = 0; i < paths.size(); ++i) - cars.push_back(aiCar(static_cast(i), paths[i].at(aiTime))); + cars.push_back(aiCar(static_cast(i), paths[i].at(aiTime), i == 2 ? 0.8f : 1.0f)); tracker.update(cars, aiTime); if (std::floor(aiTime / 50.0) != std::floor(lastAi / 50.0)) { std::vector shared; diff --git a/tests/net/test_ambient_state.cpp b/tests/net/test_ambient_state.cpp index d860c48..0aec4a3 100644 --- a/tests/net/test_ambient_state.cpp +++ b/tests/net/test_ambient_state.cpp @@ -59,6 +59,8 @@ AmbientStateMsg sampleMessage() { m.entities.push_back(railCar(7, {-1480.31f, 33.02f, 620.77f}, 0.7f, 12.5f)); m.entities.back().accel = -3.5f; m.entities.back().curvature = 0.08f; + m.entities.back().slips = true; + m.entities.back().groundSpeed = 15.0f; 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; @@ -107,6 +109,10 @@ TEST(AmbientState, RoundTripKeepsEveryField) { EXPECT_EQ(rail.target, kAmbientNoTarget); EXPECT_NEAR(rail.accel, -3.5f, 0.13f); EXPECT_NEAR(rail.curvature, 0.08f, 0.001f); + EXPECT_TRUE(rail.slips); + EXPECT_NEAR(rail.groundSpeed, 15.0f, 0.07f); + // A car that does not slip moves over the ground at its speed. + EXPECT_FALSE(out.entities[2].slips); const AmbientEntity& cop = out.entities[1]; EXPECT_EQ(cop.id, 301); @@ -244,7 +250,11 @@ TEST(AmbientState, SizesFitTheBudget) { AmbientEntity turning = rail; turning.accel = -2.0f; turning.curvature = 0.05f; - EXPECT_EQ(ambientEntityBits(turning), 136u); + EXPECT_EQ(ambientEntityBits(turning), 137u); + // ... and its speed over the ground when it is not its speed (12 bits). + turning.slips = true; + turning.groundSpeed = 12.0f; + EXPECT_EQ(ambientEntityBits(turning), 148u); // Version 4: a knocked car says whether its wheels follow (80 bits more // when they do), a police car's damage takes 10 bits. EXPECT_EQ(ambientEntityBits(offRail), 178u); From 8af683330ca534c78bde87513588d7bb0abd7f39 Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Fri, 9 Oct 2026 13:24:04 -0700 Subject: [PATCH 010/129] Share a knocked car's body that stands still as standing Once a wreck standing still on the host was shared with the 6.8 m/s its body's velocity still said for 40 s, and the client, predicting it to the present, showed it 1.2 m ahead all that time. A body that has moved less than 5 cm in 100 ms is now shared without velocity or spin, whatever its velocity says. mm2tool nettrace also gives the error at the time the client showed each car (for OPENMM2_DEBUG_TRAFFIC_LEAD_MS). Co-Authored-By: Claude Opus 5.5 (1M context) --- src/app/RaceScreen.cpp | 7 +++++++ src/game/net/TrafficPrediction.cpp | 18 ++++++++++++++++++ src/game/net/TrafficPrediction.h | 19 +++++++++++++++++++ tests/game/test_traffic_prediction.cpp | 12 ++++++++++++ tools/mm2tool/cmd_nettrace.cpp | 9 ++++++++- 5 files changed, 64 insertions(+), 1 deletion(-) diff --git a/src/app/RaceScreen.cpp b/src/app/RaceScreen.cpp index cb34870..69cb236 100644 --- a/src/app/RaceScreen.cpp +++ b/src/app/RaceScreen.cpp @@ -4008,6 +4008,11 @@ class RaceScreen final : public Screen { body.emplace(); body->transform = *m; m_trafficBodies->motionOf(c.id, body->velocity, body->angularVelocity); + // A body that stands still is shared standing (game::StillBodies). + if (m_stillBodies.still(c.id, m->m3, netBodyStateTime(ctx))) { + body->velocity = {}; + body->angularVelocity = {}; + } // Its wheels while the body is simulated (aiVehicleInstance::Draw). if (const auto wheels = m_trafficBodies->wheelsOf(c.id); wheels && c.data) body->wheels = game::trafficWheelOffsets(*m, wheels->matrix, *c.data); @@ -4023,6 +4028,7 @@ class RaceScreen final : public Screen { } if (send) m_hornLatch.clear(); + m_stillBodies.prune(netBodyStateTime(ctx)); if (m_debugNetTraffic || trace) { std::unordered_set knocked; for (const ai::AmbientCar& c : m_ai->cars()) @@ -5533,6 +5539,7 @@ class RaceScreen final : public Screen { std::unordered_set m_debugKnocked; std::uint32_t m_traceAiSteps = 0; // OPENMM2_NET_TRACE: the AI step last traced (host) game::RailMotionTracker m_railMotion; // host: the rail cars' acceleration and curvature + game::StillBodies m_stillBodies; // host: the knocked cars' bodies standing still // Sound: the player's car, city ambience and rain (src/audio/game). std::unique_ptr m_bank; diff --git a/src/game/net/TrafficPrediction.cpp b/src/game/net/TrafficPrediction.cpp index 9847277..296ec25 100644 --- a/src/game/net/TrafficPrediction.cpp +++ b/src/game/net/TrafficPrediction.cpp @@ -82,6 +82,24 @@ RailMotion RailMotionTracker::motion(int id) const { return it == m_cars.end() ? RailMotion{} : it->second.motion; } +// --- StillBodies -------------------------------------------------------------------------- + +bool StillBodies::still(int id, const Vec3& position, double time) { + auto [it, fresh] = m_anchors.try_emplace(id, Anchor{position, time, time}); + Anchor& a = it->second; + a.seen = time; + if (fresh || a.position.dist2(position) > 0.05f * 0.05f) { + a.position = position; + a.since = time; + return false; + } + return time - a.since >= 100.0; +} + +void StillBodies::prune(double time) { + std::erase_if(m_anchors, [time](const auto& e) { return e.second.seen < time - 1000.0; }); +} + // --- Prediction --------------------------------------------------------------------------- PredictedPose predictRailCar(const Mat34& transform, float speed, const RailMotion& motion, float dt) { diff --git a/src/game/net/TrafficPrediction.h b/src/game/net/TrafficPrediction.h index 6d2e1c8..5f22690 100644 --- a/src/game/net/TrafficPrediction.h +++ b/src/game/net/TrafficPrediction.h @@ -69,6 +69,25 @@ class RailMotionTracker { std::unordered_map m_cars; }; +// Host: whether a knocked car's body has stood still (moved less than 5 cm) +// for 100 ms; its state is then shared standing, whatever its velocity says +// (once a wreck was seen to keep 6.8 m/s for 40 s while it stood, and every +// client predicted it 1.2 m ahead). +class StillBodies { +public: + bool still(int id, const Vec3& position, double time); + // Forgets the bodies not asked about since `time` less a second. + void prune(double time); + +private: + struct Anchor { + Vec3 position; + double since = 0.0; + double seen = 0.0; + }; + std::unordered_map m_anchors; +}; + // The heading on the ground plane of a forward direction (atan2(x, z)). float groundHeading(const Vec3& forward); diff --git a/tests/game/test_traffic_prediction.cpp b/tests/game/test_traffic_prediction.cpp index e5de558..a215318 100644 --- a/tests/game/test_traffic_prediction.cpp +++ b/tests/game/test_traffic_prediction.cpp @@ -135,6 +135,18 @@ TEST(TrafficPrediction, ARailCarCoversTheGroundItReallyCovers) { EXPECT_NEAR(held.velocity.z, 14.0f, 1e-4f); } +TEST(TrafficPrediction, ABodyStandingStillIsSharedStanding) { + game::StillBodies still; + const Vec3 p{10, 0, 10}; + EXPECT_FALSE(still.still(1, p, 1000.0)); + EXPECT_FALSE(still.still(1, p + Vec3{0.01f, 0, 0}, 1050.0)); + EXPECT_TRUE(still.still(1, p + Vec3{0.02f, 0, 0}, 1100.0)); // 100 ms within 5 cm + EXPECT_FALSE(still.still(1, p + Vec3{1, 0, 0}, 1110.0)); // moving again + EXPECT_FALSE(still.still(2, p, 1110.0)); + still.prune(2200.0); + EXPECT_FALSE(still.still(1, p + Vec3{1, 0, 0}, 2200.0)); // forgotten: starts again +} + TEST(TrafficPrediction, ABodyTurnsWithItsYawRate) { // A police car at 20 m/s turning at 0.5 rad/s: a 40 m radius. const Mat34 start = facing(0.0f, {0, 0, 0}); diff --git a/tools/mm2tool/cmd_nettrace.cpp b/tools/mm2tool/cmd_nettrace.cpp index 9a04f0e..1676f5a 100644 --- a/tools/mm2tool/cmd_nettrace.cpp +++ b/tools/mm2tool/cmd_nettrace.cpp @@ -210,7 +210,7 @@ int cmdNettrace(std::span args) { return 1; } constexpr int kOffRail = 0x10; - std::vector rail, moving, offRail, police, near, local; + std::vector rail, moving, offRail, police, near, local, shownMoving; std::size_t present = 0, extra = 0, missing = 0, copFrames = 0, copTargets = 0; for (std::size_t i = 0; i < client.frames.size(); ++i) { const Frame& fr = client.frames[i]; @@ -243,6 +243,11 @@ int cmdNettrace(std::span args) { } if (fromMe < 40.0) near.push_back(e); + // Against the host's at the time the client showed it (another + // time than its car's with OPENMM2_DEBUG_TRAFFIC_LEAD_MS). + if (const auto s = at(it->second, c.time); s && c.kind == 0 && (c.flags & kOffRail) == 0 && + std::abs(s->speed) > 2.0) + shownMoving.push_back(std::hypot(c.x - s->x, c.z - s->z)); } // The host's cars near this client's car it did not have (every // fourth frame). @@ -264,6 +269,8 @@ int cmdNettrace(std::span args) { row("police", police); row("any car within 40 m", near); row("knocked by the client", local); + std::println("error against the host at the time the client showed the car:"); + row("rail cars moving > 2 m/s", shownMoving); std::println("cars within {:.0f} m: on the host only {} car-frames ({:.2f}%), on the client only {} " "({:.2f}%)", radius - 20.0, missing, share(missing, present + missing), extra, From de110396524d96d6f39b4b243d5399aa628d9ada Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Fri, 9 Oct 2026 13:24:56 -0700 Subject: [PATCH 011/129] Let only the host's copies of network cars break props A moving kinematic body broke every banger it hit, so with OPENMM2_NETPROPS=local each machine's kinematic copies of the others' cars knocked props too, which OpenMM2 0.3 never did. The body now has to be allowed to (Body::kinematicBreaksBangers): the host sets it on its copies of the other players' cars, which knock the props for everyone; everywhere else a banger holds against a kinematic body as before. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/app/RaceScreen.cpp | 3 +++ src/phys/Impact.cpp | 10 ++++++---- src/phys/World.h | 4 ++++ tests/game/test_prop_sync.cpp | 15 ++++++++++----- 4 files changed, 23 insertions(+), 9 deletions(-) diff --git a/src/app/RaceScreen.cpp b/src/app/RaceScreen.cpp index 4a6ac9c..2bb1ec5 100644 --- a/src/app/RaceScreen.cpp +++ b/src/app/RaceScreen.cpp @@ -4555,6 +4555,9 @@ class RaceScreen final : public Screen { sim.body.kinematicMoves = true; sim.body.kinematicVelocity = rc.velocity; sim.body.kinematicSpin = rc.angularVelocity; + // OpenMM2: the host knocks the props it hits for everyone + // (game/net/NetProps); a client's props take its own car only. + sim.body.kinematicBreaksBangers = m_netProps.host(); 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()) { diff --git a/src/phys/Impact.cpp b/src/phys/Impact.cpp index 21971f4..f9af8ea 100644 --- a/src/phys/Impact.cpp +++ b/src/phys/Impact.cpp @@ -464,12 +464,14 @@ bool calcBangerImpact(Impact& impact, float weight, float impulseLimit2) { impact.findFrictionAndElasticity(); // OpenMM2: a kinematic body that moves (a network car on this machine) // has no ICS in its collider, so no impulse could stop it and the banger - // would hold forever. It breaks the banger as a body of its own mass - // would (Body::ics), keeps its motion and gives the banger all of the - // relative velocity. MM2's network cars are simulated vehCars. + // would hold forever. One that may (Body::kinematicBreaksBangers: the + // host's copies of the other players' cars) breaks the banger as a body + // of its own mass would (Body::ics), keeps its motion and gives the + // banger all of the relative velocity. MM2's network cars are simulated + // vehCars. const Body* kinematic = !impact.colliderA->ics && impact.colliderA->moving ? impact.colliderA->body : nullptr; - if (kinematic && !(kinematic->ics.mass > 0.0f)) + if (kinematic && (!kinematic->kinematicBreaksBangers || !(kinematic->ics.mass > 0.0f))) kinematic = nullptr; if (n.z * relVel.z + n.y * relVel.y + n.x * relVel.x <= 0.01f) { Mat34 test = ma; diff --git a/src/phys/World.h b/src/phys/World.h index b9123dd..7d12d93 100644 --- a/src/phys/World.h +++ b/src/phys/World.h @@ -141,6 +141,10 @@ class Body : public Instance { // no momentum of its own (infinite mass). bool kinematicMoves = false; Vec3 kinematicVelocity, kinematicSpin; + // OpenMM2: a moving kinematic body that breaks the bangers it hits as a + // body of its mass (ics) would (a network race's host: its copies of the + // other players' cars). Otherwise a banger holds against it. + bool kinematicBreaksBangers = false; bool kinematicMotion(Vec3& velocity, Vec3& spin, Vec3& centre) const override; // Places the body's centre of mass frame and resets its motion and diff --git a/tests/game/test_prop_sync.cpp b/tests/game/test_prop_sync.cpp index b8dcd20..4a5e04f 100644 --- a/tests/game/test_prop_sync.cpp +++ b/tests/game/test_prop_sync.cpp @@ -368,16 +368,18 @@ TEST(PropSync, OtherCarsPassThroughAClientsProps) { // The host's copy of another player's car is a moving kinematic body: it // breaks a prop as a car of its mass would and keeps going; a still one -// does not, nor one that would not break an unbreakable prop. +// does not, nor one that would not break an unbreakable prop, nor a +// kinematic body not allowed to (OpenMM2 0.3's network cars). TEST(PropSync, MovingKinematicBodiesBreakProps) { TempBangers files; bangers::BangerDataLibrary lib(files.vfs); Machine m(lib); m.place(layout()); - auto kinematic = [](Car& c, const Vec3& v) { + auto kinematic = [](Car& c, const Vec3& v, bool breaks = true) { c.body.kinematic = true; c.body.kinematicMoves = true; c.body.kinematicVelocity = v; + c.body.kinematicBreaksBangers = breaks; // the host's copy of a network car c.body.resetCollider(); }; Car moving({0, 1, 3.0f}, {0, 0, -10}); @@ -386,11 +388,13 @@ TEST(PropSync, MovingKinematicBodiesBreakProps) { kinematic(still, {0, 0, 0}); Car against({18, 1, 3.0f}, {0, 0, -10}); kinematic(against, {0, 0, -10}); - for (Car* c : {&moving, &still, &against}) + Car old({200, 1, 3.0f}, {0, 0, -10}); + kinematic(old, {0, 0, -10}, false); + for (Car* c : {&moving, &still, &against, &old}) m.world.add(&c->body); for (int i = 0; i < 40; ++i) { // Placed each frame as a network car is (RaceScreen::updateRemoteCars). - for (Car* c : {&moving, &against}) { + for (Car* c : {&moving, &against, &old}) { Mat34 at = c->body.ics.matrix; at.m3 = at.m3 + c->body.kinematicVelocity * kDt; c->body.place(at); @@ -403,7 +407,8 @@ TEST(PropSync, MovingKinematicBodiesBreakProps) { EXPECT_LT(pieces.begin()->second.z, -3.0f); // thrown ahead of the car EXPECT_TRUE(m.set.standing(1)); EXPECT_TRUE(m.set.standing(3)); // ImpulseLimit2 1e15: a car does not break it - for (Car* c : {&moving, &still, &against}) + EXPECT_TRUE(m.set.standing(4)); + for (Car* c : {&moving, &still, &against, &old}) m.world.remove(&c->body); } From 6d2bde649ad7eca711840199db2465a96fc92adf Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Fri, 9 Oct 2026 13:26:00 -0700 Subject: [PATCH 012/129] Measure the second after a hit in mm2tool nettrace The error of each car the client's car hit, over the second after the hit: how well the client's view of a collision matches the host's. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/game/net/TrafficTrace.cpp | 10 ++++++---- tools/mm2tool/cmd_nettrace.cpp | 9 ++++++++- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/src/game/net/TrafficTrace.cpp b/src/game/net/TrafficTrace.cpp index f8d88c6..861c41e 100644 --- a/src/game/net/TrafficTrace.cpp +++ b/src/game/net/TrafficTrace.cpp @@ -12,13 +12,16 @@ void traceCar(std::FILE* f, const Mat34& m, float speed, std::uint8_t flags, std flags, target == net::kAmbientNoTarget ? -1 : static_cast(target)); } +// The generation as it travels (its low bits). +int generationOf(int generation) { return generation % static_cast(net::kAmbientGenerations); } + } // namespace void traceHostCar(std::FILE* f, double time, const SharedCar& car) { if (!f) return; - std::fprintf(f, "TH %.3f %d %d %d", time, car.id, car.generation % static_cast(net::kAmbientGenerations), - static_cast(car.kind)); + const int kind = static_cast(car.kind); + std::fprintf(f, "TH %.3f %d %d %d", time, car.id, generationOf(car.generation), kind); traceCar(f, car.transform, car.speed, car.flags, car.target); std::fprintf(f, " %.3f %.3f %.3f\n", car.velocity.x, car.velocity.y, car.velocity.z); } @@ -43,8 +46,7 @@ void traceHit(std::FILE* f, double time, int id, int generation) { void traceKnock(std::FILE* f, double time, int id, int generation, int player) { if (f) - std::fprintf(f, "TK %.3f %d %d %d\n", time, id, generation % static_cast(net::kAmbientGenerations), - player); + std::fprintf(f, "TK %.3f %d %d %d\n", time, id, generationOf(generation), player); } void traceRefusedHit(std::FILE* f, double time, int from, int id, int generation, std::string_view why) { diff --git a/tools/mm2tool/cmd_nettrace.cpp b/tools/mm2tool/cmd_nettrace.cpp index 1676f5a..18ad5bb 100644 --- a/tools/mm2tool/cmd_nettrace.cpp +++ b/tools/mm2tool/cmd_nettrace.cpp @@ -210,7 +210,7 @@ int cmdNettrace(std::span args) { return 1; } constexpr int kOffRail = 0x10; - std::vector rail, moving, offRail, police, near, local, shownMoving; + std::vector rail, moving, offRail, police, near, local, shownMoving, afterHit; std::size_t present = 0, extra = 0, missing = 0, copFrames = 0, copTargets = 0; for (std::size_t i = 0; i < client.frames.size(); ++i) { const Frame& fr = client.frames[i]; @@ -230,6 +230,12 @@ int cmdNettrace(std::span args) { const double e = std::hypot(c.x - h->x, c.z - h->z); if (c.mode == 3) local.push_back(e); + // In the second after the client's car hit it. + if (std::ranges::any_of(client.hits, [&](const Event& hit) { + return hit.id == c.key.first && hit.generation == c.key.second && fr.own >= hit.time && + fr.own < hit.time + 1000.0; + })) + afterHit.push_back(e); if (c.kind == 1) { police.push_back(e); ++copFrames; @@ -269,6 +275,7 @@ int cmdNettrace(std::span args) { row("police", police); row("any car within 40 m", near); row("knocked by the client", local); + row("hit by the client, 1 s", afterHit); std::println("error against the host at the time the client showed the car:"); row("rail cars moving > 2 m/s", shownMoving); std::println("cars within {:.0f} m: on the host only {} car-frames ({:.2f}%), on the client only {} " From 2a97bb00b79cbcb6132e0df2b8f088c0c28948f6 Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Fri, 9 Oct 2026 13:32:41 -0700 Subject: [PATCH 013/129] Keep the shared cars a client has when they do not all fit At traffic density 1 the cars within 200 m of a client did not all fit in a message: it held at most 96, and the farthest ones were cut. 12 % of the host's cars within 200 m of the client were missing there, and cars at the cut came and went (45 times in 100 s, at 170-195 m) as the order shifted. A message now holds up to 160 cars, a known rail car beyond 160 m gets its state every fourth message (the client predicts it in between), and when the cars still do not all fit, the ones the client has count as 25 m nearer, so the car at the edge of what fits stays until a new one is clearly nearer. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/game/net/TrafficSync.cpp | 17 ++++++++++++----- src/game/net/TrafficSync.h | 6 ++++++ src/net/AmbientState.h | 2 +- src/net/Protocol.h | 5 +++-- tests/game/test_traffic_sync.cpp | 32 ++++++++++++++++++++++++++++++++ 5 files changed, 54 insertions(+), 8 deletions(-) diff --git a/src/game/net/TrafficSync.cpp b/src/game/net/TrafficSync.cpp index 5bf9830..4f13f56 100644 --- a/src/game/net/TrafficSync.cpp +++ b/src/game/net/TrafficSync.cpp @@ -106,10 +106,12 @@ net::AmbientStateMsg TrafficHost::build(const TrafficViewer& viewer, std::span candidates; const float enter2 = m_options.enterRadius * m_options.enterRadius; @@ -126,13 +128,16 @@ net::AmbientStateMsg TrafficHost::build(const TrafficViewer& viewer, std::spanid) || c.car->kind != net::AmbientKind::Traffic || (c.car->flags & net::kAmbientOffRail) != 0 || - ((sequence + static_cast(c.car->id)) & 1u) == 0; + (sequence + static_cast(c.car->id)) % every == 0; if (!full) e.hasState = false; const std::size_t bits = net::ambientEntityBits(e); diff --git a/src/game/net/TrafficSync.h b/src/game/net/TrafficSync.h index c6b7e35..16fcd28 100644 --- a/src/game/net/TrafficSync.h +++ b/src/game/net/TrafficSync.h @@ -104,9 +104,15 @@ class TrafficHost { // ones, cars off their rails, the police and cars new to the // client get it in every message. float fullRateRadius = 80.0f; + // ... and beyond this every fourth (the client predicts it the while). + float slowRateRadius = 160.0f; // The message's size, bytes (the header included): one unfragmented // UDP datagram. std::size_t maxBytes = 1100; + // When the cars near a client do not all fit, the ones it has are + // kept ahead of new ones up to this much further away, so a car at the + // edge of what fits does not come and go with every message. + float keepMetres = 25.0f; }; TrafficHost() = default; diff --git a/src/net/AmbientState.h b/src/net/AmbientState.h index d12bede..c2f326d 100644 --- a/src/net/AmbientState.h +++ b/src/net/AmbientState.h @@ -36,7 +36,7 @@ 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; +inline constexpr std::size_t kMaxAmbientPerMessage = 160; // Offsets from the origin: +-512 m across, +-256 m up and down. inline constexpr float kAmbientOffsetRange = 512.0f; inline constexpr float kAmbientHeightRange = 256.0f; diff --git a/src/net/Protocol.h b/src/net/Protocol.h index 5fb0b85..a44e8e6 100644 --- a/src/net/Protocol.h +++ b/src/net/Protocol.h @@ -30,8 +30,9 @@ inline constexpr std::uint16_t kProtocolMagic = 0x4D32; // "M2" // RaceStart; the race in Welcome). // 4: the cars' damage (net/VehicleDamage.h events; in AmbientState the // police's damage at 10 bits and the knocked traffic cars' wheels). -// 5: in AmbientState a rail car's acceleration and curvature (the clients -// predict the shared traffic to the present). +// 5: in AmbientState a rail car's acceleration, curvature and speed over the +// ground (the clients predict the shared traffic to the present), and up to +// 160 cars. inline constexpr std::uint16_t kProtocolVersion = 5; inline constexpr std::uint32_t kConnectData = (std::uint32_t{kProtocolMagic} << 16) | kProtocolVersion; diff --git a/tests/game/test_traffic_sync.cpp b/tests/game/test_traffic_sync.cpp index cdc8a9d..e928494 100644 --- a/tests/game/test_traffic_sync.cpp +++ b/tests/game/test_traffic_sync.cpp @@ -358,6 +358,38 @@ TEST(TrafficHost, FarCarsComeWithTheirStateEveryOtherMessage) { EXPECT_EQ(states(m), (std::vector>{{1, true}, {10, true}, {11, true}, {300, true}})); } +// When the cars near a client do not all fit, the ones it has stay ahead of +// new ones a little nearer, so the car at the edge of what fits does not +// come and go; beyond 160 m a known car gets its state every fourth message. +TEST(TrafficHost, TheCarsThatFitStayAndTheFarthestComeEveryFourthMessage) { + game::TrafficHost::Options options; + options.maxBytes = 70; // the header and three cars with their state + TrafficHost host(options); + std::vector cars = {railCar(1, {10, 0, 0}), railCar(2, {20, 0, 0}), railCar(3, {60, 0, 0}), + railCar(4, {75, 0, 0})}; + 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}}; + EXPECT_EQ(ids(host.build(viewer, cars, 0, 0, 0)), (std::vector{1, 2, 3})); + // Car 4 comes 10 m nearer than car 3: car 3 stays. + cars[3].transform.m3.x = 50.0f; + EXPECT_EQ(ids(host.build(viewer, cars, 50, 0, 0)), (std::vector{1, 2, 3})); + // 30 m nearer: car 4 takes its place. + cars[3].transform.m3.x = 30.0f; + EXPECT_EQ(ids(host.build(viewer, cars, 100, 0, 0)), (std::vector{1, 2, 4})); + + TrafficHost far; + const std::vector distant = {railCar(10, {180, 0, 0})}; + int states = 0; + for (std::uint32_t i = 0; i < 9; ++i) + states += far.build(viewer, distant, i * 50, 0, 0).entities.at(0).hasState ? 1 : 0; + EXPECT_EQ(states, 3); // new, then every fourth of the next eight +} + TEST(TrafficClient, ACarWithoutItsStateIsStillThere) { TrafficClient client(8, 0x1234); client.receive(message(0, {railCar(10, {150, 0, 0}), railCar(11, {0, 0, 150})})); From 217db241f482e789895c82157ed2b9530bbd6cc5 Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Fri, 9 Oct 2026 13:35:59 -0700 Subject: [PATCH 014/129] Choose the shared cars that fit by their usual cost, not this message's With more cars near a client than fit, the cars at the edge came and went nearly 2000 times in 100 s at traffic density 1: whether a far car's turn for its state fell in a message changed its size from 13 bits to 130, and with it which cars at the edge still fit; a car left out was gone for the client, and coming back new it needed its state, pushing out others. The host now chooses the cars by their usual cost (a far known car's state every second or fourth message, its id in between), then writes them; a far car whose turn it is goes without its state when it would not fit with it. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/game/net/TrafficSync.cpp | 62 ++++++++++++++++++++++++++---------- 1 file changed, 45 insertions(+), 17 deletions(-) diff --git a/src/game/net/TrafficSync.cpp b/src/game/net/TrafficSync.cpp index 4f13f56..5ccd502 100644 --- a/src/game/net/TrafficSync.cpp +++ b/src/game/net/TrafficSync.cpp @@ -144,31 +144,59 @@ net::AmbientStateMsg TrafficHost::build(const TrafficViewer& viewer, std::span kHeaderBits ? total - kHeaderBits : 0; - std::size_t used = 0; + // Which cars go: as many as fit at their usual cost (a far car on its + // rail that the client has gets its state every other message, beyond + // slowRateRadius every fourth, the ids taking turns, and only its id in + // between), so that whose turn it is never decides which cars are left + // out (that made the cars at the edge come and go). + struct Chosen { + net::AmbientEntity entity; + std::size_t fullBits = 0; + bool full = true; // its state this time + bool canHold = false; + }; + std::vector chosen; std::unordered_set sent; + const std::size_t heldBits = 13; + double expected = 0.0; for (const Candidate& c : candidates) { - if (msg.entities.size() >= net::kMaxAmbientPerMessage) + if (chosen.size() >= 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 (beyond slowRateRadius every fourth), the ids - // taking turns. + if (sent.contains(c.car->id)) + continue; // the same id twice: the first one counts + Chosen k{toEntity(*c.car)}; + k.fullBits = net::ambientEntityBits(k.entity); + k.canHold = !c.chasing && c.distance2 >= near2 && set.contains(c.car->id) && + c.car->kind == net::AmbientKind::Traffic && (c.car->flags & net::kAmbientOffRail) == 0; const std::uint32_t every = c.distance2 < slow2 ? 2u : 4u; - 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)) % every == 0; - if (!full) - e.hasState = false; - const std::size_t bits = net::ambientEntityBits(e); + k.full = !k.canHold || (sequence + static_cast(c.car->id)) % every == 0; + const double cost = k.canHold ? static_cast(k.fullBits + (every - 1) * heldBits) / every + : static_cast(k.fullBits); + if (expected + cost > static_cast(budget)) + break; + expected += cost; + sent.insert(c.car->id); + chosen.push_back(std::move(k)); + } + // Then they are written; a far car whose turn it is goes without its + // state when it would not fit with it. + std::size_t used = 0; + std::unordered_set written; + for (Chosen& k : chosen) { + std::size_t bits = k.full ? k.fullBits : heldBits; + if (used + bits > budget && k.full && k.canHold) { + k.full = false; + bits = heldBits; + } 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)); + if (!k.full) + k.entity.hasState = false; + written.insert(k.entity.id); + msg.entities.push_back(std::move(k.entity)); } - set = std::move(sent); + set = std::move(written); return msg; } From 609a45585d37873fe0a78dd6a8e3254b40a9cb6f Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Fri, 9 Oct 2026 13:39:02 -0700 Subject: [PATCH 015/129] Give a prop what a car of the network car's mass would The host's copy of another player's car broke a banger and gave it all of the relative velocity, as an infinitely heavy body: a parked car hit by a client's car flew off at the car's full speed on the host (and so on every screen), about twice what the client's own car gave it. It now meets the banger exactly as dgImpact::CalcImpact meets a car of its mass (the car's phInertialCS): the same break test and the same share of the motion; only the car's own response is left out, which its owner's machine simulates. Within 5% of a simulated car's piece in the test. The props trace lists every placed prop once (P lines), for finding props and parked cars to drive into. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/game/net/NetProps.cpp | 11 ++++++++++ src/game/net/NetProps.h | 6 ++++-- src/phys/Impact.cpp | 17 ++++++--------- tests/game/test_prop_sync.cpp | 40 +++++++++++++++++++++++++++++++++++ 4 files changed, 62 insertions(+), 12 deletions(-) diff --git a/src/game/net/NetProps.cpp b/src/game/net/NetProps.cpp index 9179810..85218d4 100644 --- a/src/game/net/NetProps.cpp +++ b/src/game/net/NetProps.cpp @@ -44,6 +44,15 @@ std::string describe(const net::PropDescriptor& what) { return "?"; } +void PropTrace::placed(const bangers::BangerSet& set) { + const std::size_t n = placedProps(set); + for (std::size_t i = 0; i < n; ++i) { + const auto& inst = set.instances()[i]; + std::fprintf(m_file, "P %zu %s %.2f %.2f %.2f\n", i, inst.model.c_str(), inst.ground.m3.x, + inst.ground.m3.y, inst.ground.m3.z); + } +} + void PropTrace::knock(double t, std::size_t prop, std::string_view model, std::string_view cause) { std::fprintf(m_file, "K %.0f %zu %.*s %.*s\n", t, prop, static_cast(model.size()), model.data(), static_cast(cause.size()), cause.data()); @@ -96,6 +105,8 @@ void NetProps::setup(NetGame& net, bangers::BangerSet& set, m_set = &set; m_catalog = propCatalog(set); m_trace = PropTrace::fromEnvironment(); + if (m_trace) + m_trace->placed(set); set.recordKnocks(true); const std::size_t placed = placedProps(set); if (const char* mode = std::getenv("OPENMM2_NETPROPS"); mode && std::string_view(mode) == "local") { diff --git a/src/game/net/NetProps.h b/src/game/net/NetProps.h index 62533d5..d399eff 100644 --- a/src/game/net/NetProps.h +++ b/src/game/net/NetProps.h @@ -24,8 +24,8 @@ namespace mm2::game { class NetGame; struct NetGameEvent; -// Development aid: OPENMM2_DEBUG_NETPROPS= writes, one line each, every -// placed prop that broke loose on this machine ("K +// Development aid: OPENMM2_DEBUG_NETPROPS= writes, one line each, the +// placed props ("P ..."), every placed prop that broke loose on this machine ("K // "), every knock this machine predicted and undid ("X ..."), // and every 250 ms of session time ("T ") the props not standing // ("B "), the knocked-over props and thrown parts shown ("H @@ -40,6 +40,8 @@ class PropTrace { PropTrace(const PropTrace&) = delete; PropTrace& operator=(const PropTrace&) = delete; + // Every placed prop once ("P ", its ground point). + void placed(const bangers::BangerSet& set); void knock(double t, std::size_t prop, std::string_view model, std::string_view cause); void undo(double t, std::size_t prop); void impact(double t, std::string_view cause, float value, float damage); diff --git a/src/phys/Impact.cpp b/src/phys/Impact.cpp index f9af8ea..3468cce 100644 --- a/src/phys/Impact.cpp +++ b/src/phys/Impact.cpp @@ -465,10 +465,11 @@ bool calcBangerImpact(Impact& impact, float weight, float impulseLimit2) { // OpenMM2: a kinematic body that moves (a network car on this machine) // has no ICS in its collider, so no impulse could stop it and the banger // would hold forever. One that may (Body::kinematicBreaksBangers: the - // host's copies of the other players' cars) breaks the banger as a body - // of its own mass would (Body::ics), keeps its motion and gives the - // banger all of the relative velocity. MM2's network cars are simulated - // vehCars. + // host's copies of the other players' cars) meets the banger as a body + // of its own mass would (Body::ics): the same break test and the same + // share of the motion for the banger. It keeps its own motion (its + // owner's machine simulates the car's side of the hit). MM2's network + // cars are simulated vehCars. const Body* kinematic = !impact.colliderA->ics && impact.colliderA->moving ? impact.colliderA->body : nullptr; if (kinematic && (!kinematic->kinematicBreaksBangers || !(kinematic->ics.mass > 0.0f))) @@ -484,14 +485,10 @@ bool calcBangerImpact(Impact& impact, float weight, float impulseLimit2) { j = {j.x * s, j.y * s, j.z * s}; // A's velocity after the limited impulse, relative to B. const Vec3 after = transformed(test, j); - Vec3 remaining{after.x + relVel.x, after.y + relVel.y, after.z + relVel.z}; + const Vec3 remaining{after.x + relVel.x, after.y + relVel.y, after.z + relVel.z}; impulseA = j; - if (kinematic) { - remaining = relVel; - impulseA = {}; - } mb = invMass(impact.colliderB, impact.position); - m = added(m, mb); + m = added(test, mb); j = age::solveSVD(m, {-remaining.x, -remaining.y, -remaining.z}); broke = true; } else if (kinematic) { diff --git a/tests/game/test_prop_sync.cpp b/tests/game/test_prop_sync.cpp index 4a5e04f..8057e72 100644 --- a/tests/game/test_prop_sync.cpp +++ b/tests/game/test_prop_sync.cpp @@ -412,6 +412,46 @@ TEST(PropSync, MovingKinematicBodiesBreakProps) { m.world.remove(&c->body); } +// It gives the prop what a simulated car of its mass gives it (dgImpact's +// share of the motion), not all of it: a parked car flies as far on the host +// as from the owner's own car. +TEST(PropSync, KinematicBodiesShareTheMotionAsTheirMass) { + TempBangers files; + bangers::BangerDataLibrary lib(files.vfs); + auto pieceSpeed = [&](bool kinematic) { + Machine m(lib); + m.place(layout()); + Car car({6, 1, 3.0f}, {0, 0, -10}); + if (kinematic) { + car.body.kinematic = true; + car.body.kinematicMoves = true; + car.body.kinematicVelocity = {0, 0, -10}; + car.body.kinematicBreaksBangers = true; + car.body.resetCollider(); + } + m.world.add(&car.body); + float speed = 0.0f; + for (int i = 0; i < 40 && m.set.standing(1); ++i) { + if (kinematic) { + Mat34 at = car.body.ics.matrix; + at.m3 = at.m3 + car.body.kinematicVelocity * kDt; + car.body.place(at); + } + m.step(); + } + m.step(); // the impulses move the piece + for (std::size_t i = 0; i < m.set.instances().size(); ++i) + if (m.set.instances()[i].source == 1) + if (const phys::Body* b = m.set.body(i)) + speed = b->ics.linearVelocity.mag(); + m.world.remove(&car.body); + return speed; + }; + const float simulated = pieceSpeed(false), kinematic = pieceSpeed(true); + ASSERT_GT(simulated, 2.0f); + EXPECT_NEAR(kinematic, simulated, simulated * 0.05f); +} + // Lost, late and reordered messages: the client still ends with the host's // props, and every knock is applied once. TEST(PropSync, LossyLinkConverges) { From 30130dafbd3de8ed17f1439a2e1c16bf6a5b45fc Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Fri, 9 Oct 2026 13:43:07 -0700 Subject: [PATCH 016/129] Test that a big crash's props stay within one datagram A car ploughs through 60 props on the host: every knock reaches the client, the largest message is 425 bytes and the crash costs about 5 KB/s for a few seconds; the ring of 40 knocked-over props (dgBangerManager) bounds what any message can carry. Co-Authored-By: Claude Opus 5.5 (1M context) --- tests/game/test_prop_sync.cpp | 59 +++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/tests/game/test_prop_sync.cpp b/tests/game/test_prop_sync.cpp index 8057e72..76aaa5e 100644 --- a/tests/game/test_prop_sync.cpp +++ b/tests/game/test_prop_sync.cpp @@ -25,6 +25,7 @@ #include #include #include +#include #include using namespace mm2; @@ -477,6 +478,64 @@ TEST(PropSync, LossyLinkConverges) { EXPECT_EQ(r.propClient->stats().refused, 0u); } +// A big crash: a car ploughs through a field of 60 props. Every message +// stays one datagram; the ring (40, MM2's dgBangerManager) bounds it, the +// oldest knocked-over props disappearing as it wraps, on every machine alike. +TEST(PropSync, ABigCrashStaysWithinADatagram) { + TempBangers files; + bangers::BangerDataLibrary lib(files.vfs); + Machine host(lib), client(lib); + std::vector field; + for (int i = 0; i < 60; ++i) + field.push_back({"light", Mat34::translation({-1.5f + static_cast(i % 3) * 1.5f, 0.0f, + -static_cast(i / 3) * 2.0f}), + 1, PlacedProp::Source::Instance, true}); + host.place(field); + client.place(field); + client.set.setReplica([](const phys::Instance&) { return false; }); + PropHost propHost; + PropClient propClient(propCatalog(client.set)); + Car car({0, 1, 4.0f}, {0, 0, -25}); + host.world.add(&car.body); + std::size_t largest = 0, bytes = 0, events = 0, messages = 0; + double now = 10000.0; + for (int i = 0; i < 8 * 60; ++i) { + if (i == 5 * 60) + host.world.remove(&car.body); + now += kStepMs; + host.step(); + const auto t = static_cast(now); + propHost.knocked(host.set.takeKnocks(), t); + for (auto& e : propHost.takeKnockEvents()) { + EXPECT_LE(e.size(), net::kMaxEventPayload); + ++events; + net::PropKnocksEvent k; + ASSERT_TRUE(net::decodePayload(e, k)); + propClient.receiveKnocks(k); + } + if (auto m = propHost.build(host.set, t, static_cast(now), propCatalog(host.set))) { + const auto encoded = net::encodeMessage(*m); + largest = std::max(largest, encoded.size()); + bytes += encoded.size(); + ++messages; + propClient.receive(*m, now + 50.0); + } + propClient.update(client.set, now, nullptr); + client.step(); + } + std::size_t knocked = 0; + for (std::size_t i = 0; i < 60; ++i) { + knocked += host.set.standing(i) ? 0 : 1; + EXPECT_EQ(client.set.standing(i), host.set.standing(i)) << i; + } + EXPECT_GT(knocked, 20u); + EXPECT_LT(largest, 1100u); + // About 20 messages a second while the props fly, far fewer once they rest. + EXPECT_LT(bytes / 8, 12000u); // bytes a second, on average over the crash + std::printf("big crash: %zu props knocked, %zu messages (largest %zu bytes, %zu bytes/s), %zu knock events\n", + knocked, messages, largest, bytes / 8, events); +} + // A machine that loads the race late (or a prop knocked far from it) gets // every knock so far at once: PropHost::catchUp. TEST(PropSync, LateMachineCatchesUp) { From 7593b192bf793628749f710556a098b629206a3c Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Fri, 9 Oct 2026 13:43:53 -0700 Subject: [PATCH 017/129] Give the moving police cars' error in mm2tool nettrace Co-Authored-By: Claude Opus 5.5 (1M context) --- tools/mm2tool/cmd_nettrace.cpp | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tools/mm2tool/cmd_nettrace.cpp b/tools/mm2tool/cmd_nettrace.cpp index 18ad5bb..dd29068 100644 --- a/tools/mm2tool/cmd_nettrace.cpp +++ b/tools/mm2tool/cmd_nettrace.cpp @@ -210,7 +210,7 @@ int cmdNettrace(std::span args) { return 1; } constexpr int kOffRail = 0x10; - std::vector rail, moving, offRail, police, near, local, shownMoving, afterHit; + std::vector rail, moving, offRail, police, policeMoving, near, local, shownMoving, afterHit; std::size_t present = 0, extra = 0, missing = 0, copFrames = 0, copTargets = 0; for (std::size_t i = 0; i < client.frames.size(); ++i) { const Frame& fr = client.frames[i]; @@ -238,6 +238,8 @@ int cmdNettrace(std::span args) { afterHit.push_back(e); if (c.kind == 1) { police.push_back(e); + if (std::abs(h->speed) > 2.0) + policeMoving.push_back(e); ++copFrames; copTargets += h->target != c.target ? 1 : 0; } else if ((c.flags & kOffRail) != 0) { @@ -273,6 +275,7 @@ int cmdNettrace(std::span args) { row("rail cars moving > 2 m/s", moving); row("off-rail (knocked) cars", offRail); row("police", police); + row("police moving > 2 m/s", policeMoving); row("any car within 40 m", near); row("knocked by the client", local); row("hit by the client, 1 s", afterHit); From 24dbd3dcdbbdee16071c1ceddf5fc023c68f8c9e Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Fri, 9 Oct 2026 13:44:39 -0700 Subject: [PATCH 018/129] Drive, queue and predict the players' cars one sample at a time game/net/PlayerCars: NetCarDriver applies one sample's input to a car the same way on every machine (the gearbox keys, the gold's mass and throttle cap, the finish brake, the grid hold, mmGame::UpdateSteeringBrakes, Cops and Robbers' regeneration) and carries out the resets a client's rules ask for; HostInputQueue takes a client's numbered inputs, starts once a few are in hand, applies one per sample, repeats a missing one for a quarter of a second and then lets the car coast, and drops what comes too late or too far ahead; CarPrediction keeps a client's car's inputs and states, sends what the host has not acknowledged, and when the host's state for a sample differs puts the car there and runs the later samples again on their own; CorrectionBlend draws what a correction moved away over a few frames. The input frame gains Cops and Robbers' regeneration flag. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/game/PlayerVehicle.cpp | 6 +- src/game/PlayerVehicle.h | 7 + src/game/net/PlayerCars.cpp | 483 ++++++++++++++++++++++++++++++++ src/game/net/PlayerCars.h | 235 ++++++++++++++++ src/net/PlayerCars.h | 5 +- tests/game/test_player_cars.cpp | 230 +++++++++++++++ 6 files changed, 962 insertions(+), 4 deletions(-) create mode 100644 src/game/net/PlayerCars.cpp create mode 100644 src/game/net/PlayerCars.h create mode 100644 tests/game/test_player_cars.cpp diff --git a/src/game/PlayerVehicle.cpp b/src/game/PlayerVehicle.cpp index 9dca365..17a8901 100644 --- a/src/game/PlayerVehicle.cpp +++ b/src/game/PlayerVehicle.cpp @@ -246,10 +246,12 @@ bool SimVehicle::settleOnGround(const phys::World& world, const Vec3& from) { return true; } -void SimVehicle::respawnAt(const Mat34& at) { +void SimVehicle::respawnAt(const Mat34& at) { respawnAt(at.m3, phys::resetRotationOf(at)); } + +void SimVehicle::respawnAt(const Vec3& position, float rotation) { const Vec3 savedPos = m_sim.resetPos(); const float savedRotation = m_sim.resetRotation; - setResetPos(at); + setResetPos(position, rotation); reset(); // SetResetPos(the saved reset position): CenterOfGravity is added again. m_sim.setResetPos(savedPos); diff --git a/src/game/PlayerVehicle.h b/src/game/PlayerVehicle.h index 91a9d9f..6020f91 100644 --- a/src/game/PlayerVehicle.h +++ b/src/game/PlayerVehicle.h @@ -66,6 +66,8 @@ class SimVehicle { // back through SetResetPos, which adds CenterOfGravity again, so every // such respawn moves the start a later reset() uses by CenterOfGravity. void respawnAt(const Mat34& at); + // The same at `position` turned by `rotation` (as setResetPos takes them). + void respawnAt(const Vec3& position, float rotation); // Applies pedal input through the original's automatic-reverse logic // (mmGame::UpdateSteeringBrakes). After hold() it first makes the car @@ -78,6 +80,11 @@ class SimVehicle { // The pedal handling's state (the AUTO REVERSE option, the swapped // pedals that the transmission keys reset). phys::ArcadeControls& controls() { return m_controls; } + const phys::ArcadeControls& controls() const { return m_controls; } + // Whether the car is held (hold() until drive()). OpenMM2's network + // prediction saves and restores it with the car's state. + bool held() const { return m_held; } + void setHeld(bool held) { m_held = held; } bool reversing() const; // Pose for rendering (body and wheel matrices from the simulation). diff --git a/src/game/net/PlayerCars.cpp b/src/game/net/PlayerCars.cpp new file mode 100644 index 0000000..f6e0dd6 --- /dev/null +++ b/src/game/net/PlayerCars.cpp @@ -0,0 +1,483 @@ +// The players' cars of a network race, simulated by the host (see +// PlayerCars.h and docs/multiplayer.md, "Players' cars"). + +#include "game/net/PlayerCars.h" + +#include "game/Interpolation.h" + +#include +#include +#include + +namespace mm2::game { + +// --- Inputs -------------------------------------------------------------------------- + +net::CarInputFrame inputFrame(const phys::PedalInput& recorded) { + // The pedals as mmReplayManager::Update records them (controls:: + // replayQuantize): steering x 127 in a signed byte, the pedals x 255. + const auto pedal = [](float v) { + return static_cast(std::clamp(std::lround(v * 255.0f), 0L, 255L)); + }; + net::CarInputFrame f; + f.steering = static_cast(std::clamp(std::lround(recorded.steering * 127.0f), -127L, 127L)); + f.throttle = pedal(recorded.accelerator); + f.brake = pedal(recorded.brake); + f.handbrake = pedal(recorded.handbrake); + return f; +} + +phys::PedalInput pedalsOf(const net::CarInputFrame& f) { + // GetSteering / GetThrottle / GetBrakes / GetHandBrakes read the bytes + // back x 1/127 and x 1/255. + phys::PedalInput p; + p.steering = static_cast(static_cast(f.steering)) * 0.007874016f; + p.accelerator = static_cast(f.throttle) * 0.003921569f; + p.brake = static_cast(f.brake) * 0.003921569f; + p.handbrake = static_cast(f.handbrake) * 0.003921569f; + return p; +} + +void NetCarDriver::attach(const SimVehicle& car) { + m_baseMass = car.sim().body.ics.mass; + m_extraMass = 0; +} + +bool NetCarDriver::apply(SimVehicle& car, const net::CarInputFrame& in) { + phys::CarSim& sim = car.sim(); + // mmPlayer::UpdateRegen (Cops and Robbers, while the car carries no gold). + const bool cleared = (in.flags & net::kInputRegen) && sim.regenerate(); + phys::Transmission& trans = sim.trans; + phys::ArcadeControls& controls = car.controls(); + // mmGame::UpdateGameInput's gearbox keys, in its order: automatic <-> + // manual (a reverse taken with the swapped pedals goes back to drive + // first), shift up, shift down, reverse (or first from reverse). + const bool automatic = (in.flags & net::kInputAutomatic) != 0; + if (automatic != trans.isAutomatic) { + if (controls.swapThrottle) + trans.setDrive(); + controls.swapThrottle = false; + trans.automatic(automatic); + } + if ((in.events & net::kInputShiftUp) && !trans.isAutomatic) + trans.upshift(); + if ((in.events & net::kInputShiftDown) && !trans.isAutomatic) + trans.downshift(); + if (in.events & net::kInputReverse) { + controls.swapThrottle = false; + trans.setCurrentGear(trans.currentGear != phys::Transmission::kReverse ? phys::Transmission::kReverse + : phys::Transmission::kFirst); + } + controls.autoReverse = (in.flags & net::kInputAutoReverse) != 0; + // mmMultiCR::FondleCarMass: the gold's mass on the carrier + // (phInertialCS::Init with the mass changed). + if (in.extraMass != m_extraMass) { + phys::InertialCS& ics = sim.body.ics; + ics.init(m_baseMass + static_cast(in.extraMass), ics.inertia.x, ics.inertia.y, ics.inertia.z); + m_extraMass = in.extraMass; + } + // mmGame::UpdateSteeringBrakes, network games: in a forward gear the + // throttle is capped (the gold's weight). + phys::PedalInput pedals = pedalsOf(in); + if (trans.getCurrentGear() > 0) + pedals.accelerator = + std::clamp(pedals.accelerator, 0.0f, static_cast(in.throttleCap) * 0.003921569f); + const bool finished = (in.flags & net::kInputFinished) != 0; + sim.raceFinished = finished; + if (finished) + car.drive({}); + else if (in.flags & net::kInputHeld) + car.hold(pedals); + else + car.drive(pedals); + return cleared; +} + +void NetCarDriver::command(SimVehicle& car, const net::CarCommand& c) { + switch (c.kind) { + case net::CarCommandKind::Reset: car.reset(); return; // mmPlayer::Reset + case net::CarCommandKind::ResetTo: + car.sim().setResetPosRaw(c.position); + car.sim().resetRotation = c.rotation; + car.reset(); + return; + case net::CarCommandKind::RespawnAt: car.respawnAt(c.position, c.rotation); return; + case net::CarCommandKind::ClearDamage: car.sim().damage.reset(); return; // vehCar::ClearDamage + } +} + +// --- The host ------------------------------------------------------------------------ + +void HostInputQueue::receive(const net::PlayerInputMsg& msg) { + if (msg.frames.empty() || msg.first == 0) + return; + const auto count = static_cast(msg.frames.size()); + if (m_next == 0) { + // The first message: its first input comes first (once a few are in + // hand, ready()). + m_next = msg.first; + m_last = msg.frames.front(); + } + for (std::uint32_t i = 0; i < count; ++i) { + const std::uint32_t seq = msg.first + i; + if (seq < m_next || seq > m_next + kMaxAhead) + continue; // already applied (a repeat, or too late), or far ahead + m_frames[seq] = msg.frames[i]; + m_newest = std::max(m_newest, seq); + } + for (const auto& c : msg.commands) + if (c.seq > m_lastCommand && c.seq <= m_next + kMaxAhead && m_commands.size() < 16) + m_commands[c.seq] = c; +} + +std::optional HostInputQueue::next() { + if (m_next == 0) + return std::nullopt; + Next n; + n.seq = m_next; + if (const auto it = m_frames.find(m_next); it != m_frames.end()) { + n.frame = it->second; + n.real = true; + m_last = n.frame; + m_starved = 0; + m_frames.erase(m_frames.begin(), std::next(it)); + } else { + // Late or lost: the last input again (without its keys), then the + // car coasts. + ++m_missed; + ++m_starved; + n.frame = m_last; + n.frame.events = 0; + if (m_starved > kRepeatSamples) { + n.frame.steering = 0; + n.frame.throttle = 0; + n.frame.brake = 0; + n.frame.handbrake = 0; + } + } + // The commands due by this sample (a late one at once). + for (auto it = m_commands.begin(); it != m_commands.end() && it->first <= m_next;) { + n.commands.push_back(it->second); + m_lastCommand = std::max(m_lastCommand, it->first); + it = m_commands.erase(it); + } + const std::int32_t waiting = static_cast(m_newest) - static_cast(m_next); + m_leastWaiting = std::min(m_leastWaiting, waiting); + ++m_next; + return n; +} + +std::optional HostInputQueue::placement() const { + for (const auto& [seq, c] : m_commands) + if (c.kind == net::CarCommandKind::ResetTo || c.kind == net::CarCommandKind::RespawnAt) + return c; + return std::nullopt; +} + +std::int32_t HostInputQueue::takeLeastWaiting() { + const std::int32_t w = m_leastWaiting; + m_leastWaiting = 1 << 20; + if (w == (1 << 20)) + return static_cast(m_newest) - static_cast(m_next) + 1; + return w; +} + +net::OwnCarState ownCarState(const SimVehicle& car, std::uint32_t resets) { + const phys::CarSim& sim = car.sim(); + const phys::InertialCS& ics = sim.body.ics; + net::OwnCarState s; + s.matrix = ics.matrix; + s.linearMomentum = ics.linearMomentum; + s.angularMomentum = ics.angularMomentum; + s.linearVelocity = ics.linearVelocity; + s.angularVelocity = ics.angularVelocity; + s.lastPush = ics.lastPush; + for (std::size_t i = 0; i < 4; ++i) { + const phys::Wheel& w = sim.wheels[i]; + s.wheels[i] = {w.rotationSpeed, w.rotation, w.suspension, + w.suspensionVelocity, w.currentTireDispLat, w.currentTireDispLong}; + } + s.engineSpeed = sim.engine.rotationSpeed; + s.gearChangeTime = sim.engine.gearChangeTime; + s.prevGearRpm = sim.engine.prevGearRPM; + s.changingGear = sim.engine.changingGear; + s.gear = sim.trans.currentGear; + s.automatic = sim.trans.isAutomatic; + s.gearChanged = sim.trans.gearChanged; + s.timeInGear = sim.trans.timeInGear; + for (std::size_t i = 0; i < 3; ++i) + s.drivetrainSpeed[i] = sim.drivetrains[i].rotationSpeed; + s.damage = sim.damage.currentDamage; + s.stuckState = sim.stuck.state; + s.stuckActive = sim.stuck.active; + s.stuckTime = sim.stuck.stuckTime; + s.stuckPosition = sim.stuck.impactPosition; + s.random = sim.randomState; + s.swapThrottle = car.controls().swapThrottle; + s.held = car.held(); + s.resets = resets; + return s; +} + +void applyOwnCarState(SimVehicle& car, const net::OwnCarState& s) { + phys::CarSim& sim = car.sim(); + phys::InertialCS& ics = sim.body.ics; + ics.matrix = s.matrix; + ics.linearMomentum = s.linearMomentum; + ics.angularMomentum = s.angularMomentum; + ics.linearVelocity = s.linearVelocity; + ics.angularVelocity = s.angularVelocity; + ics.lastPush = s.lastPush; + for (std::size_t i = 0; i < 4; ++i) { + phys::Wheel& w = sim.wheels[i]; + const auto& o = s.wheels[i]; + w.rotationSpeed = o.rotationSpeed; + w.rotation = o.rotation; + w.suspension = o.suspension; + w.suspensionVelocity = o.suspensionVelocity; + w.currentTireDispLat = o.tireDispLat; + w.currentTireDispLong = o.tireDispLong; + } + sim.engine.rotationSpeed = s.engineSpeed; + sim.engine.gearChangeTime = s.gearChangeTime; + sim.engine.prevGearRPM = s.prevGearRpm; + sim.engine.changingGear = s.changingGear; + sim.trans.currentGear = std::clamp(s.gear, 0, phys::Transmission::kSlots - 1); + sim.trans.isAutomatic = s.automatic; + sim.trans.gearChanged = s.gearChanged; + sim.trans.timeInGear = s.timeInGear; + for (std::size_t i = 0; i < 3; ++i) + sim.drivetrains[i].rotationSpeed = s.drivetrainSpeed[i]; + sim.damage.currentDamage = s.damage; + sim.stuck.state = s.stuckState; + sim.stuck.active = s.stuckActive; + sim.stuck.stuckTime = s.stuckTime; + sim.stuck.impactPosition = s.stuckPosition; + sim.randomState = s.random; + car.controls().swapThrottle = s.swapThrottle; + car.setHeld(s.held); + // The bound follows the body; the next sweep starts where it is (the + // host's bound matrix does not travel: it differs from the body's only + // by a push during a contact). + sim.body.syncBoundMatrix(); + sim.body.collider.lastMatrix = sim.body.boundMatrix; +} + +net::VehicleSnapshot carSnapshot(const SimVehicle& car, const net::CarInputFrame& input) { + const phys::CarSim& sim = car.sim(); + const Mat34 model = sim.modelMatrix(); + net::VehicleSnapshot s; + s.position = model.m3; + s.orientation = Quat::fromMatrix(model); + s.linearVelocity = sim.body.ics.frameVelocity; + s.angularVelocity = sim.body.ics.angularVelocity; + const phys::PedalInput pedals = pedalsOf(input); + s.controls.steering = pedals.steering; + s.controls.throttle = sim.engine.throttle; + s.controls.brake = sim.brakes; + s.controls.handbrake = sim.handBrake; + s.controls.gear = static_cast(sim.trans.getCurrentGear()); + s.damage = std::clamp(sim.damage.damage, 0.0f, 1.0f); + if (input.flags & net::kInputHorn) + s.flags |= net::kVehicleHorn; + if (input.flags & net::kInputHeadlights) + s.flags |= net::kVehicleHeadlights; + if (sim.brakes != 0.0f) + s.flags |= net::kVehicleBrakeLights; + if (sim.damage.wrecked()) + s.flags |= net::kVehicleWrecked; + return s; +} + +// --- The client ---------------------------------------------------------------------- + +void CarPrediction::save(Entry& e, const SimVehicle& car, const NetCarDriver& driver) const { + e.car = car.sim().saveState(); + if (const phys::Trailer* t = car.trailer()) + e.trailer = t->saveState(); + else + e.trailer.reset(); + e.controls = car.controls(); + e.held = car.held(); + e.driver = driver; +} + +void CarPrediction::restore(const Entry& e, SimVehicle& car, NetCarDriver& driver) { + car.sim().restoreState(e.car); + if (phys::Trailer* t = car.trailer(); t && e.trailer) + t->restoreState(*e.trailer); + car.controls() = e.controls; + car.setHeld(e.held); + driver = e.driver; +} + +void CarPrediction::command(SimVehicle& car, net::CarCommand c) { + c.seq = m_next; + NetCarDriver::command(car, c); + m_pending.push_back(c); + m_unacked.push_back(c); + if (m_unacked.size() > net::kMaxCarCommands) + m_unacked.erase(m_unacked.begin()); +} + +void CarPrediction::beginSample(SimVehicle& car, NetCarDriver& driver, const net::CarInputFrame& input) { + Entry e; + e.seq = m_next; + e.input = input; + e.commands = std::exchange(m_pending, {}); + driver.apply(car, input); + m_history.push_back(std::move(e)); +} + +void CarPrediction::endSample(const SimVehicle& car, const NetCarDriver& driver) { + if (m_history.empty() || m_history.back().seq != m_next) + return; + save(m_history.back(), car, driver); + ++m_next; + while (m_history.size() > m_options.history) + m_history.pop_front(); +} + +std::optional CarPrediction::message() const { + if (m_history.empty() || m_history.back().seq != m_next - 1) + return std::nullopt; + const std::uint32_t newest = m_history.back().seq; + std::uint32_t first = std::max(m_acked + 1, m_history.front().seq); + if (newest + 1 - first > net::kMaxInputFrames) + first = newest + 1 - static_cast(net::kMaxInputFrames); + if (first > newest) + first = newest; // everything acknowledged: the newest again keeps the host's clock fed + net::PlayerInputMsg msg; + msg.first = first; + for (const Entry& e : m_history) + if (e.seq >= first) + msg.frames.push_back(e.input); + msg.commands = m_unacked; + return msg; +} + +CarPrediction::Correction CarPrediction::acknowledge(SimVehicle& car, NetCarDriver& driver, + phys::World& world, std::uint32_t ack, + const net::OwnCarState& host, + const std::function& beforeLast) { + Correction out; + if (ack <= m_acked) + return out; // an older or repeated state + m_acked = ack; + ++m_stats.acks; + std::erase_if(m_unacked, [ack](const net::CarCommand& c) { return c.seq <= ack; }); + if (m_history.empty() || ack < m_history.front().seq || ack > m_history.back().seq) { + ++m_stats.unreplayable; + return out; + } + const std::size_t index = ack - m_history.front().seq; + Entry& base = m_history[index]; + const phys::InertialCS& predicted = base.car.ics; + out.positionError = (predicted.matrix.m3 - host.matrix.m3).mag(); + out.velocityError = (predicted.linearVelocity - host.linearVelocity).mag(); + float rotation = 0.0f; + for (const auto& [a, b] : {std::pair{predicted.matrix.m0, host.matrix.m0}, + std::pair{predicted.matrix.m1, host.matrix.m1}, + std::pair{predicted.matrix.m2, host.matrix.m2}}) + rotation = std::max({rotation, std::abs(a.x - b.x), std::abs(a.y - b.y), std::abs(a.z - b.z)}); + out.rotationError = rotation; + out.damage = base.car.damage.currentDamage != host.damage; + out.held = base.held != host.held; + out.gear = base.car.trans.currentGear != host.gear; + const bool differs = out.positionError > m_options.positionTolerance || + out.velocityError > m_options.velocityTolerance || + rotation > m_options.rotationTolerance || out.damage || out.held || out.gear; + const std::size_t later = m_history.size() - 1 - index; + if (differs && later > m_options.maxReplay) { + ++m_stats.unreplayable; + m_history.erase(m_history.begin(), m_history.begin() + static_cast(index)); + return out; + } + if (differs) { + const Vec3 before = car.sim().modelMatrix().m3; + restore(base, car, driver); + applyOwnCarState(car, host); + save(base, car, driver); + phys::Body* bodies[2] = {&car.sim().body, nullptr}; + std::size_t count = 1; + if (phys::Trailer* t = car.trailer()) + bodies[count++] = &t->body; + for (std::size_t k = index + 1; k < m_history.size(); ++k) { + Entry& e = m_history[k]; + for (const auto& c : e.commands) + NetCarDriver::command(car, c); + driver.apply(car, e.input); + if (k + 1 == m_history.size() && beforeLast) + beforeLast(); + world.replaySample(std::span(bodies, count), phys::kFixedSampleStep); + save(e, car, driver); + ++out.replayed; + } + // The resets asked for the next sample stand again. + for (const auto& c : m_pending) + NetCarDriver::command(car, c); + out.corrected = true; + out.moved = car.sim().modelMatrix().m3 - before; + ++m_stats.corrections; + m_stats.replayedSamples += static_cast(out.replayed); + } + m_history.erase(m_history.begin(), m_history.begin() + static_cast(index)); + return out; +} + +// --- CorrectionBlend ----------------------------------------------------------------- + +namespace { + +Mat34 rotationOf(const Mat34& m) { + Mat34 r = m; + r.m3 = {}; + return r; +} + +} // namespace + +void CorrectionBlend::add(const Mat34& before, const Mat34& after) { + // `before` is where the car is drawn (what is still being drawn away + // included): the offset takes the drawn car there from the corrected one. + const Vec3 moved = before.m3 - after.m3; + if (moved.mag() > snapDistance) { + clear(); + return; + } + m_position = moved; + // before = D x after (the drawn car keeps its turn): D = before x after^T. + m_rotation = Mat34::mul(rotationOf(before), rotationOf(after).fastInverse()); + m_rotation.m3 = {}; + m_active = true; +} + +void CorrectionBlend::update(float dt) { + if (!m_active) + return; + const float keep = std::exp2(-std::max(0.0f, dt) / halfLife); + m_position = m_position * keep; + m_rotation = blendTransform(Mat34::identity(), m_rotation, keep); + m_rotation.m3 = {}; + const bool straight = std::abs(m_rotation.m0.x - 1.0f) < 1e-6f && + std::abs(m_rotation.m1.y - 1.0f) < 1e-6f && + std::abs(m_rotation.m2.z - 1.0f) < 1e-6f; + if (m_position.mag() < 0.0005f && straight) + clear(); +} + +void CorrectionBlend::clear() { + m_position = {}; + m_rotation = Mat34::identity(); + m_active = false; +} + +Mat34 CorrectionBlend::apply(const Mat34& drawn) const { + if (!m_active) + return drawn; + Mat34 m = Mat34::mul(m_rotation, rotationOf(drawn)); + m.m3 = drawn.m3 + m_position; + return m; +} + +} // namespace mm2::game diff --git a/src/game/net/PlayerCars.h b/src/game/net/PlayerCars.h new file mode 100644 index 0000000..8d3278f --- /dev/null +++ b/src/game/net/PlayerCars.h @@ -0,0 +1,235 @@ +#pragma once + +// The players' cars in a network race, simulated by the host (OpenMM2; see +// docs/multiplayer.md, "Players' cars", and net/PlayerCars.h for the wire). +// +// MM2 ran a network car on every machine as a vehCar pulled toward its +// owner's packets (mmNetObject::PositionUpdate, Predict), so each machine +// had its own version of every collision. OpenMM2's host simulates every +// player's car from the players' inputs instead: collisions between +// players, with the traffic and with the props happen once, on the host. +// +// NetCarDriver what a machine does to a car each sample: the commands +// due (resets), then the input (mmGame:: +// UpdateSteeringBrakes, the gearbox keys, the hold, the +// gold's mass), the same on the host and on the car's +// own machine +// HostInputQueue host: one client's numbered inputs, applied one per +// sample; a late or missing one is repeated, then the +// car coasts +// CarPrediction client: its own car runs ahead on its inputs at once +// (no latency); each host state for an earlier sample +// that differs from what was predicted puts the car back +// there and runs the later inputs again +// CorrectionBlend client: what a correction moved, drawn away over a +// few frames (a large one at once) +// +// Nothing here touches the network or the race's presentation. + +#include "game/PlayerVehicle.h" +#include "net/PlayerCars.h" +#include "net/Snapshot.h" +#include "phys/World.h" + +#include +#include +#include +#include +#include +#include + +namespace mm2::game { + +// --- Inputs -------------------------------------------------------------------------- + +// The pedals as recorded (bytes, controls::replayQuantize) <-> an input frame. +net::CarInputFrame inputFrame(const phys::PedalInput& recorded); +phys::PedalInput pedalsOf(const net::CarInputFrame& frame); + +class NetCarDriver { +public: + // The car's mass without gold (call once the car is loaded). + void attach(const SimVehicle& car); + // One sample's input, before the sample: the gearbox switches and keys + // (mmGame::UpdateGameInput's TRANSMISSION, SHIFT UP / DOWN and REVERSE), + // the gold's mass (mmMultiCR::FondleCarMass), then the pedals: after the + // finish the car brakes with the wheel turned (mmPlayer +0x2258); held on + // the grid (vehCar::SetDrivable(0, 1)) the brakes are on; otherwise + // mmGame::UpdateSteeringBrakes (the throttle capped in a forward gear by + // the gold, then ArcadeControls' automatic reverse). + // With net::kInputRegen first mmPlayer::UpdateRegen (OpenMM2: once a + // sample, MM2 once a frame); returns true when that cleared the car's + // damage (mmPlayer::ResetDamage). + bool apply(SimVehicle& car, const net::CarInputFrame& in); + // A reset the client's rules asked for (net::CarCommand). + static void command(SimVehicle& car, const net::CarCommand& c); + + std::uint16_t extraMass() const { return m_extraMass; } + +private: + float m_baseMass = 0.0f; + std::uint16_t m_extraMass = 0; +}; + +// --- The host ------------------------------------------------------------------------ + +class HostInputQueue { +public: + // Inputs a sample may be ahead of the one applied (further ones are + // ignored), and the inputs in hand the queue waits for before its first + // (the margin it keeps against late ones; the client's pace keeps it). + static constexpr std::uint32_t kMaxAhead = 240; + static constexpr std::uint32_t kStartMargin = 3; + // Samples a missing input repeats the last one before the car coasts + // (no throttle, brake or steering). + static constexpr int kRepeatSamples = 15; + + // A client's message (untrusted: frames far from the next sample are + // ignored, commands already applied are dropped). + void receive(const net::PlayerInputMsg& msg); + + struct Next { + net::CarInputFrame frame; + std::vector commands; // due at this sample (or late) + std::uint32_t seq = 0; + bool real = false; // the client's own input for this sample + }; + // The next sample's input, or nothing before the first message. + std::optional next(); + + bool started() const { return m_next != 0; } + // Enough inputs in hand to begin with (the host places the car then). + bool ready() const { return m_next != 0 && m_newest + 1 >= m_next + kStartMargin; } + // The first command that places the car (ResetTo or RespawnAt) among + // those waiting: the host puts the car in the world with it. + std::optional placement() const; + // The last input applied (CarStatesMsg::ack). + std::uint32_t lastApplied() const { return m_next == 0 ? 0 : m_next - 1; } + // The fewest inputs waiting behind the applied one since the last call + // (negative: samples the queue ran short); for CarStatesMsg::waiting. + std::int32_t takeLeastWaiting(); + // Inputs applied that the client did not send in time (repeated or + // coasted), and inputs that came too late, since the start. + std::uint64_t missed() const { return m_missed; } + std::uint64_t late() const { return m_late; } + +private: + std::map m_frames; + std::map m_commands; + std::uint32_t m_next = 0; // the sample the next input is for + std::uint32_t m_newest = 0; + std::uint32_t m_lastCommand = 0; + net::CarInputFrame m_last; + int m_starved = 0; + std::int32_t m_leastWaiting = 1 << 20; + std::uint64_t m_missed = 0, m_late = 0; +}; + +// The car's state as the host sends it to its player, and the car put there +// (the rest of its state stays as it was). +net::OwnCarState ownCarState(const SimVehicle& car, std::uint32_t resets); +void applyOwnCarState(SimVehicle& car, const net::OwnCarState& state); + +// A player's car as the others draw it. +net::VehicleSnapshot carSnapshot(const SimVehicle& car, const net::CarInputFrame& input); + +// --- The client ---------------------------------------------------------------------- + +class CarPrediction { +public: + struct Options { + std::size_t history = 180; // samples kept (3 s) + std::size_t maxReplay = 120; // samples run again at most (a later state corrects instead) + // What counts as the host's state differing from the prediction. + float positionTolerance = 0.003f; // m + float velocityTolerance = 0.03f; // m/s + float rotationTolerance = 0.0015f; // matrix entries + }; + CarPrediction() = default; + explicit CarPrediction(const Options& o) : m_options(o) {} + + // The number the next sample gets (from 1). + std::uint32_t nextSeq() const { return m_next; } + std::uint32_t acknowledged() const { return m_acked; } + + // A reset for the next sample. It is applied to the car now (the car + // stands as it will at that sample's start) and again whenever the + // samples are run again. + void command(SimVehicle& car, net::CarCommand c); + // Around each sample: its input (applied and kept), then the car's state. + void beginSample(SimVehicle& car, NetCarDriver& driver, const net::CarInputFrame& input); + void endSample(const SimVehicle& car, const NetCarDriver& driver); + + // The inputs and commands the host has not acknowledged (the newest + // net::kMaxInputFrames), or nothing before the first sample. + std::optional message() const; + + struct Correction { + bool corrected = false; + int replayed = 0; + float positionError = 0.0f; // at the acknowledged sample + float velocityError = 0.0f; + float rotationError = 0.0f; // the largest difference of the matrices' entries + bool damage = false, held = false, gear = false; // which of these differed + Vec3 moved; // where the car is now against where it was predicted + }; + // The host's state after sample `ack`. Forgets what it acknowledged; when + // the state differs from the prediction for that sample, puts the car + // there and runs the later samples again on their inputs (alone: + // phys::World::replaySample), calling `beforeLast` before the last one + // (the drawing keeps the car's pose before its last sample). + Correction acknowledge(SimVehicle& car, NetCarDriver& driver, phys::World& world, std::uint32_t ack, + const net::OwnCarState& host, const std::function& beforeLast = {}); + + // Statistics since the start. + struct Stats { + std::uint64_t acks = 0, corrections = 0, replayedSamples = 0, unreplayable = 0; + }; + const Stats& stats() const { return m_stats; } + +private: + struct Entry { + std::uint32_t seq = 0; + net::CarInputFrame input; + std::vector commands; // applied before the input + // The state after the sample. + phys::CarSimState car; + std::optional trailer; + phys::ArcadeControls controls; + bool held = false; + NetCarDriver driver; + }; + void save(Entry& e, const SimVehicle& car, const NetCarDriver& driver) const; + static void restore(const Entry& e, SimVehicle& car, NetCarDriver& driver); + + Options m_options; + std::deque m_history; // oldest first, consecutive numbers + std::vector m_pending; // for the next sample + std::vector m_unacked; + std::uint32_t m_next = 1; + std::uint32_t m_acked = 0; + Stats m_stats; +}; + +// What a correction moved the car by, drawn away over a few frames: the +// drawn car stays where it was and eases onto the corrected one; a jump +// beyond `snapDistance` (a reset) is drawn at once. +class CorrectionBlend { +public: + float halfLife = 0.06f; // s + float snapDistance = 4.0f; // m + // The car was drawn at `before` (apply() of the drawing before the + // correction) and now would be at `after`. + void add(const Mat34& before, const Mat34& after); + void update(float dt); + void clear(); + Mat34 apply(const Mat34& drawn) const; + float offset() const { return m_position.mag(); } + +private: + Vec3 m_position; + Mat34 m_rotation = Mat34::identity(); // drawn = m_rotation x the car's + bool m_active = false; +}; + +} // namespace mm2::game diff --git a/src/net/PlayerCars.h b/src/net/PlayerCars.h index ff08336..3868b5a 100644 --- a/src/net/PlayerCars.h +++ b/src/net/PlayerCars.h @@ -56,13 +56,14 @@ enum CarInputFlags : std::uint8_t { kInputAutoReverse = 1 << 3, // the AUTO REVERSE option (mmInput +0x18c) kInputHorn = 1 << 4, kInputHeadlights = 1 << 5, + kInputRegen = 1 << 6, // Cops and Robbers' damage regeneration (mmPlayer::EnableRegen) }; enum CarInputEvents : std::uint8_t { kInputShiftUp = 1 << 0, kInputShiftDown = 1 << 1, kInputReverse = 1 << 2, // reverse, or first gear from reverse }; -inline constexpr std::uint8_t kCarInputFlagMask = 0x3f; +inline constexpr std::uint8_t kCarInputFlagMask = 0x7f; inline constexpr std::uint8_t kCarInputEventMask = 0x07; inline constexpr std::uint16_t kMaxExtraMass = 2000; // kg @@ -116,7 +117,7 @@ bool serialize(S& s, CarInputFrame& f, const CarInputFrame& previous) { s.u8(f.brake); s.u8(f.handbrake); std::uint32_t flags = f.flags & kCarInputFlagMask; - s.bits(flags, 6); + s.bits(flags, 7); f.flags = static_cast(flags); std::uint32_t events = f.events & kCarInputEventMask; s.bits(events, 3); diff --git a/tests/game/test_player_cars.cpp b/tests/game/test_player_cars.cpp new file mode 100644 index 0000000..aebdf1e --- /dev/null +++ b/tests/game/test_player_cars.cpp @@ -0,0 +1,230 @@ +// The host-simulated players' cars (game/net/PlayerCars): the per-sample +// driving, the host's input queue, the client's prediction and its +// corrections, run in one process with a simulated network between a +// "host" world and a "client" world. +#include "TestData.h" +#include "game/PlayerVehicle.h" +#include "game/net/PlayerCars.h" +#include "net/PlayerCars.h" +#include "phys/World.h" + +#include + +#include +#include +#include + +using namespace mm2; +using namespace mm2::game; + +namespace { + +phys::PolygonSoup flatGround(float half = 3000.0f) { + phys::SoupGeometry g; + g.vertices = {{-half, 0, -half}, {-half, 0, half}, {half, 0, half}, {half, 0, -half}}; + g.polys.push_back({{0, 1, 2, 3}, 4, 0}); + g.materialNames = {"default"}; + phys::PolygonSoup soup; + soup.add(g, Mat34::identity(), phys::MaterialTable{}); + soup.finalize(2048.0f); + return soup; +} + +// A player's input for sample `seq` of a test drive: accelerate, weave, +// brake, a gear key now and then. +net::CarInputFrame driveInput(std::uint32_t seq) { + phys::PedalInput p; + p.accelerator = (seq / 120) % 4 == 3 ? 0.0f : 1.0f; + p.brake = (seq / 120) % 4 == 3 ? 0.7f : 0.0f; + p.steering = std::sin(static_cast(seq) * 0.02f) * 0.6f; + net::CarInputFrame f = inputFrame(p); + f.flags = net::kInputAutomatic | net::kInputAutoReverse; + return f; +} + +// One machine's world with one car. +struct Machine { + phys::World world; + std::unique_ptr car; + NetCarDriver driver; + explicit Machine(const vfs::Vfs& vfs) { + world.setStatic(flatGround()); + std::string error; + car = SimVehicle::loadPlayer(vfs, "vpbug", &error, false); + EXPECT_TRUE(car) << error; + car->sim().options.player = true; + car->sim().setPolygonalBound(true); + car->sim().ownRandom = true; + car->sim().randomState = 1; + car->addTo(world); + driver.attach(*car); + } +}; + +// A message on its way: due at a client sample count. +template +struct InFlight { + std::uint32_t due = 0; + M msg; +}; + +struct Outcome { + int corrections = 0; + int replayed = 0; + float largest = 0.0f; // the largest correction's move (m) + std::uint64_t missed = 0; +}; + +// The client drives `samples` samples; its messages reach the host +// `upLatency` samples later and the host's states come back `downLatency` +// samples after it sent them (every third sample). `lose` drops every n-th +// message of the client's (0 none). +Outcome simulate(const vfs::Vfs& vfs, int samples, std::uint32_t upLatency, std::uint32_t downLatency, + int lose = 0) { + Machine host(vfs), client(vfs); + const Mat34 start = Mat34::translation({0.0f, 0.6f, 0.0f}); + client.car->setResetPos(start); + client.car->reset(); + CarPrediction prediction; + HostInputQueue queue; + bool placed = false; + std::deque> up; + std::deque>> down; + Outcome run; + const float dt = phys::kFixedSampleStep; + for (std::uint32_t t = 1; t <= static_cast(samples); ++t) { + // Client: the host's states due by now, then its sample. + while (!down.empty() && down.front().due <= t) { + const auto& [ack, state] = down.front().msg; + const auto c = prediction.acknowledge(*client.car, client.driver, client.world, ack, state); + if (c.corrected) { + ++run.corrections; + run.replayed += c.replayed; + run.largest = std::max(run.largest, c.moved.mag()); + } + down.pop_front(); + } + if (prediction.nextSeq() == 1) + prediction.command(*client.car, {0, net::CarCommandKind::ResetTo, client.car->sim().resetPos(), + client.car->sim().resetRotation}); + prediction.beginSample(*client.car, client.driver, driveInput(prediction.nextSeq())); + client.world.step(dt); + prediction.endSample(*client.car, client.driver); + const bool lost = lose > 0 && t % static_cast(lose) == 0; + if (const auto m = prediction.message(); m && !lost) + up.push_back({t + upLatency, *m}); + // Host: the inputs due by now, then its sample. + while (!up.empty() && up.front().due <= t) { + queue.receive(up.front().msg); + up.pop_front(); + } + if (!placed && queue.ready()) { + if (const auto c = queue.placement()) { + NetCarDriver::command(*host.car, *c); + placed = true; + } + } + if (placed) { + if (auto next = queue.next()) { + for (const auto& c : next->commands) + NetCarDriver::command(*host.car, c); + host.driver.apply(*host.car, next->frame); + } + } + host.world.step(dt); + if (placed && t % 3 == 0 && queue.lastApplied() != 0) + down.push_back({t + downLatency, {queue.lastApplied(), ownCarState(*host.car, 0)}}); + } + run.missed = queue.missed(); + return run; +} + +} // namespace + +TEST(PlayerCars, InputFramesCarryTheRecordedPedalsExactly) { + for (int s = -127; s <= 127; ++s) { + phys::PedalInput p; + p.steering = static_cast(s) * 0.007874016f; + p.accelerator = static_cast((s + 127) % 256) * 0.003921569f; + const phys::PedalInput back = pedalsOf(inputFrame(p)); + EXPECT_EQ(back.steering, p.steering); + EXPECT_EQ(back.accelerator, p.accelerator); + } +} + +TEST(PlayerCars, TheHostQueueRepeatsThenCoastsAndSkipsLateInputs) { + HostInputQueue q; + EXPECT_FALSE(q.next()); // nothing before the first message + net::PlayerInputMsg m; + m.first = 1; + net::CarInputFrame f; + f.throttle = 200; + f.events = net::kInputShiftUp; + m.frames.assign(5, f); + m.commands.push_back({3, net::CarCommandKind::Reset, {}, 0.0f}); + q.receive(m); + EXPECT_TRUE(q.ready()); + // It starts at the first. + auto n = q.next(); + ASSERT_TRUE(n); + EXPECT_EQ(n->seq, 1u); + EXPECT_TRUE(n->real); + EXPECT_TRUE(n->commands.empty()); + EXPECT_EQ(q.lastApplied(), 1u); + EXPECT_EQ(q.takeLeastWaiting(), 4); + n = q.next(); + n = q.next(); + ASSERT_EQ(n->commands.size(), 1u); // the command for 3, with its sample + n = q.next(); + n = q.next(); + EXPECT_EQ(n->seq, 5u); + // Nothing more: the last input again without its keys, then coasting. + for (int i = 0; i < HostInputQueue::kRepeatSamples; ++i) { + n = q.next(); + EXPECT_FALSE(n->real); + EXPECT_EQ(n->frame.throttle, 200); + EXPECT_EQ(n->frame.events, 0); + } + n = q.next(); + EXPECT_EQ(n->frame.throttle, 0); + EXPECT_EQ(q.missed(), static_cast(HostInputQueue::kRepeatSamples) + 1); + // Inputs for samples already applied change nothing; newer ones count. + m.first = 6; + q.receive(m); // 6..10: all but the current ones are past + n = q.next(); + EXPECT_FALSE(n->real); + m.first = n->seq + 1; + q.receive(m); + n = q.next(); + EXPECT_TRUE(n->real); + EXPECT_TRUE(n->commands.empty()); // the command was carried out +} + +TEST(PlayerCars, AnInputFarAheadIsIgnored) { + HostInputQueue q; + net::PlayerInputMsg m; + m.first = 10; + m.frames.resize(2); + q.receive(m); + m.first = 10 + HostInputQueue::kMaxAhead * 4; + q.receive(m); + EXPECT_LE(q.takeLeastWaiting(), 2); +} + +TEST(PlayerCars, ACarPredictedOnTheSameInputsNeedsNoCorrection) { + MM2_REQUIRE_GAME_DATA(); + // 10 s of driving with 100 ms of latency each way: after the start (the + // host's car takes over at its first input) the prediction is the + // host's simulation exactly. + const Outcome run = simulate(*test::gameData(), 600, 6, 6); + EXPECT_LE(run.corrections, 1); + EXPECT_EQ(run.missed, 0u); +} + +TEST(PlayerCars, LostInputsAreRepeatedFromTheNextMessage) { + MM2_REQUIRE_GAME_DATA(); + // Every fourth message lost: the next one carries its inputs again. + const Outcome run = simulate(*test::gameData(), 600, 6, 6, 4); + EXPECT_LE(run.corrections, 1); + EXPECT_EQ(run.missed, 0u); +} From 3f7305967e2b1598fc900f9425b0b3bcc5f2825d Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Fri, 9 Oct 2026 13:44:39 -0700 Subject: [PATCH 019/129] Let the host simulate every player's car from its player's inputs The maintainer's decision: the host is the authority for every player's car. In a network race every player's car takes its input once a sample. A client predicts its own car from its inputs at once and sends them (all the host has not acknowledged, each frame); the host simulates each client's car from them as a full car of the level (mmNetObject's vehCar, polygonal bound, type-3 mover), so collisions between players, with the traffic and with the props happen there once, and sends each client 20 times a second the last input it applied, the car's state after it and every other player's car. A client puts its car back to the host's state when they differ and runs its later inputs again; it draws the other cars interpolated from the host's states as before, and runs its samples up to 3% faster or 2% slower to keep the host a sample or three of its inputs ahead. Resets (the water, a fall, a debug respawn, Cops and Robbers' repairs) travel as commands with the inputs and happen on the host at the same sample. The host decides every car's damage: it records its simulated cars' dents, parts and impacts and sends them about each player's car (VehicleDamage subjects 513 on), and refuses a player's word on its own car; a client shows its own car's damage from them and keeps only its predicted sparks and sounds. OPENMM2_NET_TRACE's divergence lines now use the machine's shared monotonic clock (each process's own clock made the traces of instances started apart incomparable), syncreport also reports how close each machine drew two colliding cars around the collision, and OPENMM2_DEBUG_NETCARS logs the corrections and what the cars cost. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/app/RaceScreen.cpp | 704 ++++++++++++++++++++++++++------ src/game/net/DamageSync.cpp | 16 +- src/game/net/DamageSync.h | 5 + src/game/net/NetGame.cpp | 38 +- src/game/net/NetGame.h | 14 +- src/net/VehicleDamage.h | 9 +- tests/game/test_damage_sync.cpp | 76 ++-- tests/net/test_fuzz.cpp | 2 +- tools/netprobe/SyncReport.cpp | 36 +- 9 files changed, 737 insertions(+), 163 deletions(-) diff --git a/src/app/RaceScreen.cpp b/src/app/RaceScreen.cpp index ce776ab..1fe937e 100644 --- a/src/app/RaceScreen.cpp +++ b/src/app/RaceScreen.cpp @@ -43,6 +43,7 @@ #include "game/fx/Weather.h" #include "game/net/DamageSync.h" #include "game/net/NetGame.h" +#include "game/net/PlayerCars.h" #include "game/net/RaceStart.h" #include "game/net/TrafficProxies.h" #include "game/net/TrafficSync.h" @@ -67,6 +68,7 @@ #include #include #include +#include #include #include #include @@ -115,6 +117,11 @@ class RaceScreen final : public Screen { // The in-race popup's pages (mmPopup: PUMain, PUChat, and the pages // frontend::PopupOptions builds: OPTIONS, PUQuit, PUKey, PURoster). enum class Popup : std::uint8_t { None, Main, Chat, Options }; + +private: + struct RemoteVehicle; // another player's car (below) + +public: RaceScreen(Context& ctx, const game::RaceConfig& config) : m_ui(ctx.device(), ctx.game->vfs), m_text(ctx.device()) { m_result.config = config; @@ -394,7 +401,10 @@ class RaceScreen final : public Screen { if (!m_flyCamera && (in.keyDown(Key::LCtrl) || in.keyDown(Key::RCtrl)) && in.keyPressed(Key::B)) phys::Trailer::breakKeyPressed = true; } - m_frameSteps = m_world ? m_world->advanceFixed(static_cast(dt)) : 0; + // OpenMM2: a network client's car takes the host's word on its + // earlier samples first (game/net/PlayerCars). + reconcileNetCar(ctx); + m_frameSteps = m_world ? m_world->advanceFixed(physicsDt(ctx, static_cast(dt))) : 0; if (m_frameSteps > 0) phys::Trailer::breakKeyPressed = false; // The props and traffic cars the collisions set moving follow their @@ -406,9 +416,8 @@ class RaceScreen final : public Screen { if (m_player) { m_pose = m_player->pose(); m_trailerPose = m_player->trailerPose(); - if (multiplayer(ctx)) - sendLocalState(ctx); } + afterNetSamples(ctx); // OpenMM2: the host's states, a client's inputs updateDrawnPoses(); // OpenMM2: between the last two simulation steps traceNetDrawn(ctx); // OPENMM2_NET_TRACE: every player's car as drawn sendNetTraffic(ctx); // OpenMM2: the host's shared traffic @@ -720,7 +729,7 @@ class RaceScreen final : public Screen { continue; game::VehiclePose pose = rv.sim->pose(); stepBodies[id] = pose.body; - if (const auto it = m_remoteStepBodies.find(id); it != m_remoteStepBodies.end()) + if (const auto it = m_remoteStepBodies.find(id); it != m_remoteStepBodies.end() && !rv.simulated) pose.body = it->second; const std::uint32_t resets = rv.sim->sim().resets; m_drawnPhys.record(drawnKey(Drawn::RemoteCar, id), pose, resets); @@ -754,11 +763,24 @@ class RaceScreen final : public Screen { m_drawnPhys.setAlpha(m_world->interpolationAlpha()); if (m_ai) m_drawnAi.setAlpha(m_ai->interpolationAlpha()); + // OpenMM2: the host draws the players' cars it simulates between + // their last two samples, like its own. + for (const auto& [id, rv] : m_remotes) + if (rv.simulated && rv.placed && rv.sim) + m_remoteDrawn[id] = drawnPose(game::Drawn::RemoteCar, id, *rv.sim).body; if (!m_player) return; m_drawPose = drawnPose(game::Drawn::Player, 0, *m_player); m_drawTrailerPose = m_drawnPhys.pose(game::drawnKey(game::Drawn::PlayerTrailer, 0), m_player->trailerPose(), m_player->sim().resets); + // OpenMM2: a network client draws what the host's corrections moved + // its car by away over a few frames. + m_correction.update(m_frameDt); + if (const Mat34 smoothed = m_correction.apply(m_drawPose.body); m_correction.offset() > 0.0f) { + m_drawPose = game::placePose(m_drawPose, smoothed); + m_drawTrailerPose = game::placePose(m_drawTrailerPose, + m_correction.apply(m_drawTrailerPose.body)); + } } // The level as lvlLevel::Draw draws it for one view: the city, traffic, @@ -1111,6 +1133,15 @@ class RaceScreen final : public Screen { // or without race rules: it reports this machine loaded. if (multiplayer(ctx)) m_netStart.emplace(game::NetRaceStart::kindOf(m_result.config.mode)); + // OpenMM2: every player's car takes its input once a sample, on the + // host from its player's (game/net/PlayerCars). + if (multiplayer(ctx) && m_player && m_world) { + m_netDriver.attach(*m_player); + m_netAutomatic = m_result.config.automatic; + m_player->sim().ownRandom = true; + m_player->sim().randomState = 1; + m_world->setSampleHooks([this] { beforeNetSample(); }, [this] { afterNetSample(); }); + } // -nomusic: mmGameMusicData::Load loads neither the song nor the // city's ambience segment, so the race plays neither. if (auto* music = ctx.music(); music && !ctx.commandLine.noMusic) { @@ -2099,10 +2130,12 @@ class RaceScreen final : public Screen { // (back to the reset position); with them mmSingleCircuit / // mmGameMulti::HitWaterHandler reset the car at the last // checkpoint and put the reset position back. - if (m_session->setup().checkpoints.empty()) - m_player->reset(); - else - m_player->respawnAt(m_session->respawnTransform()); + if (m_session->setup().checkpoints.empty()) { + netCommand({0, net::CarCommandKind::Reset, {}, 0.0f}); + } else { + const Mat34 at = m_session->respawnTransform(); + netCommand({0, net::CarCommandKind::RespawnAt, at.m3, phys::resetRotationOf(at)}); + } m_crWaterHandled = m_cr != nullptr; // mmMultiCR::HitWaterHandler drops the gold if (m_vehicleFx) m_vehicleFx->reset(); // vehCar::Reset @@ -2168,7 +2201,7 @@ class RaceScreen final : public Screen { m_gameInput.reset(); m_ff.reset(); // mmPlayer::Reset: ResetFF, mmCarRoadFF::Reset } else if (e.type == EventType::DamageReset) { - m_player->sim().damage.reset(); + netCommand({0, net::CarCommandKind::ClearDamage, {}, 0.0f}); clearVehicleDamage(); // vehCar::ClearDamage } else if (e.type == EventType::PlayerDamageLimits) { auto& d = m_player->sim().damage.params; @@ -2519,9 +2552,11 @@ class RaceScreen final : public Screen { // mmMultiCR::FondleCarMass: the gold's mass on the carrier // (phInertialCS::Init with the mass changed) and its throttle cap. + // (OpenMM2: the car takes it with its inputs, game::NetCarDriver, on + // the host too.) void fondleMass(float kg) { - auto& ics = m_player->sim().body.ics; - ics.init(ics.mass + kg, ics.inertia.x, ics.inertia.y, ics.inertia.z); + const long gold = std::lround(static_cast(m_netGold) + kg); + m_netGold = static_cast(std::clamp(gold, 0L, static_cast(net::kMaxExtraMass))); m_throttleCap = kg > 0.0f ? m_cr->carrierThrottleCap() : 1.0f; } @@ -2567,9 +2602,8 @@ class RaceScreen final : public Screen { using game::session::CopsAndRobbers; using Type = CopsAndRobbers::Message::Type; const bool host = ctx.netGame->isHost(); - // mmPlayer::UpdateRegen while regeneration is on. - if (m_regen && m_player->sim().regenerate()) - clearVehicleDamage(); + // mmPlayer::UpdateRegen while regeneration is on: the car's input + // carries it (net::kInputRegen; game::NetCarDriver). // mmMultiCR::UpdateGame for the local car, with the others' places. std::vector cars; // mmMultiCR::HitWaterHandler / DropThruCityHandler drop the gold back @@ -2670,7 +2704,7 @@ class RaceScreen final : public Screen { // UpdateBank / UpdateHideout: regeneration, a repaired car. fondleMass(-m_cr->carrierExtraMassKg()); m_regen = true; - m_player->sim().damage.reset(); + netCommand({0, net::CarCommandKind::ClearDamage, {}, 0.0f}); clearVehicleDamage(); m_session->showMessage(s.get(117, "Gold delivered!"), 5.0f, false); } else { @@ -3612,10 +3646,23 @@ class RaceScreen final : public Screen { }; if (m_player && m_vehicleFx) { m_vehicleFx->update(dt, m_player->sim(), vehicleFxContext(m_player->sim())); - if (m_vehicle) + if (m_traceNet && !m_traceNet->isHost()) { + // OpenMM2: the host decides a network client's dents and + // lost parts (updateOwnNetDamage). + (void)m_vehicleFx->takeDamagePoint(); + (void)m_vehicleFx->takeImpacts(); + } else if (m_vehicle) { paint(*m_vehicleFx, *m_vehicle, m_player->sim(), m_player->model().baseName, m_result.config.multiplayer ? &m_netDamage.own() : nullptr); + } } + // OpenMM2: the network host's simulated players' cars, whose damage + // everyone is sent. + for (auto& [id, rv] : m_remotes) + if (rv.simulated && rv.placed && rv.fx && rv.renderer && rv.sim) { + rv.fx->update(dt, rv.sim->sim(), vehicleFxContext(rv.sim->sim())); + paint(*rv.fx, *rv.renderer, rv.sim->sim(), rv.sim->model().baseName, &m_netDamage.player(id)); + } for (auto& o : m_opponents) if (o.fx) { o.fx->update(dt, o.sim->sim(), vehicleFxContext(o.sim->sim())); @@ -3697,13 +3744,11 @@ class RaceScreen final : public Screen { } void playerImpact(const phys::CarImpact& impact) { - // OPENMM2_NET_TRACE: a collision with another player's car. - if (impact.otherBody && m_traceNet && m_traceNet->tracing()) - for (const auto& [id, rv] : m_remotes) - if (rv.sim && &rv.sim->sim().body == impact.otherBody) - m_traceNet->traceImpact( - m_traceNet->localId(), id, impact.position, impact.total, - m_world ? static_cast(m_world->remainder()) * 1000.0 : 0.0); + // OpenMM2: a network client running its samples again has had them. + if (m_netReplaying) + return; + if (m_traceNet) + traceNetImpact(m_traceNet->localId(), impact); // OPENMM2_NET_TRACE // mmMultiCR::ImpactCallback (from vehCarDamage::ApplyImpact's damaging // branch): a hit from another player's car, its summed total. if (m_cr && impact.otherBody && impact.damaging) @@ -3714,7 +3759,8 @@ class RaceScreen final : public Screen { m_impacts.push_back({impact.soundStrength, impact.audioId, impact.position}); if (m_vehicleFx) m_vehicleFx->impact(impact, m_player->sim()); - if (m_result.config.multiplayer && impact.damaging) // OpenMM2: the others see it too + // OpenMM2: the others see it too (the host's: it decides every car's). + if (m_result.config.multiplayer && impact.damaging && m_traceNet && m_traceNet->isHost()) m_netDamage.own().impact(m_netStateTime, game::damageImpactOf(impact, m_player->sim())); if (impact.damaging) { ++(impact.otherIsBody ? m_vehicleImpacts : m_objectImpacts); @@ -3857,12 +3903,12 @@ class RaceScreen final : public Screen { 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) { + // The other players as the host's traffic and police see them: the cars + // it simulates (updateHostCars) with their players' controls. + void collectNetPlayers(Context&) { m_netTrafficPlayers.clear(); m_netTrackedPlayers.clear(); - for (const auto& rc : ctx.netGame->remoteCars()) { + for (const auto& rc : m_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) @@ -4407,42 +4453,15 @@ class RaceScreen final : public Screen { } } - void sendLocalState(Context& ctx) { - const auto& sim = m_player->sim(); - net::VehicleControls controls; - controls.steering = m_lastPedals.steering; - controls.throttle = m_lastPedals.accelerator; - controls.brake = m_lastPedals.brake; - controls.handbrake = m_lastPedals.handbrake; - controls.gear = static_cast(sim.trans.getCurrentGear()); - std::uint8_t flags = 0; - // The simulated pose carries no lights (the drawn poses get them). - if (carLights()) - flags |= net::kVehicleHeadlights; - if (m_pose.brakeLights) - flags |= net::kVehicleBrakeLights; - if (sim.damage.wrecked()) - 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, age); - ctx.netGame->traceFrame(m_pose.body, sim.body.ics.frameVelocity, age, m_remoteCars); - } - - // mmNetObject: every other player's car is a vehCar of the level (built - // with the polygonal bound, a vpcop on vpmustang99's tuning, towing its - // trailer except in multiplayer cruise and Cops and Robbers), declared + // mmNetObject: every other player's car is a vehCar of the level, declared // each frame as a type-3 mover (its room and the neighbours stay active) - // 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 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. + // with its trailer. MM2 drove it on every machine with its player's + // inputs and pulled it toward the received positions (mmNetObject:: + // Predict, Update). OpenMM2's host simulates it from its player's inputs + // (updateHostCars); a client places it at the host's interpolated states + // as a kinematic body, which its own car collides with as with a body + // moving at their velocity (it does not give way there: the host's + // collision is the one that counts). // // 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 @@ -4453,11 +4472,14 @@ class RaceScreen final : public Screen { void updateRemoteCars(Context& ctx, float dt) { if (!multiplayer(ctx) || !m_world) return; - m_remoteCars = ctx.netGame->remoteCars(static_cast(m_world->remainderAfter(dt)) * 1000.0); + if (ctx.netGame->isHost()) { + updateHostCars(ctx, dt); // OpenMM2: the host simulates them + return; + } + const double lagMs = static_cast(m_world->remainderAfter(physicsDt(ctx, dt))) * 1000.0; + m_remoteCars = ctx.netGame->remoteCars(lagMs); // The session time the simulation's state will belong to after this - // frame's steps (as sendLocalState stamps the car): the damage this - // machine's cars take is stamped with it. - const double lagMs = static_cast(m_world->remainderAfter(dt)) * 1000.0; + // frame's steps. m_netStateTime = static_cast(std::max(0.0, ctx.netGame->frameTime() - lagMs)); // Drawn where the rest of the scene is, a sample behind the frame // (game::StepHistory): alpha x step = the remainder. @@ -4467,56 +4489,14 @@ class RaceScreen final : public Screen { m_remoteDrawn[rc.id] = rc.transform; // A player who quit the race takes its car out of it. std::erase_if(m_remoteCars, [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; for (const auto& rc : m_remoteCars) { if (!rc.hasState) continue; present.push_back(rc.id); RemoteVehicle& rv = m_remotes[rc.id]; - 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, vehicle, &error, {}, true, towing); - if (!rv.sim) { - // Tried once per car and paint job, not again every frame. - log::warn("race: network car {}: {}", vehicle, error); - continue; - } - auto& sim = rv.sim->sim(); - sim.options.weatherFriction = weatherFriction(); - sim.setPolygonalBound(true); // vehCar::Init(..., true) in mmNetObject::Init - sim.body.kinematic = true; - sim.body.resetCollider(); - rv.sim->addTo(*m_world); - rv.sim->reset(rc.transform); - rv.renderer = std::make_unique(ctx.device(), *m_textures, *m_models, - rv.sim->model(), rv.color); - setupVehicleRenderer(ctx, *rv.renderer); - if (const auto* trailer = rv.sim->trailerModel()) { - rv.trailer = std::make_unique(ctx.device(), *m_textures, *m_models, - *trailer, rv.color, "TRAILER", "TWHL"); - setupVehicleRenderer(ctx, *rv.trailer); - } - // 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)); - // ... and its vehCarDamage (vehCar::Init): OpenMM2 replays the - // owner's damage on it (updateNetCarDamage). - rv.fx = loadVehicleFx(ctx, vehicle, rv.sim->model(), *rv.renderer); - } + if (rv.base != rc.car.vehicle || rv.color != rc.car.color) + loadRemoteCar(ctx, rc.id, rv, rc.car, false); if (!rv.sim) continue; auto& sim = rv.sim->sim(); @@ -4532,8 +4512,7 @@ class RaceScreen final : public Screen { // the shared traffic and police): as a still wall, a car closing // on it at 1 m/s while both did 30 m/s lost 12.6 m/s, a rear tap // threw the chaser back and rubbing side by side dragged as a - // barrier would. MM2's network car was a simulated vehCar with a - // velocity of its own. + // barrier would. sim.body.kinematicMoves = true; sim.body.kinematicVelocity = rc.velocity; sim.body.kinematicSpin = rc.angularVelocity; @@ -4548,6 +4527,12 @@ class RaceScreen final : public Screen { rv.sim->model().baseName, netCarDrawn(rc.id).value_or(rc.transform), rc.velocity, rc.damage, rc.time, rv.fresh, rv.impacts); } + dropRemoteCars(present); + updateOwnNetDamage(ctx); + } + + // The cars of players no longer in the race leave it. + void dropRemoteCars(const std::vector& present) { for (auto it = m_remotes.begin(); it != m_remotes.end();) { if (std::find(present.begin(), present.end(), it->first) == present.end()) { if (it->second.sim) @@ -4555,6 +4540,7 @@ class RaceScreen final : public Screen { if (it->second.audio) it->second.audio->stop(); m_netDamage.replica().forget(game::DamageReplica::playerKey(it->first)); + m_netDamage.forgetPlayer(it->first); it = m_remotes.erase(it); } else { ++it; @@ -4562,6 +4548,416 @@ class RaceScreen final : public Screen { } } + // mmNetObject::Init: another player's car is a vehCar of the level, built + // with the polygonal bound (a vpcop on vpmustang99's tuning, towing its + // trailer except in multiplayer cruise and Cops and Robbers). On a client + // it is drawn from the host's states, a kinematic body moving at their + // velocity; on the host (`simulated`, OpenMM2) it is simulated from its + // player's inputs like this machine's car. + void loadRemoteCar(Context& ctx, std::uint8_t id, RemoteVehicle& rv, const game::NetCar& car, + bool simulated) { + if (rv.sim) + rv.sim->removeFrom(*m_world); + if (rv.audio) + rv.audio->stop(); + game::HostInputQueue inputs = std::move(rv.inputs); + rv = {}; + rv.inputs = std::move(inputs); + rv.base = car.vehicle; + rv.color = car.color; + rv.simulated = simulated; + const auto mode = m_result.config.mode; + const bool towing = mode != game::GameMode::Cruise && mode != game::GameMode::CopsAndRobbers; + // 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, vehicle, &error, {}, true, towing); + if (!rv.sim) { + // Tried once per car and paint job, not again every frame. + log::warn("race: network car {}: {}", vehicle, error); + return; + } + auto& sim = rv.sim->sim(); + sim.options.weatherFriction = weatherFriction(); + sim.setPolygonalBound(true); // vehCar::Init(..., true) in mmNetObject::Init + if (simulated) { + // As this machine's car (loadVehicle): mmPlayer::Update's input + // overrides, the player's damage, its own random stream. + sim.options.player = true; + sim.ownRandom = true; + sim.randomState = 1; + rv.driver.attach(*rv.sim); + sim.onImpactCallback = [this, id](const phys::CarImpact& impact) { hostCarImpact(id, impact); }; + } else { + sim.body.kinematic = true; + sim.body.resetCollider(); + rv.sim->addTo(*m_world); + } + rv.renderer = std::make_unique(ctx.device(), *m_textures, *m_models, + rv.sim->model(), rv.color); + setupVehicleRenderer(ctx, *rv.renderer); + if (const auto* trailer = rv.sim->trailerModel()) { + rv.trailer = std::make_unique(ctx.device(), *m_textures, *m_models, + *trailer, rv.color, "TRAILER", "TWHL"); + setupVehicleRenderer(ctx, *rv.trailer); + } + // 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)); + // ... and its vehCarDamage (vehCar::Init): the host's own collisions + // of it, or (on a client) the host's record of them replayed. + rv.fx = loadVehicleFx(ctx, vehicle, rv.sim->model(), *rv.renderer); + } + + // --- OpenMM2: the players' cars simulated by the host (game/net/PlayerCars) ------------- + + // A client's simulation runs a little faster or slower to keep the host + // supplied with its inputs (reconcileNetCar). + float physicsDt(Context& ctx, float dt) const { + return multiplayer(ctx) && !ctx.netGame->isHost() ? dt * m_netDilation : dt; + } + + // Host: the clients' inputs, and their cars, before the frame's samples. + void updateHostCars(Context& ctx, float dt) { + const std::uint8_t self = ctx.netGame->localId(); + for (auto& r : ctx.netGame->takePlayerInputs()) + if (r.player != self && !m_netLeft.contains(r.player)) + m_remotes[r.player].inputs.receive(r.msg); + const double lagMs = static_cast(m_world->remainderAfter(dt)) * 1000.0; + m_netStateTime = static_cast(std::max(0.0, ctx.netGame->frameTime() - lagMs)); + m_remoteDrawn.clear(); // updateDrawnPoses, after the samples + const bool damage = !m_session || m_session->playerDamageEnabled(); + std::vector present; + for (const auto& p : ctx.netGame->players()) { + if (p.id == self || m_netLeft.contains(p.id) || !ctx.netGame->playerLoaded(p.id)) + continue; + present.push_back(p.id); + RemoteVehicle& rv = m_remotes[p.id]; + const game::NetCar car = ctx.netGame->playerCar(p.id); + if (!rv.simulated || rv.base != car.vehicle || rv.color != car.color) + loadRemoteCar(ctx, p.id, rv, car, true); + if (!rv.sim) + continue; + if (!rv.placed) { + // Its player's first command puts it where that machine + // started it. + const auto first = rv.inputs.ready() ? rv.inputs.placement() : std::nullopt; + if (!first || !netCommandAllowed(rv, *first)) + continue; + game::NetCarDriver::command(*rv.sim, *first); + rv.lastMoveSeq = first->seq; + rv.sim->addTo(*m_world); + rv.placed = true; + log::info("race: player {}'s car starts at ({:.1f}, {:.1f}, {:.1f})", p.id, first->position.x, + first->position.y, first->position.z); + } + auto& sim = rv.sim->sim(); + sim.body.declare(3, 0x1b); // mmNetObject::Update + if (auto* trailer = rv.sim->trailer()) + trailer->body.declare(3, 0x1b); + sim.setWaterLevel(waterLevelAt(sim.modelMatrix().m3)); + sim.damage.enabled = damage; // EnableRacers / DisableRacers + } + dropRemoteCars(present); + } + + // Host: whether a client's command may move its car: in the city, and + // not more than four times a second (a hostile client would teleport). + bool netCommandAllowed(const RemoteVehicle& rv, const net::CarCommand& c) const { + if (c.kind != net::CarCommandKind::ResetTo && c.kind != net::CarCommandKind::RespawnAt) + return true; + if (rv.placed && c.seq < rv.lastMoveSeq + 15) + return false; + const Aabb& city = m_city->psdl.bounds; + constexpr float kMargin = 200.0f; + const Vec3& at = c.position; + return at.x >= city.min.x - kMargin && at.x <= city.max.x + kMargin && at.y >= city.min.y - kMargin && + at.y <= city.max.y + kMargin && at.z >= city.min.z - kMargin && at.z <= city.max.z + kMargin; + } + + // Host: a simulated player's car's impact (vehCarDamage::ApplyImpact): + // its sound, sparks and damage, which everyone is sent. + void hostCarImpact(std::uint8_t id, const phys::CarImpact& impact) { + const auto it = m_remotes.find(id); + if (it == m_remotes.end() || !it->second.sim) + return; + RemoteVehicle& rv = it->second; + if (impact.sound && rv.impacts.size() < 16) + rv.impacts.push_back({impact.soundStrength, impact.audioId, impact.position}); + if (rv.fx) + rv.fx->impact(impact, rv.sim->sim()); + if (impact.damaging) + m_netDamage.player(id).impact(m_netStateTime, game::damageImpactOf(impact, rv.sim->sim())); + traceNetImpact(id, impact); + } + + // OPENMM2_NET_TRACE: a collision between two players' cars. + void traceNetImpact(std::uint8_t self, const phys::CarImpact& impact) { + if (!impact.otherBody || !m_traceNet || !m_traceNet->tracing()) + return; + std::optional other; + if (m_player && impact.otherBody == &m_player->sim().body) + other = m_traceNet->localId(); + for (const auto& [id, rv] : m_remotes) + if (rv.sim && &rv.sim->sim().body == impact.otherBody) + other = id; + if (other && *other != self) + m_traceNet->traceImpact(self, *other, impact.position, impact.total, + m_world ? static_cast(m_world->remainder()) * 1000.0 : 0.0); + } + + // The sample hooks (phys::World::setSampleHooks), every machine of a + // network race: each player's car takes its input for the sample. + void beforeNetSample() { + if (!m_player || !m_traceNet) + return; + net::CarInputFrame in = m_netInput; + in.events = std::exchange(m_netKeys, 0); // the keys go with the first sample after them + if (m_traceNet->isHost()) { + if (m_netDriver.apply(*m_player, in)) + clearVehicleDamage(); // mmPlayer::ResetDamage + for (auto& [id, rv] : m_remotes) { + if (!rv.simulated || !rv.placed || !rv.sim) + continue; + auto next = rv.inputs.next(); + if (!next) + continue; + for (const auto& c : next->commands) { + if (!netCommandAllowed(rv, c)) + continue; + game::NetCarDriver::command(*rv.sim, c); + if (c.kind == net::CarCommandKind::ResetTo || c.kind == net::CarCommandKind::RespawnAt) + rv.lastMoveSeq = c.seq; + ++rv.resets; + hostCarReset(id, rv); + } + // Nobody goes before the start, whatever its input says. + if (m_netHeld) + next->frame.flags |= net::kInputHeld; + if (rv.driver.apply(*rv.sim, next->frame)) + hostCarReset(id, rv); + rv.input = next->frame; + } + return; + } + // A client: its car's first sample puts it where this machine + // started it, on the host too. + if (m_prediction.nextSeq() == 1) + m_prediction.command(*m_player, {0, net::CarCommandKind::ResetTo, m_player->sim().resetPos(), + m_player->sim().resetRotation}); + m_prediction.beginSample(*m_player, m_netDriver, in); + } + void afterNetSample() { + if (m_player && m_traceNet && !m_traceNet->isHost()) + m_prediction.endSample(*m_player, m_netDriver); + } + + // Host: a simulated player's car was reset or its damage cleared + // (vehCar::ClearDamage): its damage starts again on every machine. + void hostCarReset(std::uint8_t id, RemoteVehicle& rv) { + if (rv.fx) + rv.fx->reset(); + if (rv.renderer) + rv.renderer->resetDamage(); + m_netDamage.player(id).reset(m_netStateTime); + } + + // Client: the host's states of this machine's car since the last frame: + // the newest corrects the prediction when they differ; how many of its + // inputs the host had in hand sets how fast the samples run. + void reconcileNetCar(Context& ctx) { + if (!multiplayer(ctx) || ctx.netGame->isHost() || !m_player || !m_world) + return; + const auto updates = ctx.netGame->takeOwnCarStates(); + const net::OwnCarState* newest = nullptr; + std::uint32_t ack = 0; + for (const auto& u : updates) { + m_netWaiting.push_back(u.waiting); + if (u.hasOwn && u.ack > ack) { + newest = &u.own; + ack = u.ack; + } + } + while (m_netWaiting.size() > 20) // a second of reports + m_netWaiting.pop_front(); + // The host keeps a sample of this car's inputs in hand at the least + // (HostInputQueue): below that the samples run 3% faster (the inputs + // reach it sooner), above three 2% slower. + if (!m_netWaiting.empty()) { + const std::int32_t least = *std::ranges::min_element(m_netWaiting); + const float target = least < 1 ? 1.03f : (least > 3 ? 0.98f : 1.0f); + m_netDilation += (target - m_netDilation) * std::min(1.0f, m_frameDt * 2.0f); + } + if (!newest) + return; + const game::VehiclePose before = drawnPose(game::Drawn::Player, 0, *m_player); + m_netReplaying = true; + const auto c = m_prediction.acknowledge(*m_player, m_netDriver, *m_world, ack, *newest, [this] { + // What the drawing blends from: the car before its last sample. + const std::uint32_t resets = m_player->sim().resets; + m_drawnPhys.record(game::drawnKey(game::Drawn::Player, 0), m_player->pose(), resets); + if (m_player->trailer()) + m_drawnPhys.record(game::drawnKey(game::Drawn::PlayerTrailer, 0), m_player->trailerPose(), + resets); + }); + m_netReplaying = false; + if (!c.corrected) + return; + const game::VehiclePose after = drawnPose(game::Drawn::Player, 0, *m_player); + m_correction.add(m_correction.apply(before.body), after.body); + m_pose = m_player->pose(); + m_trailerPose = m_player->trailerPose(); + ctx.netGame->traceCorrection(ack, c.replayed, c.moved, c.velocityError, + m_correction.offset() == 0.0f); + static const bool verbose = std::getenv("OPENMM2_DEBUG_NETCARS") != nullptr; + if (verbose) + log::info("netcars: correction at sample {} ({} run again): position {:.4f} m, " + "velocity {:.4f} m/s, rotation {:.5f}{}{}{}", + ack, c.replayed, c.positionError, c.velocityError, c.rotationError, + c.damage ? ", damage" : "", c.held ? ", held" : "", c.gear ? ", gear" : ""); + } + + // After the frame's samples: the host sends each client its states, a + // client the inputs the host has not acknowledged. + void afterNetSamples(Context& ctx) { + if (!multiplayer(ctx) || !m_player) + return; + const std::uint64_t now = net::monotonicMs(); + if (ctx.netGame->isHost()) { + collectHostCars(ctx); + if (now - m_netCarsSentAt >= kNetTrafficIntervalMs) + sendNetCars(ctx, now); + } else if (m_frameSteps > 0) { + if (const auto msg = m_prediction.message()) { + ctx.netGame->sendPlayerInput(*msg); + ++m_netInputsSent; + m_netInputBytes += net::encodeMessage(*msg).size(); + } + } + const double age = m_world ? static_cast(m_world->remainder()) * 1000.0 : 0.0; + ctx.netGame->traceFrame(m_pose.body, m_player->sim().body.ics.frameVelocity, age, m_remoteCars); + logNetCars(ctx, now); + } + + // Host: the simulated players' cars for the rules, the HUD and the + // drawing, as the frame's samples left them. + void collectHostCars(Context& ctx) { + m_remoteCars.clear(); + for (const auto& p : ctx.netGame->players()) { + const auto it = m_remotes.find(p.id); + if (it == m_remotes.end() || !it->second.simulated || !it->second.placed || !it->second.sim) + continue; + const RemoteVehicle& rv = it->second; + const net::VehicleSnapshot s = game::carSnapshot(*rv.sim, rv.input); + game::NetRemoteCar rc; + rc.id = p.id; + rc.name = p.name; + rc.car = ctx.netGame->playerCar(p.id); + rc.time = m_netStateTime; + rc.transform = rv.sim->sim().modelMatrix(); + rc.velocity = s.linearVelocity; + rc.angularVelocity = s.angularVelocity; + rc.controls = s.controls; + rc.damage = s.damage; + rc.flags = s.flags; + rc.hasState = true; + m_remoteCars.push_back(std::move(rc)); + } + } + + // Host: each client's CarStates: the last input applied to its car and + // the car's state after it, and every other player's car. + void sendNetCars(Context& ctx, std::uint64_t now) { + m_netCarsSentAt = now; + std::vector> cars; + cars.emplace_back(ctx.netGame->localId(), game::carSnapshot(*m_player, m_netInput)); + for (const auto& [id, rv] : m_remotes) + if (rv.simulated && rv.placed && rv.sim) + cars.emplace_back(id, game::carSnapshot(*rv.sim, rv.input)); + for (auto& [id, rv] : m_remotes) { + if (!rv.simulated || !rv.sim) + continue; + net::CarStatesMsg msg; + msg.time = m_netStateTime; + msg.ack = rv.inputs.lastApplied(); + msg.waiting = rv.inputs.takeLeastWaiting(); + msg.hasOwn = rv.placed && msg.ack != 0; + if (msg.hasOwn) + msg.own = game::ownCarState(*rv.sim, rv.resets); + for (const auto& c : cars) + if (c.first != id) + msg.cars.push_back(c); + m_netStatesBytes += ctx.netGame->sendCarStates(id, msg); + ++m_netStatesSent; + } + } + + // Every 10 s with OPENMM2_DEBUG_NETCARS: what the players' cars cost and + // how the prediction fares. + void logNetCars(Context& ctx, std::uint64_t now) { + static const bool verbose = std::getenv("OPENMM2_DEBUG_NETCARS") != nullptr; + if (m_netStatsAt == 0) + m_netStatsAt = now; + if (now - m_netStatsAt < 10000) + return; + const double seconds = static_cast(now - m_netStatsAt) / 1000.0; + m_netStatsAt = now; + if (verbose && ctx.netGame->isHost()) { + std::string queues; + for (const auto& [id, rv] : m_remotes) + if (rv.simulated) + queues += std::format(" player {}: {} inputs missed;", id, rv.inputs.missed()); + log::info("netcars: host sent {} states ({:.0f} B/s);{}", m_netStatesSent, + static_cast(m_netStatesBytes) / seconds, queues); + } else if (verbose) { + const auto& st = m_prediction.stats(); + log::info("netcars: client sent {} inputs ({:.0f} B/s); {} acks, {} corrections, {} samples " + "replayed, {} too old; running at {:.3f}", + m_netInputsSent, static_cast(m_netInputBytes) / seconds, st.acks, + st.corrections, st.replayedSamples, st.unreplayable, m_netDilation); + } + m_netStatesBytes = m_netStatesSent = m_netInputBytes = m_netInputsSent = 0; + } + + // mmPlayer::Reset, the HitWaterHandlers and vehCar::ClearDamage on this + // machine's car. A client's command reaches the host with its inputs and + // happens there at the same sample. + void netCommand(net::CarCommand c) { + if (!m_player) + return; + if (m_traceNet && !m_traceNet->isHost() && m_world) + m_prediction.command(*m_player, c); + else + game::NetCarDriver::command(*m_player, c); + } + + // Client: this machine's car's damage as the host decides it (its dents + // and lost parts; the sparks and sounds it predicted itself). + void updateOwnNetDamage(Context& ctx) { + if (!m_player || !m_vehicle) + return; + auto& sim = m_player->sim(); + game::DamageTarget t; + t.renderer = m_vehicle.get(); + t.body = m_drawPose.body; + t.speed = sim.speed(); + t.texelRadius = sim.damage.params.textelDamageRadius; + const std::string base = m_player->model().baseName; + t.eject = [&](const game::VehicleRenderer::Breakable& b, float speed) { + return ejectCarPart(*m_vehicle, base, b, t.body, speed, sim.body.room); + }; + t.sound = [](const Vec3&, float, int) {}; + const double now = ctx.netGame->frameTime(); + const auto key = game::DamageReplica::playerKey(ctx.netGame->localId()); + m_netDamage.update(key, t, now, now, m_ownDamageFresh); + m_ownDamageFresh = false; + } + void drawRemoteCars(Context& ctx, const game::Camera& camera) { if (!multiplayer(ctx)) return; @@ -4599,7 +4995,7 @@ class RaceScreen final : public Screen { void clearVehicleDamage() { if (m_vehicle) m_vehicle->resetDamage(); - if (m_result.config.multiplayer) + if (m_result.config.multiplayer && (!m_traceNet || m_traceNet->isHost())) m_netDamage.own().reset(m_netStateTime); } @@ -4669,7 +5065,7 @@ class RaceScreen final : public Screen { fx.smoke().count(), fx.sparks().count()); }; for (auto& [id, rv] : m_remotes) - if (rv.fx && rv.sim) + if (rv.fx && rv.sim && !rv.simulated) update("player", id, *rv.fx, *rv.sim, netCarDrawn(id)); for (auto& [id, cop] : m_netCops) if (cop.fx && cop.sim) @@ -4716,7 +5112,7 @@ class RaceScreen final : public Screen { return; ++m_debugRespawns; log::info("race: debug respawn at session t {}", ctx.netGame->sessionTime()); - m_player->reset(); + netCommand({0, net::CarCommandKind::Reset, {}, 0.0f}); if (m_vehicleFx) m_vehicleFx->reset(); clearVehicleDamage(); @@ -4792,15 +5188,38 @@ class RaceScreen final : public Screen { // the water it is left alone. const bool over = (m_session && m_session->playerHold() == game::session::PlayerHold::FinishBrake) || m_crFinished; - // mmGame::UpdateSteeringBrakes, network games: in a forward gear the - // throttle is capped by +0x40c (the gold's weight; 1 otherwise). - if (multiplayer(ctx) && m_player->sim().trans.getCurrentGear() > 0) - pedals.accelerator = std::clamp(pedals.accelerator, 0.0f, m_throttleCap); + const bool held = (m_session && m_session->playerHeld()) || (multiplayer(ctx) && m_netHeld); + if (multiplayer(ctx)) { + // OpenMM2: in a network race the car takes this input once a + // sample (game::NetCarDriver, which applies the rest of this + // function there), as the host takes it for this car. + m_netInput = game::inputFrame(pedals); + m_netInput.flags = static_cast( + (held ? net::kInputHeld : 0) | (over ? net::kInputFinished : 0) | + (m_netAutomatic ? net::kInputAutomatic : 0) | + (m_controlOptions.autoReverse ? net::kInputAutoReverse : 0) | + (hornDown(ctx) ? net::kInputHorn : 0) | (carLights() ? net::kInputHeadlights : 0) | + (m_regen ? net::kInputRegen : 0)); + m_netInput.extraMass = m_netGold; + m_netInput.throttleCap = + static_cast(std::clamp(std::lround(m_throttleCap * 255.0f), 0L, 255L)); + if (m_player->sim().trans.getCurrentGear() > 0) + pedals.accelerator = std::clamp(pedals.accelerator, 0.0f, m_throttleCap); + if (over) + pedals = {}; + else if (held) + pedals.brake = 1.0f; + m_lastPedals = pedals; + updateForceFeedback(ctx, dt, false); + if (m_player->sim().modelMatrix().m3.y < std::min(-50.0f, m_city->psdl.bounds.min.y) - 30.0f) + netCommand({0, net::CarCommandKind::Reset, {}, 0.0f}); + return; + } m_player->sim().raceFinished = over; if (over) { pedals = {}; m_player->drive(pedals); - } else if ((m_session && m_session->playerHeld()) || (multiplayer(ctx) && m_netHeld)) { + } else if (held) { // (mmMultiRoam says "Go!" and lets the car go two updates after // its own load, with no shared start: NetRaceStart never holds // a cruise.) @@ -4890,7 +5309,18 @@ class RaceScreen final : public Screen { m_mirror.toggle(); m_cams.setViewSetting(game::ViewSetting::Mirror); } - if (m_player) { + if (m_player && multiplayer(ctx)) { + // OpenMM2: the car takes them with its next sample's input + // (game::NetCarDriver), on the host too. + if (pressed(Action::Transmission)) + m_netAutomatic = !m_netAutomatic; + if (pressed(Action::ShiftUp)) + m_netKeys |= net::kInputShiftUp; + if (pressed(Action::ShiftDown)) + m_netKeys |= net::kInputShiftDown; + if (pressed(Action::Reverse)) + m_netKeys |= net::kInputReverse; + } else if (m_player) { auto& trans = m_player->sim().trans; auto& pedals = m_player->controls(); if (pressed(Action::Transmission)) { @@ -5330,6 +5760,15 @@ class RaceScreen final : public Screen { std::unique_ptr fx; std::vector impacts; // replayed impact sounds this frame bool fresh = true; // its damage record not shown yet + // Host (OpenMM2, game/net/PlayerCars): the car simulated from its + // player's inputs, placed where the player's first command puts it. + bool simulated = false; + bool placed = false; + game::HostInputQueue inputs; + game::NetCarDriver driver; + net::CarInputFrame input; // the last one applied + std::uint32_t resets = 0; // the commands carried out + std::uint32_t lastMoveSeq = 0; // the sample of the last command that moved it }; std::map m_remotes; // OpenMM2: the network cars' damage (game/net/DamageSync): this player's @@ -5348,7 +5787,26 @@ class RaceScreen final : public Screen { 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 - game::NetGame* m_traceNet = nullptr; // the race's session, for OPENMM2_NET_TRACE's impacts + game::NetGame* m_traceNet = nullptr; // the race's session (the sample hooks, OPENMM2_NET_TRACE) + // OpenMM2: the host simulates every player's car (game/net/PlayerCars, + // docs/multiplayer.md "Players' cars"). This machine's car takes its + // input once a sample, as every machine applies it; a client runs its + // own car ahead on it and the host's states correct it. + net::CarInputFrame m_netInput; // this frame's input for this machine's car + std::uint8_t m_netKeys = 0; // gearbox keys pressed since the last sample + bool m_netAutomatic = true; // the gearbox switch (TRANSMISSION) + std::uint16_t m_netGold = 0; // Cops and Robbers: the gold's mass on this car (kg) + game::NetCarDriver m_netDriver; // this machine's car + game::CarPrediction m_prediction; // client + game::CorrectionBlend m_correction; // client: what corrections moved, drawn away + bool m_netReplaying = false; // client: samples run again (no sounds or effects) + bool m_ownDamageFresh = true; // client: its car's damage record not shown yet + float m_netDilation = 1.0f; // client: the rate its samples run at + std::deque m_netWaiting; // client: the host's latest counts of its inputs in hand + std::uint64_t m_netCarsSentAt = 0; // host: the last CarStates + std::uint64_t m_netStatsAt = 0; // the last log of the statistics + std::uint64_t m_netStatesBytes = 0, m_netStatesSent = 0; // host: sent since then + std::uint64_t m_netInputBytes = 0, m_netInputsSent = 0; // client: sent since then std::optional m_netStart; // from the end of the loading (updateNetStart) bool m_netHeld = true; // its car hold std::optional m_netToGo; // its countdown (Session::setNetStart) diff --git a/src/game/net/DamageSync.cpp b/src/game/net/DamageSync.cpp index 8aaa687..4b75786 100644 --- a/src/game/net/DamageSync.cpp +++ b/src/game/net/DamageSync.cpp @@ -226,11 +226,19 @@ bool DamageReplica::receive(std::uint8_t from, const net::VehicleDamageEvent& e, ++m_stats.events; std::uint32_t key = 0; if (e.subject == net::kDamageOwnCar) { - if (from >= net::kMaxPlayers) { + // The host decides every car's damage (protocol 5): a player's word + // on its own car is refused. + if (from != net::kHostPlayerId) { ++m_stats.refused; return false; } key = playerKey(from); + } else if (e.subject >= net::kDamagePlayerCar) { + if (from != net::kHostPlayerId || e.subject > net::kDamageLastSubject) { + ++m_stats.refused; + return false; + } + key = playerKey(static_cast(e.subject - net::kDamagePlayerCar)); } else { // Only the host runs the shared police. if (from != net::kHostPlayerId || e.subject >= net::kMaxAmbientIds) { @@ -438,10 +446,16 @@ void NetDamage::send(NetGame& net, std::uint64_t nowMs) { } }; send(m_own); + for (auto& [id, recorder] : m_players) + send(recorder); for (auto& [id, recorder] : m_police) send(recorder); } +DamageRecorder& NetDamage::player(std::uint8_t id) { + return m_players.try_emplace(id, static_cast(net::kDamagePlayerCar + id)).first->second; +} + void NetDamage::receive(std::span events, double now) { for (const NetGameEvent& ev : events) { if (static_cast(ev.type) != net::kVehicleDamageEvent) diff --git a/src/game/net/DamageSync.h b/src/game/net/DamageSync.h index 8374e95..9cc3aac 100644 --- a/src/game/net/DamageSync.h +++ b/src/game/net/DamageSync.h @@ -244,6 +244,10 @@ class NetDamage { DamageRecorder& own() { return m_own; } // A shared police car on the host, by its ambient id (created on first use). DamageRecorder& police(std::uint16_t id); + // Another player's car on the host, which simulates it (created on first + // use; protocol 5). + DamageRecorder& player(std::uint8_t id); + void forgetPlayer(std::uint8_t id) { m_players.erase(id); } DamageReplica& replica() { return m_replica; } // Sends what the recorders have (reliable game events to everyone). @@ -269,6 +273,7 @@ class NetDamage { private: DamageRecorder m_own; std::map m_police; + std::map m_players; DamageReplica m_replica; Stats m_stats; std::uint64_t m_statsAt = 0; diff --git a/src/game/net/NetGame.cpp b/src/game/net/NetGame.cpp index ed38bb6..cc49d40 100644 --- a/src/game/net/NetGame.cpp +++ b/src/game/net/NetGame.cpp @@ -8,6 +8,7 @@ #include "net/Session.h" #include +#include #include #include #include @@ -745,13 +746,25 @@ void NetGame::traceFrame(const Mat34& transform, const Vec3& velocity, double st bool NetGame::tracing() const { return m_impl->trace && m_impl->session; } +namespace { + +// The divergence lines' clock: the machine's monotonic clock (every process +// on it shares it), so that traces of several instances on one computer +// compare the same moments. net::monotonicMs counts from each process's start. +double machineMs() { + const auto now = std::chrono::steady_clock::now().time_since_epoch(); + return std::chrono::duration(now).count(); +} + +} // namespace + void NetGame::traceDrawn(std::uint8_t id, bool own, const Mat34& transform, const Vec3& velocity) { std::FILE* f = m_impl->trace.get(); if (!f || !m_impl->session) return; // D wall frameTime id own x y z vx vy vz const Vec3& p = transform.m3; - std::fprintf(f, "D %.3f %.3f %u %d %.3f %.3f %.3f %.3f %.3f %.3f\n", net::monotonicMsPrecise(), + std::fprintf(f, "D %.3f %.3f %u %d %.3f %.3f %.3f %.3f %.3f %.3f\n", machineMs(), m_frameTime, id, own ? 1 : 0, p.x, p.y, p.z, velocity.x, velocity.y, velocity.z); } @@ -761,7 +774,7 @@ void NetGame::traceImpact(std::uint8_t a, std::uint8_t b, const Vec3& position, if (!f || !m_impl->session) return; // K wall sessionTime a b x y z strength - std::fprintf(f, "K %.3f %.3f %u %u %.3f %.3f %.3f %.1f\n", net::monotonicMsPrecise(), + std::fprintf(f, "K %.3f %.3f %u %u %.3f %.3f %.3f %.1f\n", machineMs(), m_frameTime - stateAgeMs, a, b, position.x, position.y, position.z, strength); } @@ -770,7 +783,7 @@ void NetGame::traceCorrection(std::uint32_t seq, int replayed, const Vec3& dx, f if (!f || !m_impl->session) return; // C wall frameTime seq replayed dx dy dz dv snapped - std::fprintf(f, "C %.3f %.3f %u %d %.4f %.4f %.4f %.4f %d\n", net::monotonicMsPrecise(), m_frameTime, seq, + std::fprintf(f, "C %.3f %.3f %u %d %.4f %.4f %.4f %.4f %d\n", machineMs(), m_frameTime, seq, replayed, dx.x, dx.y, dx.z, dv, snapped ? 1 : 0); } @@ -819,6 +832,25 @@ void NetGame::sendLeftRace() { std::vector NetGame::takeGameEvents() { return std::exchange(m_gameEvents, {}); } +// --- The players' cars ---------------------------------------------------------------------- + +void NetGame::sendPlayerInput(const net::PlayerInputMsg& msg) { + if (m_impl->session) + m_impl->session->sendPlayerInput(msg); +} + +std::vector NetGame::takePlayerInputs() { + return m_impl->session ? m_impl->session->takePlayerInputs() : std::vector{}; +} + +std::size_t NetGame::sendCarStates(std::uint8_t playerId, const net::CarStatesMsg& msg) { + return m_impl->session ? m_impl->session->sendCarStates(playerId, msg) : 0; +} + +std::vector NetGame::takeOwnCarStates() { + return m_impl->session ? m_impl->session->takeOwnCarStates() : std::vector{}; +} + // --- Shared ambient traffic -------------------------------------------------------------- bool NetGame::sharedTraffic() const { diff --git a/src/game/net/NetGame.h b/src/game/net/NetGame.h index 551041a..86935c5 100644 --- a/src/game/net/NetGame.h +++ b/src/game/net/NetGame.h @@ -13,6 +13,7 @@ #include "net/AmbientState.h" #include "net/Discovery.h" #include "net/Protocol.h" +#include "net/Session.h" #include "net/Snapshot.h" #include "net/Transport.h" @@ -293,7 +294,8 @@ class NetGame { void traceFrame(const Mat34& transform, const Vec3& velocity, double stateAgeMs, const std::vector& cars); // The same trace's divergence lines (docs/review/multiplayer-desync-cars.md; - // `netprobe syncreport` reads them): every player's car as this frame + // `netprobe syncreport` reads them), stamped with the machine's monotonic + // clock (shared by every process on it): every player's car as this frame // draws it (`own`: this machine's player), a collision between two // players' cars in this machine's simulation, and a correction of this // player's car by the host's state (`dx`: how far the car moved). @@ -302,6 +304,16 @@ class NetGame { void traceImpact(std::uint8_t a, std::uint8_t b, const Vec3& position, float strength, double stateAgeMs); void traceCorrection(std::uint32_t seq, int replayed, const Vec3& dx, float dv, bool snapped); + // --- The players' cars, simulated by the host (net/PlayerCars.h) ------------------- + // Client: this machine's car's inputs. + void sendPlayerInput(const net::PlayerInputMsg& msg); + // Host: the clients' inputs received since the last call. + std::vector takePlayerInputs(); + // Host: one client's states; returns the bytes sent. + std::size_t sendCarStates(std::uint8_t playerId, const net::CarStatesMsg& msg); + // Client: the host's word on this machine's car since the last call. + std::vector takeOwnCarStates(); + // --- 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). diff --git a/src/net/VehicleDamage.h b/src/net/VehicleDamage.h index 9dad166..b89b940 100644 --- a/src/net/VehicleDamage.h +++ b/src/net/VehicleDamage.h @@ -40,8 +40,13 @@ namespace mm2::net { inline constexpr std::uint16_t kVehicleDamageEvent = static_cast(GameEventType::Custom) + 32; // Whose car: the sender's own, or (from the host only) a shared police car -// by its ambient id. +// by its ambient id, or (from the host only, protocol 5) a player's car, +// kDamagePlayerCar + the player's id: the host simulates every player's car +// and decides its damage (docs/multiplayer.md, "Players' cars"). inline constexpr std::uint16_t kDamageOwnCar = static_cast(kMaxAmbientIds); +inline constexpr std::uint16_t kDamagePlayerCar = kDamageOwnCar + 1; +inline constexpr std::uint16_t kDamageLastSubject = + static_cast(kDamagePlayerCar + kMaxPlayers - 1); inline constexpr std::size_t kMaxDamagePatches = 16; // per event inline constexpr std::size_t kMaxDamageImpacts = 8; // per event @@ -93,7 +98,7 @@ inline float damageExp(float v) { return std::exp2(v) - 1.0f; } template bool serialize(S& s, VehicleDamageEvent& e) { std::int32_t subject = e.subject, first = e.first; - s.ranged(subject, 0, static_cast(kDamageOwnCar)); + s.ranged(subject, 0, static_cast(kDamageLastSubject)); e.subject = static_cast(subject); s.u8(e.epoch); s.u32(e.time); diff --git a/tests/game/test_damage_sync.cpp b/tests/game/test_damage_sync.cpp index 6cd1c33..1b192ba 100644 --- a/tests/game/test_damage_sync.cpp +++ b/tests/game/test_damage_sync.cpp @@ -201,6 +201,13 @@ TEST(DamageSync, RecorderKeepsToItsBudget) { // --- DamageReplica ------------------------------------------------------------------------- +// The host's word on player `player`'s car (protocol 5: the host decides +// every car's damage), as the replica receives it. +bool fromHost(DamageReplica& rep, std::uint8_t player, net::VehicleDamageEvent e, double arrival) { + e.subject = static_cast(net::kDamagePlayerCar + player); + return rep.receive(net::kHostPlayerId, e, arrival); +} + // Each entry shows when the car is drawn at the time it happened on its // owner's machine; the sparks, shards, sound and flying parts while fresh. TEST(DamageSync, ReplicaAppliesEachEntryAtItsTime) { @@ -212,7 +219,7 @@ TEST(DamageSync, ReplicaAppliesEachEntryAtItsTime) { e.impacts[0].delay = 20; e.parts = 1u << 9; e.partsDelay = 80; - ASSERT_TRUE(rep.receive(1, overTheWire(e), 10050.0)); + ASSERT_TRUE(fromHost(rep, 1, overTheWire(e), 10050.0)); const auto key = DamageReplica::playerKey(1); EXPECT_TRUE(rep.advance(key, 9990.0, 10050.0).empty()); @@ -250,7 +257,7 @@ TEST(DamageSync, ReplicaWaitsAtMostTheHoldAndSkipsStaleEffects) { e.patches = {{0, {0.1f, 0.1f, 0.1f}, 1}}; e.impacts = {sampleImpact()}; e.parts = 1u; - ASSERT_TRUE(rep.receive(2, e, 1000.0)); + ASSERT_TRUE(fromHost(rep, 2, e, 1000.0)); const auto key = DamageReplica::playerKey(2); EXPECT_TRUE(rep.advance(key, 1000.0, 2999.0).empty()); auto a = rep.advance(key, 1000.0, 3000.0); @@ -264,7 +271,7 @@ TEST(DamageSync, ReplicaWaitsAtMostTheHoldAndSkipsStaleEffects) { e.epoch = 0; e.first = 1; e.parts = 3u; - ASSERT_TRUE(rep.receive(2, e, 5100.0)); + ASSERT_TRUE(fromHost(rep, 2, e, 5100.0)); a = rep.advance(key, 7000.0, 5200.0); EXPECT_EQ(a.patches.size(), 1u); EXPECT_TRUE(a.impacts.empty()); @@ -281,7 +288,7 @@ TEST(DamageSync, ReplicaClearsTheCarOnAReset) { rec.patch(1000, {0.1f, 0.1f, 0.1f}, 1); rec.part(1000, "BREAK1"); for (const auto& e : rec.take(0)) - ASSERT_TRUE(rep.receive(3, overTheWire(e), 1000.0)); + ASSERT_TRUE(fromHost(rep, 3, overTheWire(e), 1000.0)); auto a = rep.advance(key, 1100.0, 1100.0); EXPECT_EQ(a.patches.size(), 1u); EXPECT_EQ(a.partsOff, 2u); @@ -289,7 +296,7 @@ TEST(DamageSync, ReplicaClearsTheCarOnAReset) { rec.reset(2000); // mmPlayer::Reset rec.patch(2010, {0.2f, 0.2f, 0.2f}, 2); for (const auto& e : rec.take(500)) - ASSERT_TRUE(rep.receive(3, overTheWire(e), 2000.0)); + ASSERT_TRUE(fromHost(rep, 3, overTheWire(e), 2000.0)); EXPECT_TRUE(rep.advance(key, 1990.0, 2000.0).empty()); // still the old car a = rep.advance(key, 2005.0, 2005.0); EXPECT_TRUE(a.clear); @@ -323,8 +330,8 @@ TEST(DamageSync, ReplicaConvergesAfterALostEvent) { batch(1100, "BREAK1"); batch(1200, "WHL0"); ASSERT_EQ(sent.size(), 3u); - ASSERT_TRUE(rep.receive(4, overTheWire(sent[0]), 1000.0)); - ASSERT_TRUE(rep.receive(4, overTheWire(sent[2]), 1200.0)); // sent[1] lost + ASSERT_TRUE(fromHost(rep, 4, overTheWire(sent[0]), 1000.0)); + ASSERT_TRUE(fromHost(rep, 4, overTheWire(sent[2]), 1200.0)); // sent[1] lost const auto a = rep.advance(key, 1300.0, 1300.0); EXPECT_EQ(a.patches.size(), 4u); EXPECT_EQ(a.partsOff, (1u << 0) | (1u << 1) | (1u << 9)); // all three parts @@ -382,30 +389,39 @@ TEST(DamageSync, ReplicaRefusesHostileEvents) { e.subject = net::kDamageOwnCar; EXPECT_FALSE(rep.receive(net::kInvalidPlayerId, e, 0.0)); EXPECT_FALSE(rep.receive(static_cast(net::kMaxPlayers), e, 0.0)); + // The host decides every player's car's damage: a player's word on its + // own car is refused, the host's on its own and on any player's taken. + EXPECT_FALSE(rep.receive(1, e, 0.0)); + EXPECT_TRUE(rep.receive(net::kHostPlayerId, e, 0.0)); + e.subject = net::kDamagePlayerCar + 3; + EXPECT_FALSE(rep.receive(2, e, 0.0)); // a player claiming another's car + EXPECT_TRUE(rep.receive(net::kHostPlayerId, e, 0.0)); + e.subject = net::kDamageOwnCar; auto bad = e; bad.patches = {{0, {std::numeric_limits::quiet_NaN(), 0.0f, 0.0f}, 0}}; - EXPECT_FALSE(rep.receive(1, bad, 0.0)); + EXPECT_FALSE(fromHost(rep, 1, bad, 0.0)); bad = e; bad.patches = {{0, {0.0f, 100.0f, 0.0f}, 0}}; - EXPECT_FALSE(rep.receive(1, bad, 0.0)); + EXPECT_FALSE(fromHost(rep, 1, bad, 0.0)); bad = e; bad.impacts = {sampleImpact()}; bad.impacts[0].normal = {5.0f, 0.0f, 0.0f}; - EXPECT_FALSE(rep.receive(1, bad, 0.0)); + EXPECT_FALSE(fromHost(rep, 1, bad, 0.0)); bad.impacts[0] = sampleImpact(); bad.impacts[0].speed = std::numeric_limits::infinity(); - EXPECT_FALSE(rep.receive(1, bad, 0.0)); + EXPECT_FALSE(fromHost(rep, 1, bad, 0.0)); bad = e; bad.first = static_cast(net::kMaxDamageRecord); bad.patches = {{0, {}, 0}}; - EXPECT_FALSE(rep.receive(1, bad, 0.0)); + EXPECT_FALSE(fromHost(rep, 1, bad, 0.0)); bad = e; bad.patches.assign(net::kMaxDamagePatches + 1, {}); - EXPECT_FALSE(rep.receive(1, bad, 0.0)); + EXPECT_FALSE(fromHost(rep, 1, bad, 0.0)); bad = e; bad.parts = 1u << net::kDamagePartCount; - EXPECT_FALSE(rep.receive(1, bad, 0.0)); - EXPECT_TRUE(rep.receive(1, e, 0.0)); + EXPECT_FALSE(fromHost(rep, 1, bad, 0.0)); + EXPECT_TRUE(fromHost(rep, 1, e, 0.0)); + EXPECT_FALSE(rep.receive(1, e, 0.0)); // At most kMaxRecords cars. for (std::uint16_t id = 0; id < net::kMaxAmbientIds; ++id) { @@ -440,7 +456,7 @@ TEST(DamageSync, RecordReachesTheReceiverIntact) { std::uint64_t now = 0; auto pump = [&] { for (const auto& e : rec.take(now)) - ASSERT_TRUE(rep.receive(5, overTheWire(e), t)); + ASSERT_TRUE(fromHost(rep, 5, overTheWire(e), t)); rep.advance(key, t, t); }; for (int i = 0; i < 30; ++i) { @@ -612,7 +628,7 @@ TEST(DamageSync, ReplayedDentsMatchTheOwnersTexelForTexel) { EXPECT_NE(remote.texelDamageState(), before); remote.resetDamage(); for (const auto& e : rec.take(0)) - ASSERT_TRUE(rep.receive(1, overTheWire(e), t)); + ASSERT_TRUE(fromHost(rep, 1, overTheWire(e), t)); game::DamageTarget target; target.renderer = &remote; target.texelRadius = radius; @@ -633,7 +649,7 @@ TEST(DamageSync, ReplayedDentsMatchTheOwnersTexelForTexel) { rec.reset(t); owner.resetDamage(); for (const auto& e : rec.take(1000)) - ASSERT_TRUE(rep.receive(1, overTheWire(e), t)); + ASSERT_TRUE(fromHost(rep, 1, overTheWire(e), t)); game::applyDamage(rep.advance(DamageReplica::playerKey(1), t + 1, t + 1), target); EXPECT_TRUE(device.damageCopies(ownerTag) == clean); const auto after = device.damageCopies(remoteTag); @@ -677,9 +693,9 @@ bool pump(std::initializer_list games, const std::function(i)); - clientSide.own().part(host.sessionTime(), "WHL3"); + hostSide.player(clientId).patch(host.sessionTime(), {0.1f * i, 0.5f, 0.0f}, + static_cast(i)); + hostSide.player(clientId).part(host.sessionTime(), "WHL3"); + // The client's own word on its car (OpenMM2 0.3's owner-based damage). + clientSide.own().patch(host.sessionTime(), {0.3f, 0.3f, 0.3f}, 7); clientSide.send(client, 0); hostSide.police(402).patch(host.sessionTime(), {0.2f, 0.4f, 0.6f}, 99); hostSide.police(402).part(host.sessionTime(), "BREAK0"); @@ -715,14 +734,15 @@ TEST(DamageSync, DamageTravelsBetweenNetGames) { clientSide.receive(client.takeGameEvents(), client.frameTime()); hostSide.settle(host.frameTime() + 5000.0); clientSide.settle(client.frameTime() + 5000.0); - return hostSide.replica().recordSize(DamageReplica::playerKey(clientId)) == 20 && - clientSide.replica().recordSize(DamageReplica::ambientKey(402)) == 1; + return clientSide.replica().recordSize(DamageReplica::playerKey(clientId)) == 20 && + clientSide.replica().recordSize(DamageReplica::ambientKey(402)) == 1 && + hostSide.stats().receivedEvents == 2; })); - EXPECT_EQ(hostSide.replica().replay(DamageReplica::playerKey(clientId)).partsOff, 1u << 12); + EXPECT_EQ(clientSide.replica().replay(DamageReplica::playerKey(clientId)).partsOff, 1u << 12); EXPECT_EQ(clientSide.replica().replay(DamageReplica::ambientKey(402)).partsOff, 1u); EXPECT_EQ(hostSide.replica().recordSize(DamageReplica::ambientKey(402)), 0u); // the client's claim - EXPECT_GE(hostSide.replica().stats().refused, 1u); - EXPECT_EQ(hostSide.stats().receivedEvents, 3u); + EXPECT_EQ(hostSide.replica().recordSize(DamageReplica::playerKey(clientId)), 0u); // its own word + EXPECT_GE(hostSide.replica().stats().refused, 2u); } // --- Knocked traffic cars' wheels --------------------------------------------------------- diff --git a/tests/net/test_fuzz.cpp b/tests/net/test_fuzz.cpp index c8ca8e4..980f94c 100644 --- a/tests/net/test_fuzz.cpp +++ b/tests/net/test_fuzz.cpp @@ -442,7 +442,7 @@ void decodeEverything(std::span b) { ASSERT_TRUE(std::isfinite(e->damage)); } if (const auto e = payload(b)) { - ASSERT_LE(e->subject, kDamageOwnCar); + ASSERT_LE(e->subject, kDamageLastSubject); ASSERT_LE(e->patches.size(), kMaxDamagePatches); ASSERT_LE(e->first + e->patches.size(), kMaxDamageRecord); ASSERT_LE(e->impacts.size(), kMaxDamageImpacts); diff --git a/tools/netprobe/SyncReport.cpp b/tools/netprobe/SyncReport.cpp index ee8b16a..32a0b58 100644 --- a/tools/netprobe/SyncReport.cpp +++ b/tools/netprobe/SyncReport.cpp @@ -179,6 +179,26 @@ bool drawnGap(const Machine& m, int a, int b, double w, double& gap) { return true; } +// The closest machine `m` drew the two cars within `window` ms of wall time +// `w` (the same collision a machine shows a little earlier or later). +bool closestGap(const Machine& m, int a, int b, double w, double window, double& gap) { + const auto ia = m.drawn.find(a); + if (ia == m.drawn.end()) + return false; + bool any = false; + gap = 1e30; + for (const Drawn& d : ia->second) { + if (d.wall < w - window || d.wall > w + window) + continue; + double g = 0; + if (drawnGap(m, a, b, d.wall, g)) { + gap = std::min(gap, g); + any = true; + } + } + return any; +} + } // namespace int syncReport(const SyncReportOptions& o) { @@ -272,7 +292,8 @@ int syncReport(const SyncReportOptions& o) { // (touching cars' centres are 2-5 m apart). std::println("\nCollisions between players' cars (episodes; matched: another machine reporting the"); std::println("pair had one within 750 ms of session time and 4 m; gap: the two cars' centres as each"); - std::println("machine drew them at that moment, median and largest):"); + std::println("machine drew them at that moment, and the closest they came within 0.6 s of it; cars"); + std::println("drawn less than 5 m apart are touching):"); std::vector, std::vector>> eps; for (const auto& m : ms) eps.push_back(episodes(m)); @@ -305,15 +326,22 @@ int syncReport(const SyncReportOptions& o) { } std::println("{}", line); for (std::size_t n = 0; n < ms.size(); ++n) { - Stats gaps; + Stats gaps, closest; + int touching = 0; for (const Hit& h : it->second) { double g = 0; if (drawnGap(ms[n], pair.first, pair.second, h.wall, g)) gaps.add(g); + if (closestGap(ms[n], pair.first, pair.second, h.wall, 600.0, g)) { + closest.add(g); + touching += g < 5.0 ? 1 : 0; + } } if (!gaps.v.empty()) - std::println(" drawn gap on {:<9}: median {:.2f} m, largest {:.2f} m", who(ms, n), - gaps.pct(0.5), gaps.pct(1.0)); + std::println(" drawn gap on {:<9}: median {:.2f} m, largest {:.2f} m; " + "closest within 0.6 s median {:.2f} m, largest {:.2f} m ({} of {} touching)", + who(ms, n), gaps.pct(0.5), gaps.pct(1.0), closest.pct(0.5), closest.pct(1.0), + touching, closest.v.size()); } } } From 3292ac5b640da3496f9c6b53db3544527e80cf7e Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Fri, 9 Oct 2026 13:48:13 -0700 Subject: [PATCH 020/129] Keep the props' code and tests clear of MSVC's pitfalls A function-local struct with default member initialisers made with std::vector(n), and polymorphic objects (worlds, prop sets) built inside lambdas in the tests: both have broken MSVC before. The ring's per-slot state is a namespace-scope struct, the tests' helpers are functions. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/game/net/PropSync.cpp | 18 ++-- tests/game/test_prop_sync.cpp | 168 +++++++++++++++++++--------------- 2 files changed, 104 insertions(+), 82 deletions(-) diff --git a/src/game/net/PropSync.cpp b/src/game/net/PropSync.cpp index 6b6c623..37e0704 100644 --- a/src/game/net/PropSync.cpp +++ b/src/game/net/PropSync.cpp @@ -42,6 +42,13 @@ Mat34 blendMatrix(const Mat34& from, const Mat34& to, float t) { constexpr std::uint32_t kTagMarker = 0x80000000u; +// What a slot of the host's ring holds this frame (PropHost::build). +struct RingSlot { + std::optional what; + bool moving = false; + bool slow = false; +}; + } // namespace std::size_t placedProps(const BangerSet& set) { @@ -175,15 +182,10 @@ std::optional PropHost::build(const BangerSet& set, std::uint if (m_seen.size() < slots) m_seen.resize(slots); bool busy = !m_sentAny, creeping = false; - struct Current { - std::optional what; - bool moving = false; - bool slow = false; - }; - std::vector current(slots); + std::vector current(slots); for (std::size_t k = 0; k < slots; ++k) { const auto& inst = set.instances()[ring[k]]; - Current& c = current[k]; + RingSlot& c = current[k]; if (inst.state != BangerSet::State::Gone) c.what = describeHit(inst); c.moving = c.what && set.moving(ring[k]); @@ -215,7 +217,7 @@ std::optional PropHost::build(const BangerSet& set, std::uint msg.catalog = catalog; const int rotation = std::max(1, m_options.restRotation); for (std::size_t k = 0; k < slots; ++k) { - const Current& c = current[k]; + const RingSlot& c = current[k]; if (!c.what) continue; SlotSeen& seen = m_seen[k]; diff --git a/tests/game/test_prop_sync.cpp b/tests/game/test_prop_sync.cpp index 76aaa5e..2cd1ecb 100644 --- a/tests/game/test_prop_sync.cpp +++ b/tests/game/test_prop_sync.cpp @@ -122,6 +122,42 @@ struct Machine { } }; +std::vector layout(); + +// The speed of the piece prop 1 becomes when a car of 1000 kg at 10 m/s hits +// it, simulated or kinematic (the host's copy of a network car). A function, +// not a lambda: MSVC's optimiser has crashed on polymorphic objects built +// inside lambdas. +float pieceSpeed(const bangers::BangerDataLibrary& lib, bool kinematic) { + Machine m(lib); + m.place(layout()); + Car car({6, 1, 3.0f}, {0, 0, -10}); + if (kinematic) { + car.body.kinematic = true; + car.body.kinematicMoves = true; + car.body.kinematicVelocity = {0, 0, -10}; + car.body.kinematicBreaksBangers = true; + car.body.resetCollider(); + } + m.world.add(&car.body); + float speed = 0.0f; + for (int i = 0; i < 40 && m.set.standing(1); ++i) { + if (kinematic) { + Mat34 at = car.body.ics.matrix; + at.m3 = at.m3 + car.body.kinematicVelocity * kDt; + car.body.place(at); + } + m.step(); + } + m.step(); // the impulses move the piece + for (std::size_t i = 0; i < m.set.instances().size(); ++i) + if (m.set.instances()[i].source == 1) + if (const phys::Body* b = m.set.body(i)) + speed = b->ics.linearVelocity.mag(); + m.world.remove(&car.body); + return speed; +} + std::vector layout() { auto at = [](const char* model, const Vec3& p) { return PlacedProp{model, Mat34::translation(p), 1, PlacedProp::Source::Instance, true}; @@ -419,36 +455,7 @@ TEST(PropSync, MovingKinematicBodiesBreakProps) { TEST(PropSync, KinematicBodiesShareTheMotionAsTheirMass) { TempBangers files; bangers::BangerDataLibrary lib(files.vfs); - auto pieceSpeed = [&](bool kinematic) { - Machine m(lib); - m.place(layout()); - Car car({6, 1, 3.0f}, {0, 0, -10}); - if (kinematic) { - car.body.kinematic = true; - car.body.kinematicMoves = true; - car.body.kinematicVelocity = {0, 0, -10}; - car.body.kinematicBreaksBangers = true; - car.body.resetCollider(); - } - m.world.add(&car.body); - float speed = 0.0f; - for (int i = 0; i < 40 && m.set.standing(1); ++i) { - if (kinematic) { - Mat34 at = car.body.ics.matrix; - at.m3 = at.m3 + car.body.kinematicVelocity * kDt; - car.body.place(at); - } - m.step(); - } - m.step(); // the impulses move the piece - for (std::size_t i = 0; i < m.set.instances().size(); ++i) - if (m.set.instances()[i].source == 1) - if (const phys::Body* b = m.set.body(i)) - speed = b->ics.linearVelocity.mag(); - m.world.remove(&car.body); - return speed; - }; - const float simulated = pieceSpeed(false), kinematic = pieceSpeed(true); + const float simulated = pieceSpeed(lib, false), kinematic = pieceSpeed(lib, true); ASSERT_GT(simulated, 2.0f); EXPECT_NEAR(kinematic, simulated, simulated * 0.05f); } @@ -532,7 +539,8 @@ TEST(PropSync, ABigCrashStaysWithinADatagram) { EXPECT_LT(largest, 1100u); // About 20 messages a second while the props fly, far fewer once they rest. EXPECT_LT(bytes / 8, 12000u); // bytes a second, on average over the crash - std::printf("big crash: %zu props knocked, %zu messages (largest %zu bytes, %zu bytes/s), %zu knock events\n", + std::printf("big crash: %zu props knocked, %zu messages (largest %zu bytes, %zu bytes/s), %zu knock " + "events\n", knocked, messages, largest, bytes / 8, events); } @@ -651,6 +659,59 @@ TEST(PropSync, RefusesWhatItCannotFollow) { EXPECT_TRUE(r.client.set.standing(1)); } +namespace { + +struct Placement { + std::uint32_t catalog = 0; + std::size_t placed = 0; +}; + +// One machine's props in a network race, in RaceScreen's order: the city's +// (loadWorldObjects), the gizmos' parked cars (initGizmos, multiplayer), the +// traffic lights (addTrafficLightProps, where aiMap::Init runs). +Placement placeAsAMachine(const vfs::Vfs& v, const char* name, GameMode mode, int raceIndex, float traffic) { + auto city = city::loadCity(v, name); + if (!city) + return {}; + bangers::BangerDataLibrary data(v); + CityLevel level(*city, v, [&](std::string_view n) { return data.has(n); }); + phys::World physics(level.takeMaterials()); + physics.setLevel(&level); + BangerSet set(data); + std::uint32_t state = 1; + set.add(bangers::placeCityProps(*city, v, data, bangers::racePropsName(mode, raceIndex), &state)); + ai::Random random(state); + takeVehCarInitDraws(random); + RaceConfig config; + config.city = name; + config.mode = mode; + config.raceIndex = raceIndex; + auto gizmos = world::initGizmos(v, *city, config, true, set, data, &level, random); + if (mode == GameMode::Cruise) { + ai::Settings settings; + settings.trafficDensity = traffic; + settings.pedestrianDensity = 0.0f; + settings.random = &random; + const auto ai = ai::World::create(*city, v, settings); + if (!ai) + return {}; + for (const ai::Signal& s : ai->signals()) { + PlacedProp p; + p.model = s.model; + p.transform = s.transform; + p.room = level.findRoom(s.position(), 0); + p.ownerDrawn = true; + set.addOne(p); + } + } + set.setWorld(&physics); + Placement out{propCatalog(set), placedProps(set)}; + set.setWorld(nullptr); + return out; +} + +} // namespace + // Retail data: every machine of a network race places the same props with // the same indices. The street props restart the random generator per road, // the parked cars (network races only) draw from the stream they leave, and @@ -659,49 +720,8 @@ TEST(PropSync, RefusesWhatItCannotFollow) { TEST(PropSyncRetail, EveryMachinePlacesTheSameProps) { MM2_REQUIRE_GAME_DATA(); const auto& v = *test::gameData(); - struct Placement { - std::uint32_t catalog = 0; - std::size_t placed = 0; - }; - auto place = [&](const char* name, GameMode mode, int raceIndex, float traffic) -> Placement { - auto city = city::loadCity(v, name); - if (!city) - return {}; - bangers::BangerDataLibrary data(v); - CityLevel level(*city, v, [&](std::string_view n) { return data.has(n); }); - phys::World physics(level.takeMaterials()); - physics.setLevel(&level); - BangerSet set(data); - std::uint32_t state = 1; - set.add(bangers::placeCityProps(*city, v, data, bangers::racePropsName(mode, raceIndex), &state)); - ai::Random random(state); - takeVehCarInitDraws(random); - RaceConfig config; - config.city = name; - config.mode = mode; - config.raceIndex = raceIndex; - auto gizmos = world::initGizmos(v, *city, config, true, set, data, &level, random); - if (mode == GameMode::Cruise) { - ai::Settings settings; - settings.trafficDensity = traffic; - settings.pedestrianDensity = 0.0f; - settings.random = &random; - const auto ai = ai::World::create(*city, v, settings); - if (!ai) - return {}; - for (const ai::Signal& s : ai->signals()) { - PlacedProp p; - p.model = s.model; - p.transform = s.transform; - p.room = level.findRoom(s.position(), 0); - p.ownerDrawn = true; - set.addOne(p); - } - } - set.setWorld(&physics); - Placement out{propCatalog(set), placedProps(set)}; - set.setWorld(nullptr); - return out; + auto place = [&](const char* name, GameMode mode, int raceIndex, float traffic) { + return placeAsAMachine(v, name, mode, raceIndex, traffic); }; for (const char* name : {"sf", "london"}) { // Cruise: the host at the cruise menu's traffic, a client at none. From b3253f77be3d44ee1f601065ac640de6a111863c Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Fri, 9 Oct 2026 13:48:13 -0700 Subject: [PATCH 021/129] Document the host's props of a network race docs/multiplayer.md gains a Props section (what MM2 does, the protocol, the client's rules, the measurements, the deviations) and the props in the channel, message, untrusted input, event, per-frame contract, automation and testing notes; docs/bangers.md what BangerSet carries for network games; docs/parity/openmm2-only.md the extras and deviations. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/bangers.md | 27 ++++++ docs/multiplayer.md | 160 +++++++++++++++++++++++++++++++++--- docs/parity/openmm2-only.md | 23 ++++++ 3 files changed, 199 insertions(+), 11 deletions(-) diff --git a/docs/bangers.md b/docs/bangers.md index d9705be..0ebf578 100644 --- a/docs/bangers.md +++ b/docs/bangers.md @@ -183,3 +183,30 @@ room of the player's car (type 4), an opponent's or a network player's its room, so a prop still moving vanishes when the cars leave it behind. A prop still standing (`dgUnhitBangerInstance`) keeps lvlInstance's empty Detach. + +## Network games + +MM2 sends nothing about props: every machine knocks its own with its own +simulation of every car (`mmNetObject`, `mmGameMulti`). OpenMM2's host is the +authority instead (docs/multiplayer.md, "Props"); BangerSet carries what that +needs, all of it inert in a single-player race: + +* The placed props' indices name them on every machine (they are placed in + the same order everywhere); a hit instance remembers the placed prop it came + from (`Instance::source`, with `part` for a BREAKnn piece) or the tag its + thrower gave a car part (`Instance::tag`). The ring's slots count how often + they were handed out (`generation`). +* `takeKnocks` (with `recordKnocks(true)`): the placed props that broke loose + (`dgUnhitBangerInstance::Impact`) and what hit them. +* A client (`setReplica`): only its own car and the props it simulates may + touch its props (`phys::Instance::acceptsContact`); `breakPlaced` takes a + prop out of its room as Impact does but without a body; `restoreStanding` + undoes a knock its car predicted that the host did not make (Reset for one + prop); `showMirror` / `hideMirror` show the host's ring slots in instances + of their own, drawn where the host has them. An active attached to a + mirror starts from the host's motion (`dgBangerActive::Attach` starts a + placed or resting prop at rest). +* A moving kinematic body allowed to (`phys::Body::kinematicBreaksBangers`, + the host's copies of the other players' cars) breaks a banger as a body of + its mass would (`calcBangerImpact`); every other kinematic body leaves it + standing, as before. diff --git a/docs/multiplayer.md b/docs/multiplayer.md index ff428a9..94cf8ca 100644 --- a/docs/multiplayer.md +++ b/docs/multiplayer.md @@ -178,7 +178,7 @@ 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, unsequenced (late snapshots still fill the buffer; ENet's throttle never drops them) | `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), `PropState` (host→clients) | | 2 Events | reliable, ordered | `GameEvent` | | 3 Ambient | unreliable, sequenced | `AmbientState` (host→clients): the shared cruise traffic | @@ -195,7 +195,9 @@ incompatible builds never parse each other's messages. **Any change to message layouts must bump `kProtocolVersion`.** `Hello` carries the version again, along with a free-form build string. Version 2 added the shared cruise traffic, version 3 the race start handshake (`RaceLoad` in place of -`Countdown`, `RaceLoaded`, `RaceStart`, the race in `Welcome`). +`Countdown`, `RaceLoaded`, `RaceStart`, the race in `Welcome`), version 4 the +cars' damage, version 5 the host's props (`PropState`, the `PropKnocks` +event). ### Handshake @@ -245,6 +247,7 @@ attempt within `connectTimeoutMs` (8 s). | 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") | +| PropState | H→C | the host's ring of knocked-over props and thrown car parts (see "Props") | `SessionSettings` holds the session name, city, mode (Cruise, Checkpoint, Circuit, Blitz, Cops & Robbers, Crash Course), race id, laps, time of day, @@ -297,6 +300,15 @@ packet to the game or discovery port, and whatever answers as the router. host only, keeps at most 96 cars' records with at most 1024 patches each, lets at most 64 events wait for their time per car (older ones go into the record at once) and waits at most 2 s for an entry's time. +* **Props.** A `PropState` holds at most 64 slots (ranged: slot 0-63, + generation 0-15, placed prop 0-32767, piece 0-15, car part 0-19, owner + 0-63, paint job 0-15), quantized positions (±8 km), velocities (±128 m/s) + and spin (±64 rad/s); a client keeps at most 16 unread, takes them only + during a race, refuses a repeated slot and a slot whose generation changes + what it holds, ignores a prop it has not placed and, when the host's + placement checksum differs from its own, every placed prop (it then + simulates its props itself). A `PropKnocks` event holds at most 512 knocks + and counts only from the host. * **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 @@ -382,7 +394,8 @@ 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: Cops and Robbers uses 0x8001 and 0x8002, the shared cruise traffic's hit report (`TrafficHitEvent`, client to host) 0x8010, a -car's damage (`VehicleDamageEvent`, see "Damage") 0x8020. +car's damage (`VehicleDamageEvent`, see "Damage") 0x8020, the host's +knocked props (`PropKnocksEvent`, see "Props") 0x8030. ### Race start @@ -608,10 +621,12 @@ their owner's damage instead: A patch copies the damaged texture's texels, so patches can land in any order. The parts are the owner's: the ones it broke off (`vehBreakableMgr::Impact`'s nearest, `EjectOneshot`'s by speed and its own - random numbers) come off on every machine, thrown as bangers when the event - is fresh (4 m/s, wheels at 1.3 times the car's speed), just taken off - otherwise. The sparks, shards and impact sound of each damaging impact are - replayed at the car as drawn. + random numbers) come off on every machine. The host throws them as bangers + when the event is fresh (4 m/s, wheels at 1.3 times the car's speed) and + every machine shows the host's (see "Props"); a client takes them off + without throwing, and throws its own car's at once, which hand over to the + host's when they rest. The sparks, shards and impact sound of each + damaging impact are replayed at the car as drawn. * **Timing.** Every entry happens when the receiving machine draws the car at the session time it happened on its owner's machine (the police: at the shared traffic's drawing time), so a dent appears when the car hits the @@ -668,6 +683,110 @@ cars knocked in 2 minutes): 10 bytes a second without a car on its body's wheels, 80-770 bytes a second while one to four were (about 1 to 4 cars a message), of 11-19 KB/s; the 1100-byte message budget still bounds it. +### Props + +**MM2.** Nothing about props travels: no message of `mmGameMulti::GameMessageCB` +or of the modes' `GameMessage` names one, and `mmNetObject` carries the cars +only. Every machine simulates every player's car as a `vehCar` +(`mmNetObject::Update` declares it a type-3 mover), so each machine's own +collisions knock its props (`dgUnhitBangerInstance::Impact`), its parked cars, +its traffic lights and the parts thrown off its simulation of each car +(`vehBreakableMgr::Eject`), and each rests where that machine's simulation +leaves it. What a network game has (`mmGame::InitGizmos`, `mmGameMulti::Init`): +parked cars in the races only (none in a network cruise or Cops and Robbers), +no ferries, no cable cars; traffic lights in cruise and Cops and Robbers (the +races skip `aiMap::Init`). OpenMM2 0.3 differed in one more way: a network car +was a kinematic body, against which `dgImpact::CalcImpact` holds every banger, +so a prop another player knocked stood on every other screen, and the host's +shared traffic knocked props on the host only. + +**OpenMM2** (the maintainer's decision: the host is the authority for +everything; `game/net/PropSync`, `game/net/NetProps`, `net/PropState.h`): the +host simulates the props for everyone, as a single-player race does, with +every car: its own, its copies of the other players' cars, the shared traffic +and police. Its copies of the others' cars are kinematic until they are +simulated on the host; they meet a banger as a car of their mass would +(`phys::Body::kinematicBreaksBangers`: dgImpact's break test and share of the +motion with the car's phInertialCS, only the car's own response left out). + +* **Names.** Every machine places the same props in the same order (the street + props restart the random generator per road, the instances and path sets + come in file order, the parked cars draw from the stream the street props, + the player's `vehCar::Init` and the sailboats leave, the traffic lights come + from the AI map whatever the traffic density), so a placed prop's index + names it everywhere (test `PropSyncRetail.EveryMachinePlacesTheSameProps`; + 7068 props in a San Francisco cruise, 6637 in its checkpoint races). A + checksum of the placement (`game::propCatalog`) travels with every + message. A knocked-over prop is a slot of the host's ring of 40 + (`dgBangerManager`): the whole prop, one of its BREAKnn pieces, or a part + thrown off a car, named by the car (a player's id, or a shared car's catalog + index), the part (`damagePartIndex`) and the paint job. +* **Knocks** (`PropKnocks`, reliable, to everyone): the placed props the host + broke loose, each with its session time. A machine that reports the race + loaded gets every knock so far at once, so props knocked earlier, anywhere, + are down when it gets there. +* **The ring** (`PropState`, unreliable, the same message to every client): + every occupied slot, complete (a slot left out is empty from that message's + time), 20 times a second while a prop flies or a slot changed, 5 while props + only creep (slower than 0.5 m/s: a meter sliding down a hill, which phSleep + may never stop), 2 otherwise: + + | Field | Encoding | + | --- | --- | + | header | u32 session time, u32 placement checksum, count | + | slot, generation | 6 + 4 bits; without a state only these travel (still there, at rest) | + | what | 2 bits: a placed prop (15 bits), a piece (15 + 4 bits), a car part (1 + 6 + 5 + 4 bits: owner kind and id, part, paint job) | + | moving | 1 bit | + | frame at the CG | 3 × 22 bits over ±8 km (4 mm), smallest-three quaternion (32 bits) | + | motion (moving only) | velocity 3 × 13 bits (±128 m/s), spin 3 × 12 bits (±64 rad/s) | + + A flying piece takes about 206 bits, a slot at rest 110, a slot only still + there 11. A slot carries its state while it moves (a creeping one every + fourth message), in the three messages after it changed, and in every + tenth otherwise. +* **A client** shows the host's props as the host had them a playout delay + in the past (what the messages needed to arrive over the last 3 s plus a + send interval, 50-500 ms, as the other players' cars): a knock when it shows + that time, the ring in mirror slots interpolated on their velocities + (`net::SnapshotBuffer`). Only its own car (and the props it sets moving + itself) may touch its props (`phys::Instance::acceptsContact`): the other + cars pass through them, since the host decides what they do. Its car hits a + prop at once, as in single player (the impulse, the damage, the prop + breaking loose): a prediction. When the host's knock comes (from its copy + of the car, a playout delay later) the piece simulated here stands in for + the host's until both rest, then hands over to the host's, blended over + 0.4 s; a knock the host has not made 2 s later is undone (the prop stands + again). A mirror its car touches is simulated here from the host's motion + (so a flying cone hits its car as on the host) and stays where it stopped + until the host's has moved and rests again, or 2 s. A piece simulated here + that the host's ring does not hold disappears 2 s after it was made once at + rest. The parts its replay of another car's damage would throw are taken + off without a throw (the host's ring shows them); its own car's are thrown + at once and hand over like a knock. +* **A different placement** (an altered city, a mismatched build): the + client follows only the cars' parts and simulates its props itself, as + 0.3 did, and logs it. + +Measured on this machine through `netprobe relay` (80 ±20 ms each way, 2 % +loss, reordering; and 150 ±20 ms, 5 % loss), a host and one or two clients +driving into props, traffic lights and parked cars (the record: +`docs/review/multiplayer-desync-props.md`): before, no prop knocked on one +machine was knocked on another by the same hit, 11-16 props differed between +the screens at the end of a minute, and the props both cars hit separately +rested 8-21 m apart; after, every knock reached every machine (150-290 ms +apart, a playout delay), no prop differed at the end, and the knocked-over +props, parked cars and parts rested within 3 mm of the host's (the +position's quantization). The host sent 0.3-4.0 KB/s to each client (10 s +averages; a big crash in the test, 27 props knocked by one car, 5 KB/s for a +few seconds, at most 425 bytes a message); the ring bounds a message at 40 +slots, about 1 KB. + +**Deviations.** MM2 lets each machine knock its own props; OpenMM2 shows the +host's everywhere, as it does the traffic. The ring's wrapping (the oldest +knocked-over prop disappears when the 41st is knocked) is the host's on every +machine. A client's predicted knock that the host does not make stands up +again, and a client's pieces move to the host's place when they rest. + ### Disconnect reasons `Left`, `VersionMismatch`, `ServerFull`, `BadPassword`, `GameInProgress`, @@ -854,7 +973,15 @@ When `config.multiplayer && ctx.netGame`: shared-traffic host's police) record their patches, parts, impacts and resets (`DamageRecorder`), sent with `NetDamage::send` after the frame's effects. -8. **End:** the host calls `ctx.netGame->returnToLobby()` when the race is +8. **Props** (`game::NetProps`, see "Props"): set up once the props are + placed (`setup`, with this machine's car as the only thing that may touch + a client's props); a client's `beforeStep` before the physics step (the + host's messages and knocks, the mirrors placed for the session time the + simulation will reach), every machine's `afterStep` after the props' + update (`BangerSet::update`: the host sends, a client takes its own + knocks as predictions). A part thrown off a car carries its tag + (`game::carPartTag`). +9. **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; @@ -892,8 +1019,12 @@ the last (`-1.png`, ...) and ends at the last. For the damage: `OPENMM2_DEBUG_FOCUS=net:` frames a player's car (this machine's own, or another's as drawn), `police:<400 + post>` a shared police car, `knocked:` the knocked traffic car with a body within 60 m of a -player's car with the lowest id, `traffic:` a shared traffic car, so both -machines take the same view; `OPENMM2_DEBUG_INPUT` takes several inputs +player's car with the lowest id, `traffic:` a shared traffic car, +`prop:` a placed prop or, once knocked, its first piece, so both +machines take the same view. For the props: `OPENMM2_DEBUG_NETPROPS=` +traces each machine's props (`netprobe propdiff ` compares +two), `OPENMM2_NETPROPS=local` leaves every machine with its own props, as +0.3 did (for comparison); `OPENMM2_DEBUG_INPUT` takes several inputs separated by `/` in turn, each for `OPENMM2_DEBUG_INPUT_MS` (2000) of race time (`1,0,0.2,0/0,1,-0.2,0` rams a wall again and again); `OPENMM2_DEBUG_RESPAWN_MS=[,...]` (or `+`) puts the car back at its @@ -944,7 +1075,14 @@ hold, a reset clearing the car, convergence after a lost event, cars not drawn, hostile events; the same texels on two renderers from retail data; two `NetGame`s; the knocked cars' wheels through a message and the client's interpolation), the damage relay budget in `test_hostile_input.cpp` and the -event in `test_fuzz.cpp`. +event in `test_fuzz.cpp`. The props by `tests/net/test_prop_state.cpp` (the +messages, their sizes and limits), `test_fuzz.cpp` (both messages, decoded +and sent to a client) and `tests/game/test_prop_sync.cpp` (a host and a +client in one process through a lossy link: the host's knocks and pieces, +a predicted knock handed over and one undone, other cars passing through a +client's props, moving kinematic cars breaking props as a car of their mass +would, loss and reordering, a big crash, the catch-up, thrown car parts, +refused input, and on retail data the same placement on every machine). ### Diagnosing replication diff --git a/docs/parity/openmm2-only.md b/docs/parity/openmm2-only.md index 30fa0c0..33fc685 100644 --- a/docs/parity/openmm2-only.md +++ b/docs/parity/openmm2-only.md @@ -133,6 +133,27 @@ counted in the summary above. | A client's police cars' damage (`game::DamageReplica`, `VehicleEffects`, `AmbientEntity::damage`) | `vehCarDamage::Update`, `ApplyImpact`, `fxTexelDamage::ApplyDamage`, `vehBreakableMgr::Eject`, `vehCarModel::EjectOneshot` on the host's cars | openmm2 | Added 2026-10-09 (protocol 4): the host's police damage level (10 bits) drives the received car's smoke as `vehCarDamage::Update` does, and its dents, broken parts, sparks, shards and impact sounds are replayed from the host's records (docs/multiplayer.md, "Damage"); the wreck (out of action) as before, with its black smoke and the explosion sound. Before, a client saw the wreck flag and the explosion only. | | A client's knocked traffic cars' wheels (`AmbientEntity::wheels`, `game::trafficWheelMatrices`) | `aiVehicleInstance::Draw` with an `aiVehicleActive` (`vehWheelCheap`'s drawing matrices) | openmm2 | Added 2026-10-09 (protocol 4): a car with a body on the host sends its four wheels' drawing offsets; the client draws it on them and lays its shadow as a physical car's. Before, it was drawn on its rail wheels. | +## Props of a network race + +Added 2026-10-09 on the maintainer's decision (the host is the authority for +everything). MM2 sends nothing about props: every machine knocks its own +props, parked cars, traffic lights and thrown car parts with its own +simulation of every car (`mmNetObject::Update` declares each network car a +type-3 mover; no message of `mmGameMulti::GameMessageCB` or the modes' +`GameMessage` names a prop). What a network game has is MM2's and unchanged: +no parked cars in a network cruise or Cops and Robbers, no ferries, no cable +cars (`mmGame::InitGizmos`, `mmGameMulti::Init`). These rows are not counted +in the summary above. + +| OpenMM2 | MM2 | Verdict | Notes | +| --- | --- | --- | --- | +| `game::PropHost`, `PropClient`, `NetProps`, `net::PropStateMsg`, `PropKnocksEvent` | (none) | openmm2 | The host simulates the props for everyone and sends the knocks (reliable, all of them to a machine that has loaded the race) and its ring of knocked-over props (`dgBangerManager`'s 40 slots); a client shows them a playout delay in the past, predicts its own car's knocks and hands them over (docs/multiplayer.md, "Props"). | +| `BangerSet::setReplica`, `phys::Instance::acceptsContact` | `dgPhysManager::CollideInstances` | deviation | A client's props take contacts from its own car and its own simulated props only; MM2's take them from every mover. | +| `BangerSet::breakPlaced`, `restoreStanding`, `showMirror`, `hideMirror` | `dgUnhitBangerInstance::Impact`, `Reset`, `dgHitBangerInstance` | openmm2 | A client's placed prop leaves its room as Impact makes it leave (without a body), stands again as Reset makes it stand (a prediction the host did not confirm), and the host's pieces are hit instances of their own. | +| `BangerSet::attachEntity` on a mirror | `dgBangerActive::Attach` | deviation | MM2 starts an active at rest (a placed or resting prop is); a client's mirror of a host piece in flight starts from the host's motion. | +| `phys::calcBangerImpact` with `Body::kinematicBreaksBangers` | `dgImpact::CalcImpact` | deviation | The host's kinematic copies of the other players' cars meet a banger with their phInertialCS (the same break test and share of the motion as a simulated car of their mass); their own response is left out. Without the flag a kinematic body cannot break a banger, as before. MM2's network cars are simulated vehCars. | +| Thrown car parts (`ejectPart`'s tag, `game::carPartTag`) | `vehBreakableMgr::Eject` on every machine's simulation | deviation | The host throws them for everyone; a client takes another car's parts off without a throw and throws its own car's at once (handed over to the host's). | + ## Files | File | Class | Reason | @@ -162,6 +183,8 @@ counted in the summary 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/PropState.cpp` | O | The host's props on the wire (added 2026-10-09). | +| `src/net/PropState.h` | O | | | `src/net/VehicleDamage.h` | O | A car's damage on the wire (added 2026-10-09); the rules it replays are MM2's (docs/multiplayer.md, "Damage"). | | `src/net/BitStream.cpp` | confirmed O | Wire encoding. | | `src/net/BitStream.h` | confirmed O | | From a61f9822981915da90423bca7283f0514272f787 Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Fri, 9 Oct 2026 13:52:06 -0700 Subject: [PATCH 022/129] Document the shared traffic's prediction and the client's own knocks docs/multiplayer.md: the message's stamp and rail motion, the prediction to the present, the client's collisions, the trace lines and mm2tool nettrace, protocol 5; docs/parity/openmm2-only.md: the new OpenMM2 extras; the start of the review record (docs/review/multiplayer-desync-traffic.md). Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/multiplayer.md | 123 +++++++++++++----- docs/parity/openmm2-only.md | 4 +- docs/review/multiplayer-desync-traffic.md | 150 ++++++++++++++++++++++ 3 files changed, 245 insertions(+), 32 deletions(-) create mode 100644 docs/review/multiplayer-desync-traffic.md diff --git a/docs/multiplayer.md b/docs/multiplayer.md index ff428a9..b4f892f 100644 --- a/docs/multiplayer.md +++ b/docs/multiplayer.md @@ -195,7 +195,9 @@ incompatible builds never parse each other's messages. **Any change to message layouts must bump `kProtocolVersion`.** `Hello` carries the version again, along with a free-form build string. Version 2 added the shared cruise traffic, version 3 the race start handshake (`RaceLoad` in place of -`Countdown`, `RaceLoaded`, `RaceStart`, the race in `Welcome`). +`Countdown`, `RaceLoaded`, `RaceStart`, the race in `Welcome`), version 4 +the cars' damage, version 5 what the clients predict the shared traffic +with (a rail car's acceleration, curvature and speed over the ground). ### Handshake @@ -297,7 +299,7 @@ packet to the game or discovery port, and whatever answers as the router. host only, keeps at most 96 cars' records with at most 1024 patches each, lets at most 64 events wait for their time per car (older ones go into the record at once) and waits at most 2 s for an entry's time. -* **Shared traffic.** An `AmbientState` holds at most 96 cars, ids 0-511, +* **Shared traffic.** An `AmbientState` holds at most 160 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 @@ -501,24 +503,28 @@ 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 | +| header | u32 session time (of the AI step the cars are at), 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) | +| motion | a car on its rail: its speed along its heading (11 bits, ±64 m/s; the AI's velocity is exactly that), then 1 bit, and when it is set its acceleration (7 bits, ±16 m/s²), its heading's turn per metre (9 bits, ±0.5 rad/m) and 1 bit, set when its speed over the ground differs from its speed, with that speed (11 bits); off its rail and police: velocity 3 × 12 bits (±96 m/s) and spin 3 × 11 bits (±32 rad/s) | | wheels | a car off its rail: 1 bit, whether it has a physics body; then its four wheels' drawing offsets from their pivots (80 bits, see "Damage") | | police | target player (5 bits, 16 = none), damage (10 bits), rpm, throttle, gear (31 bits) | -A car on its rail takes 119 bits, a police car 208, a knocked car 178 (258 -with a body). Each client gets the +A car on its rail takes 120 bits (136 while it turns or changes speed, 148 +when its speed over the ground differs too), a police car 208, a knocked +car 178 (258 with a body). 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. +1100 bytes (at most 160); when they do not all fit, the cars the client has +count as 25 m nearer than they are, so the car at the edge of what fits does +not come and go either. 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, beyond 160 m every fourth (taking turns 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 @@ -526,26 +532,61 @@ 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). Its physics proxies stand at that time; the -cars are drawn a physics step (16.7 ms) earlier (`TrafficClient::transformAt`), -where the rest of the scene is drawn. - -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. +velocity explains; a car not heard of for 1.5 s is dropped. + +**Shown at the present.** A message is stamped with the session time of the +AI step its rail cars are at (the police and the knocked cars, physics +bodies, are moved to it along their velocity from the physics step's time), +and carries for each rail car, measured from the AI's last two steps +(`game::RailMotionTracker`), its acceleration, its heading's turn per metre +and, where it differs from its speed, its speed over the ground +(`ai::Traffic` moves a car along its curves by their parameter: in a turn it +covers 20-30 % more or less ground than its speed, and a car held at the end +of its lane covers none). The client (`game::TrafficClient`) shows every car +at the session time its own car reaches in the frame's steps +(`RaceScreen::netTrafficPresentTime`), predicted from its newest state +(`game::TrafficPrediction`): a rail car along an arc at its speed over the +ground and acceleration, stopping at 0; a police car or a knocked car along +its velocity, turning with its yaw rate, at most 500 ms ahead (a rail car +1 s). Its collisions take the newest prediction at once; its drawing keeps +where it was and blends a newer message's correction away (100 ms time +constant; above 4 m or a radian it jumps). A message older than the time +shown is interpolated as before (Hermite with the remote players' +`SnapshotBuffer`). The light sets run to the host's steps at the time shown +(they are deterministic from a reset), and the cars are drawn a physics step +(16.7 ms) earlier than their collisions stand (`TrafficClient::transformAt`), +where the rest of the scene is drawn. Shown a trip and a playout delay in +the past, as before, a moving car was met 2.6 m from where the host had it +(docs/review/multiplayer-desync-traffic.md). MM2's network cruise has no +traffic; MM2 predicts its network cars forward from their last packet +(`mmNetObject::PositionUpdate`), which is what this does for the host's. + +**Collisions.** The received cars on their rails are instances of the +client's level in `game::TrafficBodies`, as the host's rail cars are of its +own, with the received cars as their traffic (`game::NetTrafficCars`): when +the client's car hits one, it takes a body at once +(`aiVehicleInstance::AttachEntity`, `aiVehicleActive`) and the collision runs +the host's physics, the two cars pushing each other. Only the client's own +car knocks a car loose there (the police and the other players' cars reach +the client in the host's messages). The car stays the client's until a host +message shows it off its rail too, or still on its rail at a time half a +round trip and 150 ms after the hit (the host did not knock it); the drawing +then blends from where the local body left it to the host's car. The cars +off their rails on the host meet the client's car as kinematic instances +moving at their predicted velocities (`game::TrafficProxies`), the police as +kinematic bodies. + +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. (With the host simulating every player's +car, the hits happen on the host and the reports go: see the review.) 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 @@ -901,7 +942,10 @@ reset position as the water does; `OPENMM2_DEBUG_START_NEAR_POLICE=:` starts a cruise's car that far in front of a police post's car, facing it, and `OPENMM2_DEBUG_START=,,,` at that place (the race logs its start and angle); `OPENMM2_DEBUG_POLICE_TOUGHNESS=` scales the -police cars' MedDamage and MaxDamage on the host. The host's `nettraffic` +police cars' MedDamage and MaxDamage on the host; +`OPENMM2_DEBUG_TRAFFIC_LEAD_MS=` shows a client's shared traffic that +much later than its car's time (negative: earlier), for measuring the +prediction over another horizon. The host's `nettraffic` statistics give the share of the police damage bits and the knocked cars' wheels, the client's how many cars it draws on their bodies' wheels, and the per-car lines say which cars carry wheels. `OPENMM2_DEBUG_LOAD_DELAY_MS=` keeps a race's loading screen up @@ -935,7 +979,13 @@ 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`. +roads, avoidance, the light steps), `tests/phys/test_kinematic_motion.cpp`, +`tests/game/test_traffic_prediction.cpp` (the rail motion the host measures, +the prediction along an arc, braking, the ground speed, a body's yaw, the +drawing's corrections, and a host and a client over a simulated 60 ± 20 ms +link with 2 % loss, which prints the same-moment error) and +`tests/game/test_net_traffic_cars.cpp` (a client's own knocks, confirmed, +withdrawn, recycled). The damage by `tests/net/test_vehicle_damage.cpp` (the event's encoding, the point arriving bit for bit, non-finite values, malformed events, the police damage and wheels in `AmbientState`), `tests/game/test_damage_sync.cpp` (the @@ -962,5 +1012,16 @@ 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). + +In a cruise with shared traffic the trace also has the traffic and police +(`game/net/TrafficTrace.h`): the host's `TH` lines, each car near a player at +the session time its state belongs to (the AI's step or the physics'); a +client's `TC` lines, the cars where its car meets them each frame, and `TV`, +that frame's session time of its car; the client's hit reports (`TX`), the +host's knocks and refused reports (`TK`, `TR`) and the client's own knocks +going back to the host's messages (`TL`). `mm2tool nettrace +` compares them: each car's error against the host's at the +session time of the client's car, the cars near the client one machine had +and the other did not, the police targets, the hits and knocks. 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/docs/parity/openmm2-only.md b/docs/parity/openmm2-only.md index 30fa0c0..54bac14 100644 --- a/docs/parity/openmm2-only.md +++ b/docs/parity/openmm2-only.md @@ -126,7 +126,9 @@ counted in the summary above. | `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. | +| `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 off their rails as moving kinematic instances, and the client's hit reports. | +| `game::TrafficPrediction` (`RailMotionTracker`, `predictRailCar`, `predictBody`), `TrafficClient`'s prediction and corrections | `mmNetObject::PositionUpdate` (a network car predicted forward from its last packet) | openmm2 | Added 2026-10-09 (protocol 5, docs/review/multiplayer-desync-traffic.md): the host stamps the traffic with its AI step's time and sends a rail car's acceleration, curvature and speed over the ground; a client shows every car at its own car's time, a rail car predicted along an arc, a body along its velocity and yaw rate, the drawing blending corrections away. MM2's network cruise has no traffic; MM2 predicts its network cars forward, which this does for the host's. | +| `game::NetTrafficCars`, `TrafficBodies::Source` | `aiVehicleInstance::AttachEntity`, `aiVehicleAmbient::Impact`, `aiVehicleActive::Detach` / `PostUpdate` (the hand-over between the AI and the bodies) | openmm2 | Added 2026-10-09: `TrafficBodies` takes its traffic through an interface (the AI's for the host and single player: the same calls in the same order); a shared-traffic client gives it the received rail cars, which its own car knocks loose at once with the host's physics, until the host's messages confirm or withdraw the knock. | | `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). | diff --git a/docs/review/multiplayer-desync-traffic.md b/docs/review/multiplayer-desync-traffic.md new file mode 100644 index 0000000..19b606c --- /dev/null +++ b/docs/review/multiplayer-desync-traffic.md @@ -0,0 +1,150 @@ +# Multiplayer desync review: shared traffic and police + +Reviewed on 2026-10-09 from `main` at cd5ae29 (release 0.3.1). The +maintainer's report for this area: in a multiplayer cruise over the +Internet, "different cars, or the same cars in different places, on each +screen". + +MM2's network cruise has no traffic and no police (`mmGameMulti::Init`), so +the shared traffic is OpenMM2's own design (docs/multiplayer.md, "Shared +traffic"; docs/parity/openmm2-only.md). The choices below are recorded as +OpenMM2 extras. Where MM2 has a comparable mechanism, it predicts its +network cars forward from their last packet (`mmNetObject::PositionUpdate`); +the traffic now does the same. + +During the work the maintainer decided that the host is to be the authority +for everything (players' cars included: clients upload inputs, the host +simulates every car, each client predicts its own and reconciles). The +traffic was already host-run; the client side below is designed for that +model too (see "The host-authoritative model"). + +## Summary + +| Measure (moving traffic cars within 150 m of the client's car) | Before | After | +| --- | --- | --- | +| Error against the host at the same moment, 60 ± 20 ms each way, 2 % loss | TBD | TBD | +| ... 150 ± 30 ms, 5 % loss | TBD | TBD | +| Police in a chase | TBD | TBD | +| Cars near the client on one machine only | TBD | TBD | +| A collision with a traffic car shows on the client | a round trip and a playout delay later | at once | +| Bandwidth per client (traffic density 0.5) | TBD | TBD | + +## How it was measured + +* A host and one or two clients of a San Francisco cruise on this machine, + each with its own XDG directories, `[Display] VSync=off`, `FrameCap=120`, + UDP port 2390, each client behind its own `netprobe relay` (60 ± 20 ms each + way and 2 % loss with reordering; the harsh case 150 ± 30 ms and 5 %; three + players with 40 and 120 ms). Scripted driving (`OPENMM2_DEBUG_INPUT`, + `OPENMM2_DEBUG_SPAWN`, `OPENMM2_DEBUG_START_NEAR_POLICE`, + `OPENMM2_DEBUG_RESPAWN_MS`): through downtown traffic, weaving into it, + rear-ending cars again and again, ramming a police car and fleeing it, and at + traffic density 1. +* `OPENMM2_NET_TRACE` now writes the shared traffic (`game/net/TrafficTrace.h`): + the host each car near a player at the session time its state belongs to, + a client each car where its car meets it every frame and its own car's + session time, and the hits, knocks and hand-overs. `mm2tool nettrace + ` (added) compares them. The error is measured on + the ground plane, against the host's car at the session time the client's + car was at: what one player sees of a car next to the other player's car. +* `tests/game/test_traffic_prediction.cpp` + (`TrafficPrediction.OverALossyLinkTheClientSeesTheHostsPresent`) + reproduces the measurement without game data: a host's AI steps, its + messages over a simulated 60 ± 20 ms link with 2 % loss, and the client's + cars against the true paths. + +## Findings + +Severity: crash / gameplay-breaking / visible / minor / code quality. +Locations are at cd5ae29. + +### Fixed + +| # | Severity | Location | Scenario | Confirmed | Fix | +| --- | --- | --- | --- | --- | --- | +| T1 | visible | `RaceScreen.cpp:4110` (`updateNetTraffic`) | A client drew the traffic and police a trip and a playout delay in the past (at least the host car's playout delay, 100 ms or more; 160-260 ms over the relays) while its own car was in the present, and its car met them there. Next to the other player's car a moving traffic car was 2.6 m (median, 60 ms each way) to 4.0 m (150 ms) from where the host had it at the same moment; a client rear-ending a car hit it where it had been. | traces: error in session time 165-264 ms at their speed; test `TrafficPrediction.OverALossyLinkTheClientSeesTheHostsPresent` (1.29 m median 160 ms back on a synthetic link) | 2c8679c: every car shown at the session time of the client's car, predicted from its newest state (rail cars along an arc, bodies along their velocity and yaw rate); the drawing blends corrections away | +| T2 | visible | `RaceScreen.cpp:3998` (`sendNetTraffic`) | The messages were stamped with the session time when they were sent, while the rail cars were at the AI's last 30 Hz step (up to a step and a frame earlier) and the police and knocked cars at the last physics step: every car was drawn 17 ms (median, 8-26 ms) behind on the client, the offset changing from message to message (a surge of up to 0.5 m at speed), and the light sets ran on the same wrong time. | traces: client behind along the rail by 17.2 ms (median) at the time it showed the car; after, -0.1 ms | 2c8679c: stamped with the AI step's time, the bodies moved to it along their velocity | +| T3 | visible | `game/net/TrafficProxies.cpp:19`, `RaceScreen.cpp:3877` | A client's car met the received rail cars as kinematic instances of infinite mass: it bounced off a car as off a moving wall, where the host's (and single player's) car pushes a rail car that takes a body (`aiVehicleInstance::AttachEntity`, `aiVehicleActive`); the traffic car drove on through the client's car until the host's knock came back, a round trip later, from wherever the host had knocked it. | traces of the rear-end runs; test `NetTrafficCars.*` | e6802c2: the client's `TrafficBodies` holds the received rail cars (`NetTrafficCars`); its own car knocks one loose at once with the host's physics, and the host's messages confirm or withdraw it | +| T4 | visible | `ai/Traffic.cpp:2617` (`publish`), `TrafficSync.cpp:373` | A rail car's published velocity is its AI speed along its heading, but `ai::Traffic` moves it along its curves by their parameter: in a turn the host's car covered 70-80 % of the ground its speed said, and a car held at the end of its lane covered none while its speed stood at 10-23 m/s. Predicted with that speed, such cars ran ahead of the host's. | host traces: ground speed against speed for every car (scratch analysis) | e6802c2: the host sends the speed over the ground where it differs; the prediction uses it, the velocity a hit gives the body stays the AI's | +| T5 | minor | `TrafficSync.cpp:374` | A car the AI drives off its rail (avoiding a player, regaining its lane) was sent without spin, so a client could not follow its curve. | by reading | 2c8679c: its yaw rate (curvature × speed) | +| T7 | visible | `TrafficSync.cpp:411-430` (`TrafficHost::build`), `net/AmbientState.h:39` | At traffic density 1 the cars within 200 m did not all fit in a message (at most 96, 1100 bytes): 12 % of the host's cars within 200 m of the client were missing on the client, and cars at the cut came and went (45 times in 100 s at 170-195 m) as the order changed. | traces at density 1; test `TrafficHost.TheCarsThatFitStayAndTheFarthestComeEveryFourthMessage` | 2a97bb0: up to 160 cars, a known far car's state every fourth message beyond 160 m, and the cars the client has count as 25 m nearer at the cut | +| T8 | visible | `TrafficSync.cpp:417-424` | Whether a far car's turn for its state fell in a message changed its size from 13 bits to 130, and with it which cars at the edge still fit; a car left out was gone for the client and came back new, needing its state and pushing out others. With more cars fitting (T7) this made the edge flicker nearly 2000 times in 100 s. | traces at density 1 | 217db24: the cars are chosen by their usual cost, then written, a far car whose turn it is going without its state when it would not fit | +| T6 | minor | `TrafficBodies` (host) | Once a knocked car's body stood still on the host for 40 s with 6.8 m/s left in its velocity; every client predicted it 1.2 m ahead all that time. Not reproduced in nine later runs; the cause (a body resting against something while its velocity is not cleared) is plausible only. | one trace | 8af6833: a body that moved less than 5 cm in 100 ms is shared standing | + +### Measured and left as they are + +* **Spawns and despawns at the interest edges.** The cars near the client + that only one machine had are cars the host populated or recycled on the + roads round the player (the AI's `AdjustAmbients`, MM2's behaviour) in the + time it takes a message to arrive: 0.1-0.6 % of the car-frames within + 130 m before, 0.1-0.3 % after (now one trip late instead of a trip and a + playout delay). The 200 m / 230 m hysteresis already keeps cars at the edge + from coming and going; the 1100-byte budget was never full (at most 75 + cars, 770 bytes a message at traffic density 0.5, TBD at 1). +* **Recycled slots, lost and reordered messages.** Unchanged and correct: a + new generation is a new car; an older message never brings a car back; the + prediction starts from the newest state, so a lost message only lengthens + its horizon (by 50 ms, 100 ms for a far car between its states). +* **Jumps the host's AI makes itself.** Rail cars that finish regaining + their lane, and some far from every player, are put 10-35 m away in one AI + step on the host; the client follows a trip later. This is the AI's + behaviour (not compared with MM2 here), not a desync. +* **Hit reports** (`RaceScreen.cpp:3877-3913`, `:4078-4101`): of 1, 2, 15 and + 14 reports in the four baseline runs 0, 1, 2 and 5 were applied; the others + were refused because the host had knocked the car itself already (its + delayed copy of the client's car reaching the car later). The once-a-second + limit kept a car the client was stuck against from being reported more + often. With the host simulating every player's car (below) the reports go, + so they were not changed beyond the client now reporting its own knocks. +* **The host's view of the clients for the police and the traffic's + avoidance** (`RaceScreen.cpp:1661`, `collectNetPlayers`): the host's + delayed copy of each client's car. Superseded by the host simulating every + car (the police and the traffic then see each car where it is on the host). +* **Police chases.** The police target travels with every message; the + client never disagreed with the host on who was chased (0 of 12 259 + car-frames in the chase). Their places: see the summary. + +## The host-authoritative model + +With the host simulating every player's car from uploaded inputs, a client's +own car is predicted ahead of the host (by about half a round trip and the +host's input buffer), while the traffic still arrives a trip old. The client +side is built for that: + +* **Present the traffic at the predicted car's time.** The client shows the + received cars at `RaceScreen::netTrafficPresentTime`, today its car's + session time (`netTrafficOwnTime`). With a predicted car it must return the + session time that car is at, the time the host will simulate the inputs + being applied now; the cars are then predicted that much further. + `OPENMM2_DEBUG_TRAFFIC_LEAD_MS` emulates the longer horizon today: TBD. +* **Collide as the host does.** The client's car meets the received rail + cars in `TrafficBodies`, as the host's car meets its own: a hit gives the + rail car a body with its rail velocity and the two push each other, so the + client's predicted car takes the host's response when their states agree, + and the knocked car moves off at once. With the host simulating the hit, + the host's knock is in the message stamped at the hit's time, so the window + after which the client withdraws a knock the host did not make should drop + the half round trip (`kNetKnockConfirmMs` alone, `NetTrafficCars::update`'s + `confirmMs`). +* **Correct quickly.** The collisions take each newer prediction at once; + the drawing blends the difference away in 100 ms (above 4 m or a radian it + jumps); a knock the host confirms or withdraws is handed back the same way. +* **Replays.** A client that rewinds its car to a host state and replays its + inputs can place the received cars at each replayed step's time with + `TrafficClient::poseAt` (the physics pose, without the drawing's + correction). The cars it knocked loose itself are not rewound. +* **What goes.** The hit reports (`TrafficHitEvent`, `applyNetTrafficHits`, + the report block of `updateNetTraffic`): the host sees every hit itself. + The host's police and traffic then see each player's car where the host + simulates it (`collectNetPlayers` reads `m_remotes`' cars). + +Where this depends on the players' cars work: the present time (above), the +confirmation window, the replays, and taking the hit reports out. + +## Bandwidth + +TBD + +## Open + +TBD From bb00328d003b041be64a0bb5ffb37f7f54cecaf7 Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Fri, 9 Oct 2026 14:03:19 -0700 Subject: [PATCH 023/129] Keep a tenth of the traffic budget for the cars a client has At traffic density 1 the cars at the edge of what fits still came and went 190 times in 100 s: a near car turning or slipping costs more bits in one message than in the last, the farthest known car no longer fit, and back as a new car it needed its state. A car new to the client now waits while the known ones would leave less than a tenth of the budget, which absorbs those changes. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/game/net/TrafficSync.cpp | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/game/net/TrafficSync.cpp b/src/game/net/TrafficSync.cpp index 5ccd502..8b796e1 100644 --- a/src/game/net/TrafficSync.cpp +++ b/src/game/net/TrafficSync.cpp @@ -172,6 +172,11 @@ net::AmbientStateMsg TrafficHost::build(const TrafficViewer& viewer, std::span(c.car->id)) % every == 0; const double cost = k.canHold ? static_cast(k.fullBits + (every - 1) * heldBits) / every : static_cast(k.fullBits); + // A car new to the client waits while the ones it has would leave + // less than a tenth of the budget: that tenth absorbs the changes in + // their cost from message to message, so that they all stay. + if (!set.contains(c.car->id) && expected + cost > static_cast(budget) * 0.9) + continue; if (expected + cost > static_cast(budget)) break; expected += cost; From 753c12bc28b5b1be81d1bbbcba11d7c92f365b45 Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Fri, 9 Oct 2026 14:04:08 -0700 Subject: [PATCH 024/129] Show the shared traffic where the host will simulate the client's car With the host simulating every player's car, a client's car runs ahead of the host's simulation of it by the time its inputs take to reach the host and wait there. The client measures that lead from the host's states of its car (the host's session time after sample n against the time it ran sample n itself) and shows the received traffic at its car's time plus the lead, so its car meets the cars where the host's simulation of it will. Its own knocks are confirmed on the host's clock (the host simulates the same hit at that time), so the half round trip leaves the window. The hits now happen on the host: the client's hit reports (TrafficHit, the host's applyNetTrafficHits and TrafficBodies::knock) go. The host chooses each client's cars round its simulated car, which also fixes the traffic no longer being sent at all (the clients send no snapshots any more, and the host looked for them). Co-Authored-By: Claude Opus 5.5 (1M context) --- src/app/RaceScreen.cpp | 164 ++++++++++++-------------------- src/game/TrafficBodies.cpp | 16 ---- src/game/TrafficBodies.h | 9 -- src/game/net/TrafficProxies.cpp | 11 --- src/game/net/TrafficProxies.h | 19 ++-- src/game/net/TrafficTrace.cpp | 7 -- src/game/net/TrafficTrace.h | 15 ++- src/net/AmbientState.h | 24 ----- tests/game/test_traffic_net.cpp | 18 ---- tools/mm2tool/cmd_nettrace.cpp | 31 ++---- 10 files changed, 86 insertions(+), 228 deletions(-) diff --git a/src/app/RaceScreen.cpp b/src/app/RaceScreen.cpp index a43cf1a..3b2f85d 100644 --- a/src/app/RaceScreen.cpp +++ b/src/app/RaceScreen.cpp @@ -103,10 +103,10 @@ bool serialize(S& s, CrSetEvent& e) { constexpr int kNetPlayerTrackedId = 30000; constexpr int kNetPoliceId = 400; constexpr std::uint64_t kNetTrafficIntervalMs = 50; -// A shared-traffic client: how long after half a round trip past its car's -// hit a host state may still show the car on its rail before the client's -// knock is withdrawn (the hit report's way to the host, a message interval, -// two AI steps and the jitter). +// A shared-traffic client: how long after its car's hit (on the host's clock: +// the host simulates the same hit at that time) a host state may still show +// the car on its rail before the client's knock is withdrawn (a message +// interval, two AI steps and the jitter of the client's lead). constexpr double kNetKnockConfirmMs = 150.0; const char* modePrefix(game::GameMode m) { @@ -385,8 +385,6 @@ class RaceScreen final : public Screen { updateNetTraffic(ctx, static_cast(dt)); // OpenMM2: a client's shared traffic if (multiplayer(ctx)) m_netDamage.settle(ctx.netGame->frameTime()); // OpenMM2: the damage of cars not drawn - 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) @@ -3954,61 +3952,6 @@ 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) { - std::FILE* trace = ctx.netGame->traceFile(); - const double now = ctx.netGame->frameTime(); - for (const auto& ev : m_netEvents) { - 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) { - 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) { - game::traceRefusedHit(trace, now, ev.from, hit->id, hit->generation, - car == m_ai->cars().end() ? "gone" : "off-rail"); - 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) { - game::traceRefusedHit(trace, now, ev.from, hit->id, hit->generation, "far"); - 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) { - game::traceRefusedHit(trace, now, ev.from, hit->id, hit->generation, "slow"); - 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 side = d > 0.01f ? toward * (1.0f / d) : Vec3{}; - const Vec3 point = car->transform.m3 + Vec3{0.0f, 1.0f, 0.0f} + side; - const bool knocked = m_trafficBodies->knock(car->id, impulse, point); - if (knocked) - game::traceKnock(trace, now, car->id, car->spawns, ev.from); - if (knocked && m_debugNetTraffic) - log::info("nettraffic: host knocks traffic car {} for player {} ({:.1f} m/s)", car->id, - ev.from, speed); - } - } - // Host: the session time the AI's cars are at after this frame's update // (its last fixed step), and the physics bodies' (the last simulation // step). @@ -4029,7 +3972,7 @@ class RaceScreen final : public Screen { std::vector players; if (m_player) players.push_back(m_player->sim().modelMatrix().m3); - for (const auto& rc : ctx.netGame->remoteCars()) + for (const auto& rc : m_remoteCars) // the host's simulated players' cars if (rc.hasState) players.push_back(rc.transform.m3); // The AI's cars once per AI step; the bodies every frame. @@ -4163,8 +4106,10 @@ class RaceScreen final : public Screen { if (c.body) c.transform.m3 += c.velocity * shift; const auto time = static_cast(std::llround(std::max(0.0, aiTime))); + // Each client's cars round its car as the host simulates it + // (collectHostCars, after the frame's samples). std::set viewers; - for (const auto& rc : ctx.netGame->remoteCars()) { + for (const auto& rc : m_remoteCars) { if (!rc.hasState) continue; viewers.insert(rc.id); @@ -4237,38 +4182,20 @@ class RaceScreen final : public Screen { return; for (const auto& m : ctx.netGame->takeAmbientStates()) m_trafficClient->receive(m); - // 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(); - // The cars on their rails this machine's car knocked loose - // (NetTrafficCars), and the ones off them it hit. - std::vector hits = m_trafficProxies->takeHits(); - if (m_netTrafficCars) - std::ranges::copy(m_netTrafficCars->takeKnocks(), std::back_inserter(hits)); - for (int id : hits) { + // The cars this machine's car knocked loose in the last frame's + // samples (NetTrafficCars): only traced, since the host simulates + // that car too and knocks them itself. + if (m_netTrafficCars) + for (int id : m_netTrafficCars->takeKnocks()) { 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); - game::traceHit(ctx.netGame->traceFile(), ctx.netGame->frameTime(), id, hit.generation); + if (it != m_netCars.end()) + game::traceHit(ctx.netGame->traceFile(), m_trafficRenderTime, id, + it->spawns % static_cast(net::kAmbientGenerations)); if (m_debugNetTraffic) - log::info("nettraffic: client hit traffic car {} at session t {}", id, now); + log::info("nettraffic: client knocked traffic car {} loose at session t {:.0f}", id, + m_trafficRenderTime); } - 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"); @@ -4281,6 +4208,7 @@ class RaceScreen final : public Screen { // had them (docs/review/multiplayer-desync-traffic.md). const double frame = ctx.netGame->frameTime(); const double own = netTrafficOwnTime(ctx, dt); + const double car = netTrafficCarTime(ctx, dt); // own on the host's clock const double renderTime = netTrafficPresentTime(ctx, dt); m_trafficClient->update(renderTime); m_trafficRenderTime = renderTime; @@ -4288,7 +4216,7 @@ class RaceScreen final : public Screen { // Where the received cars meet this machine's car in this frame's // steps, and the session time that car reaches in them. const Vec3 me = m_player->sim().modelMatrix().m3; - game::traceClientView(trace, frame, own, me); + game::traceClientView(trace, frame, car, me); for (const auto& c : m_trafficClient->cars()) { if (c.transform.m3.dist2(me) >= 9e4f) continue; @@ -4334,11 +4262,10 @@ class RaceScreen final : public Screen { // car goes back to the host's messages once they show it knocked too, // or still on its rail a trip after the hit. if (m_netTrafficCars) { - const double rtt = static_cast(ctx.netGame->peerStats(net::kHostPlayerId).rttMs); - m_netTrafficCars->update(received, own, rtt * 0.5 + kNetKnockConfirmMs); + m_netTrafficCars->update(received, car, kNetKnockConfirmMs); for (const auto& h : m_netTrafficCars->takeHandovers()) { const float off = m_trafficClient->setDrawn(h.id, h.pose); - game::traceHandover(ctx.netGame->traceFile(), own, h.id, h.confirmed, off); + game::traceHandover(ctx.netGame->traceFile(), car, h.id, h.confirmed, off); if (m_debugNetTraffic) log::info("nettraffic: client's knock of traffic car {} {} the host ({:.2f} m off)", h.id, h.confirmed ? "confirmed by" : "withdrawn: not knocked on", off); @@ -4419,11 +4346,18 @@ class RaceScreen final : public Screen { // A client: the session time this machine's car reaches in this frame's // fixed steps (as sendLocalState stamps it). double netTrafficOwnTime(Context& ctx, float dt) const { - const double lag = m_world ? static_cast(m_world->remainderAfter(dt)) * 1000.0 : 0.0; + const double lag = + m_world ? static_cast(m_world->remainderAfter(physicsDt(ctx, dt))) * 1000.0 : 0.0; return ctx.netGame->frameTime() - lag; } - // ... and the time the received cars are shown at: that car's, so that it - // meets them where the host has them at the same moment. + // ... and the session time that car's state is at on the host: the + // client's car is predicted ahead of the host's, by the time its inputs + // take to reach the host and wait there (m_netCarLead, measured from the + // host's states of it). The received cars are shown at that time, so + // that the car meets them where the host's simulation of it will. + double netTrafficCarTime(Context& ctx, float dt) const { + return netTrafficOwnTime(ctx, dt) + (ctx.netGame->isHost() ? 0.0 : m_netCarLead.value_or(0.0)); + } // OPENMM2_DEBUG_TRAFFIC_LEAD_MS= shows them that much later // (negative: earlier), for measuring the prediction over other horizons. double netTrafficPresentTime(Context& ctx, float dt) const { @@ -4431,7 +4365,7 @@ class RaceScreen final : public Screen { const char* v = std::getenv("OPENMM2_DEBUG_TRAFFIC_LEAD_MS"); return v ? str::parseDouble(v).value_or(0.0) : 0.0; }(); - return netTrafficOwnTime(ctx, dt) + lead; + return netTrafficCarTime(ctx, dt) + lead; } // aiPedestrian::Accident's question on a client: a received car out of @@ -4933,6 +4867,25 @@ class RaceScreen final : public Screen { m_netDamage.player(id).reset(m_netStateTime); } + // Client: how far this machine's car runs ahead of the host's simulation + // of it (OpenMM2's shared traffic is shown that far ahead): the host's + // state after sample `ack` is at session time `time`, and this machine ran + // that sample at the time its newest sample's end since then gives less + // the samples in between. Smoothed (the host's queue of inputs moves it + // by a sample or two). + void updateNetCarLead(std::uint32_t ack, std::uint32_t time) { + if (ack == 0) + return; + const auto anchor = + std::ranges::find_if(m_netSampleTimes, [ack](const auto& e) { return e.first >= ack; }); + if (anchor == m_netSampleTimes.end()) + return; + const double step = static_cast(phys::kFixedSampleStep) * 1000.0 / m_netDilation; + const double ran = anchor->second - static_cast(anchor->first - ack) * step; + const double lead = std::clamp(static_cast(time) - ran, 0.0, 1000.0); + m_netCarLead = m_netCarLead ? *m_netCarLead + (lead - *m_netCarLead) * 0.1 : lead; + } + // Client: the host's states of this machine's car since the last frame: // the newest corrects the prediction when they differ; how many of its // inputs the host had in hand sets how fast the samples run. @@ -4948,6 +4901,7 @@ class RaceScreen final : public Screen { newest = &u.own; ack = u.ack; } + updateNetCarLead(u.ack, u.time); } while (m_netWaiting.size() > 20) // a second of reports m_netWaiting.pop_front(); @@ -5004,6 +4958,12 @@ class RaceScreen final : public Screen { ++m_netInputsSent; m_netInputBytes += net::encodeMessage(*msg).size(); } + // The session time this machine's car's newest sample ended at, + // for the lead over the host (reconcileNetCar). + const double lag = m_world ? static_cast(m_world->remainder()) * 1000.0 : 0.0; + m_netSampleTimes.emplace_back(m_prediction.nextSeq() - 1, ctx.netGame->frameTime() - lag); + while (m_netSampleTimes.size() > 240) + m_netSampleTimes.pop_front(); } const double age = m_world ? static_cast(m_world->remainder()) * 1000.0 : 0.0; ctx.netGame->traceFrame(m_pose.body, m_player->sim().body.ics.frameVelocity, age, m_remoteCars); @@ -5966,6 +5926,10 @@ class RaceScreen final : public Screen { game::CarPrediction m_prediction; // client game::CorrectionBlend m_correction; // client: what corrections moved, drawn away bool m_netReplaying = false; // client: samples run again (no sounds or effects) + // Client: its samples' numbers and the session times they ended at + // (newest last), and how far its car runs ahead of the host's (ms). + std::deque> m_netSampleTimes; + std::optional m_netCarLead; bool m_ownDamageFresh = true; // client: its car's damage record not shown yet float m_netDilation = 1.0f; // client: the rate its samples run at std::deque m_netWaiting; // client: the host's latest counts of its inputs in hand @@ -6003,8 +5967,6 @@ 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 double m_trafficRenderTime = 0.0; // client: the session time the shared cars are drawn at std::unordered_map m_netPhysical; // client: knocked cars with bodies diff --git a/src/game/TrafficBodies.cpp b/src/game/TrafficBodies.cpp index ca1bd77..81e21a6 100644 --- a/src/game/TrafficBodies.cpp +++ b/src/game/TrafficBodies.cpp @@ -725,22 +725,6 @@ 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 3525def..58f95f8 100644 --- a/src/game/TrafficBodies.h +++ b/src/game/TrafficBodies.h @@ -125,15 +125,6 @@ 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/game/net/TrafficProxies.cpp b/src/game/net/TrafficProxies.cpp index 8b0692b..de43b53 100644 --- a/src/game/net/TrafficProxies.cpp +++ b/src/game/net/TrafficProxies.cpp @@ -81,17 +81,6 @@ void TrafficProxies::update(std::span cars) { 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) diff --git a/src/game/net/TrafficProxies.h b/src/game/net/TrafficProxies.h index 6af35a4..f2c3b52 100644 --- a/src/game/net/TrafficProxies.h +++ b/src/game/net/TrafficProxies.h @@ -1,14 +1,14 @@ #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. +// (OpenMM2 extra; see TrafficSync.h): each received traffic car off its rail +// on the host is an instance of the level with its aiVehicleData box (as the +// host's rail cars, game::TrafficBodies), at its predicted place and moving +// at its predicted velocity (phys::Instance::kinematicMotion). The local car +// collides with it as with a moving car of infinite mass; the host simulates +// the same collision with its body and the result comes back with the next +// messages. (The cars on their rails are the client's TrafficBodies', which +// its car knocks loose as the host's does: game::NetTrafficCars.) #include "ai/Traffic.h" #include "game/CityLevel.h" @@ -35,9 +35,6 @@ class TrafficProxies final : public InstanceSource { // 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; diff --git a/src/game/net/TrafficTrace.cpp b/src/game/net/TrafficTrace.cpp index 861c41e..3563b76 100644 --- a/src/game/net/TrafficTrace.cpp +++ b/src/game/net/TrafficTrace.cpp @@ -2,8 +2,6 @@ // TrafficTrace.h. #include "game/net/TrafficTrace.h" -#include - namespace mm2::game { namespace { @@ -49,11 +47,6 @@ void traceKnock(std::FILE* f, double time, int id, int generation, int player) { std::fprintf(f, "TK %.3f %d %d %d\n", time, id, generationOf(generation), player); } -void traceRefusedHit(std::FILE* f, double time, int from, int id, int generation, std::string_view why) { - if (f) - std::fprintf(f, "TR %.3f %d %d %d %s\n", time, from, id, generation, std::string(why).c_str()); -} - void traceHandover(std::FILE* f, double time, int id, bool confirmed, float off) { if (f) std::fprintf(f, "TL %.3f %d %d %.3f\n", time, id, confirmed ? 1 : 0, off); diff --git a/src/game/net/TrafficTrace.h b/src/game/net/TrafficTrace.h index 5fb560e..167b9ac 100644 --- a/src/game/net/TrafficTrace.h +++ b/src/game/net/TrafficTrace.h @@ -12,13 +12,14 @@ // TC frame time id gen kind x y z fx fz speed flags target mode // client: a car where its collisions meet it (the physics proxy) for the // frame that began at session time `frame`, placed for session time -// `time`; mode 0 interpolated, 1 extrapolated, 2 predicted +// `time`; mode 0 interpolated, 1 extrapolated, 2 predicted, 3 knocked +// loose by this machine's car (where its body is) // TV frame time x y z -// client: where this machine's car is for the same frame, and its time -// TX time id gen client: reported hitting car id -// TK time id gen player host: knocked car id off its rail (player 255: -// its own simulation's collision) -// TR time from id gen why host: a client's hit report it did not apply +// client: where this machine's car is for the same frame, and the +// session time its state is at on the host's clock (its own time plus +// its lead over the host's simulation of it) +// TX time id gen client: its car knocked car id loose +// TK time id gen player host: car id left its rail (player 255) // TL time id confirmed off // client: a car it knocked loose went back to the // host's messages (confirmed 1: the host knocked it @@ -30,7 +31,6 @@ #include #include -#include namespace mm2::game { @@ -39,7 +39,6 @@ void traceClientCar(std::FILE* f, double frame, double time, const TrafficClient void traceClientView(std::FILE* f, double frame, double time, const Vec3& position); void traceHit(std::FILE* f, double time, int id, int generation); void traceKnock(std::FILE* f, double time, int id, int generation, int player); -void traceRefusedHit(std::FILE* f, double time, int from, int id, int generation, std::string_view why); void traceHandover(std::FILE* f, double time, int id, bool confirmed, float off); } // namespace mm2::game diff --git a/src/net/AmbientState.h b/src/net/AmbientState.h index c2f326d..0a6fde2 100644 --- a/src/net/AmbientState.h +++ b/src/net/AmbientState.h @@ -238,28 +238,4 @@ 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 diff --git a/tests/game/test_traffic_net.cpp b/tests/game/test_traffic_net.cpp index 729aaf9..1eb957b 100644 --- a/tests/game/test_traffic_net.cpp +++ b/tests/game/test_traffic_net.cpp @@ -130,24 +130,6 @@ TEST(SharedTrafficNet, HostAndClientInOneProcess) { 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(); diff --git a/tools/mm2tool/cmd_nettrace.cpp b/tools/mm2tool/cmd_nettrace.cpp index dd29068..221d32c 100644 --- a/tools/mm2tool/cmd_nettrace.cpp +++ b/tools/mm2tool/cmd_nettrace.cpp @@ -91,7 +91,7 @@ struct Event { struct HostTrace { std::map> cars; - std::vector knocks, refused; + std::vector knocks; }; struct ClientTrace { @@ -120,8 +120,6 @@ bool readHost(const char* path, HostTrace& out) { out.cars[{integer(f[2]), integer(f[3])}].push_back(s); } else if (f[0] == "TK" && f.size() >= 5) { out.knocks.push_back({number(f[1]), integer(f[2]), integer(f[3]), integer(f[4])}); - } else if (f[0] == "TR" && f.size() >= 5) { - out.refused.push_back({number(f[1]), integer(f[3]), integer(f[4]), integer(f[2])}); } } for (auto& [key, samples] : out.cars) @@ -287,29 +285,16 @@ int cmdNettrace(std::span args) { share(extra, present + extra)); if (copFrames) std::println("police targets differ in {} of {} car-frames", copTargets, copFrames); - std::size_t applied = 0, refused = 0, unanswered = 0; - for (const Event& hit : client.hits) { - const auto same = [&](const Event& e) { + // The cars the client's car hit that the host had off their rails within + // 2.5 s too, and the host's knocks. + const auto knockedToo = std::ranges::count_if(client.hits, [&](const Event& hit) { + return std::ranges::any_of(host.knocks, [&](const Event& e) { return e.id == hit.id && e.generation == hit.generation && std::abs(e.time - hit.time) < 2500.0; - }; - if (std::ranges::any_of(host.knocks, [&](const Event& e) { return same(e) && e.player != 255; })) - ++applied; - else if (std::ranges::any_of(host.refused, same)) - ++refused; - else - ++unanswered; - } - // The host's own knocks: those no report made (a report's knock is - // listed twice, with the player and as the car leaving its rail). - const auto native = std::ranges::count_if(host.knocks, [&](const Event& e) { - return e.player == 255 && std::ranges::none_of(host.knocks, [&](const Event& r) { - return r.player != 255 && r.id == e.id && r.generation == e.generation && - std::abs(r.time - e.time) < 200.0; - }); + }); }); - std::println("client hit reports {}: applied {}, refused {}, unanswered {}; cars the host knocked off " + std::println("cars the client's car hit {}: off their rails on the host too {}; cars the host knocked off " "their rails {}", - client.hits.size(), applied, refused, unanswered, native); + client.hits.size(), knockedToo, host.knocks.size()); if (!client.handovers.empty()) { std::vector confirmed, withdrawn; for (const Event& h : client.handovers) From 19e82710991c2f1daea8d989731f8dcfe0371a34 Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Fri, 9 Oct 2026 14:06:59 -0700 Subject: [PATCH 025/129] Run a client's corrected samples against the other cars where they stood When the host corrects a client's car, the samples run again now meet the other players' cars where each sample first met them, not where they stand in the frame of the correction: pushing another car from behind, replaying against its newest place made the corrections swing back and forth by up to 4 m. Also an experiment for the record, OPENMM2_NET_OTHERS: "ahead" places and draws the other cars where the host will have them when it runs this machine's sample (extrapolated from its newest states), "ghost" keeps this machine's car from touching them. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/app/RaceScreen.cpp | 100 +++++++++++++++++++++++++++++++----- src/game/net/NetGame.cpp | 15 +++++- src/game/net/NetGame.h | 6 +++ src/game/net/PlayerCars.cpp | 5 +- src/game/net/PlayerCars.h | 9 ++-- src/net/Session.cpp | 10 +++- src/net/Session.h | 5 +- 7 files changed, 128 insertions(+), 22 deletions(-) diff --git a/src/app/RaceScreen.cpp b/src/app/RaceScreen.cpp index 1fe937e..52a5f09 100644 --- a/src/app/RaceScreen.cpp +++ b/src/app/RaceScreen.cpp @@ -4477,14 +4477,28 @@ class RaceScreen final : public Screen { return; } const double lagMs = static_cast(m_world->remainderAfter(physicsDt(ctx, dt))) * 1000.0; - m_remoteCars = ctx.netGame->remoteCars(lagMs); + // Development aid (an experiment, docs/review/multiplayer-desync-cars.md): + // OPENMM2_NET_OTHERS=ahead places and draws the other cars where the + // host will have them when it runs this machine's sample (extrapolated + // from its newest states), =ghost keeps this machine's car from + // touching them (only the host's collision counts). + static const std::string others = [] { + const char* v = std::getenv("OPENMM2_NET_OTHERS"); + return std::string(v ? v : ""); + }(); + const bool ahead = others == "ahead"; + m_remoteCars = + ahead ? ctx.netGame->remoteCarsAhead(lagMs, m_netLead) : ctx.netGame->remoteCars(lagMs); // The session time the simulation's state will belong to after this // frame's steps. m_netStateTime = static_cast(std::max(0.0, ctx.netGame->frameTime() - lagMs)); // Drawn where the rest of the scene is, a sample behind the frame // (game::StepHistory): alpha x step = the remainder. m_remoteDrawn.clear(); - for (const auto& rc : ctx.netGame->remoteCars(static_cast(phys::kFixedSampleStep) * 1000.0)) + const double stepMs = static_cast(phys::kFixedSampleStep) * 1000.0; + const auto drawn = + ahead ? ctx.netGame->remoteCarsAhead(stepMs, m_netLead) : ctx.netGame->remoteCars(stepMs); + for (const auto& rc : drawn) if (rc.hasState) m_remoteDrawn[rc.id] = rc.transform; // A player who quit the race takes its car out of it. @@ -4517,7 +4531,7 @@ class RaceScreen final : public Screen { 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 + sim.body.declare(3, others == "ghost" ? 0x0b : 0x1b); // mmNetObject::Update if (auto* trailer = rv.sim->trailer()) { trailer->body.declare(3, 0x1b); if (jumped) @@ -4745,8 +4759,13 @@ class RaceScreen final : public Screen { } return; } - // A client: its car's first sample puts it where this machine - // started it, on the host too. + // A client: what the other players' cars stood at for this sample + // (the samples run again meet them there). + m_proxyHistory.push_back({m_prediction.nextSeq(), proxyPoses()}); + while (m_proxyHistory.size() > 240) + m_proxyHistory.pop_front(); + // Its car's first sample puts it where this machine started it, on + // the host too. if (m_prediction.nextSeq() == 1) m_prediction.command(*m_player, {0, net::CarCommandKind::ResetTo, m_player->sim().resetPos(), m_player->sim().resetRotation}); @@ -4795,16 +4814,35 @@ class RaceScreen final : public Screen { } if (!newest) return; + // How far ahead of the host this car runs: what OPENMM2_NET_OTHERS=ahead + // places the others by (the samples not yet acknowledged, less the + // way back). + { + const std::uint32_t sent = m_prediction.nextSeq() - 1; + const double unacked = static_cast(sent - std::min(ack, sent)); + const double lead = unacked * static_cast(phys::kFixedSampleStep) * 1000.0 - + static_cast(ctx.netGame->peerStats(net::kHostPlayerId).rttMs) * 0.5; + m_netLead = m_netLead < 0.0 ? lead : m_netLead + (lead - m_netLead) * 0.1; + } const game::VehiclePose before = drawnPose(game::Drawn::Player, 0, *m_player); + const auto now = proxyPoses(); m_netReplaying = true; - const auto c = m_prediction.acknowledge(*m_player, m_netDriver, *m_world, ack, *newest, [this] { - // What the drawing blends from: the car before its last sample. - const std::uint32_t resets = m_player->sim().resets; - m_drawnPhys.record(game::drawnKey(game::Drawn::Player, 0), m_player->pose(), resets); - if (m_player->trailer()) - m_drawnPhys.record(game::drawnKey(game::Drawn::PlayerTrailer, 0), m_player->trailerPose(), - resets); - }); + const auto c = m_prediction.acknowledge( + *m_player, m_netDriver, *m_world, ack, *newest, + [this] { + // What the drawing blends from: the car before its last sample. + const std::uint32_t resets = m_player->sim().resets; + m_drawnPhys.record(game::drawnKey(game::Drawn::Player, 0), m_player->pose(), resets); + if (m_player->trailer()) + m_drawnPhys.record(game::drawnKey(game::Drawn::PlayerTrailer, 0), m_player->trailerPose(), + resets); + }, + [this](std::uint32_t seq) { + for (const auto& h : m_proxyHistory) + if (h.seq == seq) + placeProxies(h.poses); + }); + placeProxies(now); m_netReplaying = false; if (!c.corrected) return; @@ -4822,6 +4860,36 @@ class RaceScreen final : public Screen { c.damage ? ", damage" : "", c.held ? ", held" : "", c.gear ? ", gear" : ""); } + // Client: the other players' cars' bodies as they stand, and put back + // there (the samples run again, reconcileNetCar). + struct ProxyPose { + std::uint8_t id = 0; + Mat34 ics; + Vec3 velocity, spin; + }; + std::vector proxyPoses() const { + std::vector out; + for (const auto& [id, rv] : m_remotes) + if (rv.sim && !rv.simulated) { + const auto& body = rv.sim->sim().body; + out.push_back({id, body.ics.matrix, body.kinematicVelocity, body.kinematicSpin}); + } + return out; + } + void placeProxies(const std::vector& poses) { + for (const auto& p : poses) { + const auto it = m_remotes.find(p.id); + if (it == m_remotes.end() || !it->second.sim || it->second.simulated) + continue; + auto& body = it->second.sim->sim().body; + body.place(p.ics); + body.ics.linearVelocity = p.velocity; + body.ics.angularVelocity = p.spin; + body.kinematicVelocity = p.velocity; + body.kinematicSpin = p.spin; + } + } + // After the frame's samples: the host sends each client its states, a // client the inputs the host has not acknowledged. void afterNetSamples(Context& ctx) { @@ -5802,6 +5870,12 @@ class RaceScreen final : public Screen { bool m_netReplaying = false; // client: samples run again (no sounds or effects) bool m_ownDamageFresh = true; // client: its car's damage record not shown yet float m_netDilation = 1.0f; // client: the rate its samples run at + double m_netLead = -1.0; // client: how far (ms) its car runs ahead of the host + struct ProxyHistory { + std::uint32_t seq = 0; + std::vector poses; + }; + std::deque m_proxyHistory; // client: the other cars at each recent sample std::deque m_netWaiting; // client: the host's latest counts of its inputs in hand std::uint64_t m_netCarsSentAt = 0; // host: the last CarStates std::uint64_t m_netStatsAt = 0; // the last log of the statistics diff --git a/src/game/net/NetGame.cpp b/src/game/net/NetGame.cpp index cc49d40..590b0d6 100644 --- a/src/game/net/NetGame.cpp +++ b/src/game/net/NetGame.cpp @@ -689,6 +689,14 @@ void NetGame::submitLocalState(const Mat34& transform, const Vec3& velocity, con } std::vector NetGame::remoteCars(double stateAgeMs) const { + return sampleRemoteCars(stateAgeMs, -1.0); +} + +std::vector NetGame::remoteCarsAhead(double stateAgeMs, double leadMs) const { + return sampleRemoteCars(stateAgeMs, std::max(0.0, leadMs)); +} + +std::vector NetGame::sampleRemoteCars(double stateAgeMs, double leadMs) const { std::vector out; if (!m_impl->session) return out; @@ -706,8 +714,11 @@ std::vector NetGame::remoteCars(double stateAgeMs) const { continue; } net::VehicleSnapshot snap; - car.time = at - m_impl->session->playoutDelay(p.id); - const auto r = m_impl->session->sampleRemoteAt(p.id, car.time, snap); + const double delay = m_impl->session->playoutDelay(p.id); + car.time = leadMs < 0.0 ? at - delay : at + leadMs; + const auto r = leadMs < 0.0 + ? m_impl->session->sampleRemoteAt(p.id, car.time, snap) + : m_impl->session->sampleRemoteAt(p.id, car.time, snap, delay + leadMs + 250.0); if (r != net::SnapshotBuffer::Result::Empty) { car.hasState = true; car.stale = r != net::SnapshotBuffer::Result::Interpolated; diff --git a/src/game/net/NetGame.h b/src/game/net/NetGame.h index 86935c5..e1ef5fa 100644 --- a/src/game/net/NetGame.h +++ b/src/game/net/NetGame.h @@ -283,6 +283,10 @@ class NetGame { // car when its RaceReady or SendGameSet arrives: mmGameMulti:: // GameMessageCB 0x1f6, 0x1fa). std::vector remoteCars(double stateAgeMs = 0.0) const; + // The same `leadMs` ahead of the frame's session time (less + // `stateAgeMs`), extrapolated from the newest snapshots rather than + // shown a playout delay in the past (an experiment: OPENMM2_NET_OTHERS). + std::vector remoteCarsAhead(double stateAgeMs, double leadMs) 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). @@ -343,6 +347,8 @@ class NetGame { private: struct Impl; void handleEvents(); + // remoteCars (leadMs < 0: a playout delay in the past) and remoteCarsAhead. + std::vector sampleRemoteCars(double stateAgeMs, double leadMs) const; void addSystemLine(std::string text); void addChatLine(NetChatLine line); std::string playerName(std::uint8_t id) const; diff --git a/src/game/net/PlayerCars.cpp b/src/game/net/PlayerCars.cpp index f6e0dd6..38177a7 100644 --- a/src/game/net/PlayerCars.cpp +++ b/src/game/net/PlayerCars.cpp @@ -359,7 +359,8 @@ std::optional CarPrediction::message() const { CarPrediction::Correction CarPrediction::acknowledge(SimVehicle& car, NetCarDriver& driver, phys::World& world, std::uint32_t ack, const net::OwnCarState& host, - const std::function& beforeLast) { + const std::function& beforeLast, + const std::function& beforeEach) { Correction out; if (ack <= m_acked) return out; // an older or repeated state @@ -407,6 +408,8 @@ CarPrediction::Correction CarPrediction::acknowledge(SimVehicle& car, NetCarDriv for (const auto& c : e.commands) NetCarDriver::command(car, c); driver.apply(car, e.input); + if (beforeEach) + beforeEach(e.seq); if (k + 1 == m_history.size() && beforeLast) beforeLast(); world.replaySample(std::span(bodies, count), phys::kFixedSampleStep); diff --git a/src/game/net/PlayerCars.h b/src/game/net/PlayerCars.h index 8d3278f..cf4c56d 100644 --- a/src/game/net/PlayerCars.h +++ b/src/game/net/PlayerCars.h @@ -176,10 +176,13 @@ class CarPrediction { // The host's state after sample `ack`. Forgets what it acknowledged; when // the state differs from the prediction for that sample, puts the car // there and runs the later samples again on their inputs (alone: - // phys::World::replaySample), calling `beforeLast` before the last one - // (the drawing keeps the car's pose before its last sample). + // phys::World::replaySample), calling `beforeEach` with each sample's + // number before it (the other players' cars put back where they stood + // when it first ran) and `beforeLast` before the last one (the drawing + // keeps the car's pose before its last sample). Correction acknowledge(SimVehicle& car, NetCarDriver& driver, phys::World& world, std::uint32_t ack, - const net::OwnCarState& host, const std::function& beforeLast = {}); + const net::OwnCarState& host, const std::function& beforeLast = {}, + const std::function& beforeEach = {}); // Statistics since the start. struct Stats { diff --git a/src/net/Session.cpp b/src/net/Session.cpp index 4f7c23a..e6ad1e5 100644 --- a/src/net/Session.cpp +++ b/src/net/Session.cpp @@ -1285,11 +1285,17 @@ void Session::resetReplication() { m_ambientStates.clear(); } -SnapshotBuffer::Result Session::sampleRemoteAt(std::uint8_t playerId, double sessionTime, VehicleSnapshot& out) const { +SnapshotBuffer::Result Session::sampleRemoteAt(std::uint8_t playerId, double sessionTime, + VehicleSnapshot& out) const { + return sampleRemoteAt(playerId, sessionTime, out, m_config.maxExtrapolationMs); +} + +SnapshotBuffer::Result Session::sampleRemoteAt(std::uint8_t playerId, double sessionTime, + VehicleSnapshot& out, double maxExtrapolationMs) const { const auto it = m_remoteStates.find(playerId); if (it == m_remoteStates.end()) return SnapshotBuffer::Result::Empty; - return it->second.buffer.sample(sessionTime, out, m_config.maxExtrapolationMs); + return it->second.buffer.sample(sessionTime, out, maxExtrapolationMs); } SnapshotBuffer::Result Session::sampleRemoteDelayed(std::uint8_t playerId, double sessionTimeMs, diff --git a/src/net/Session.h b/src/net/Session.h index 2f7afa8..c881188 100644 --- a/src/net/Session.h +++ b/src/net/Session.h @@ -229,8 +229,11 @@ class Session { // (`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`. + // Remote vehicle state at exactly `sessionTime` (extrapolated at most + // `maxExtrapolationMs` past the newest snapshot; default the config's). SnapshotBuffer::Result sampleRemoteAt(std::uint8_t playerId, double sessionTime, VehicleSnapshot& out) const; + SnapshotBuffer::Result sampleRemoteAt(std::uint8_t playerId, double sessionTime, VehicleSnapshot& out, + double maxExtrapolationMs) 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, From ee48adca4094a66080e460776520b5be0df8d69e Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Fri, 9 Oct 2026 14:07:35 -0700 Subject: [PATCH 026/129] Trace the props in OPENMM2_NET_TRACE and report them in syncreport The props' lines (placed props, knocks and their causes, undone predictions, and every 250 ms of session time the props down, the pieces and parts shown, the cars' damage and the own car's damaging impacts) go into the session's divergence trace with lowercase tags, and `netprobe syncreport` compares every pair of machines' props after the cars. OPENMM2_DEBUG_NETPROPS and `netprobe propdiff` are gone. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/game/net/NetGame.cpp | 8 +++++ src/game/net/NetGame.h | 3 ++ src/game/net/NetProps.cpp | 47 +++++++++------------------ src/game/net/NetProps.h | 30 +++++++++--------- tools/netprobe/PropDiff.cpp | 60 ++++++++++++++++++++++------------- tools/netprobe/PropDiff.h | 8 +++-- tools/netprobe/SyncReport.cpp | 13 ++++++-- tools/netprobe/main.cpp | 9 ++---- 8 files changed, 97 insertions(+), 81 deletions(-) diff --git a/src/game/net/NetGame.cpp b/src/game/net/NetGame.cpp index 2d65c0d..ab11b9d 100644 --- a/src/game/net/NetGame.cpp +++ b/src/game/net/NetGame.cpp @@ -787,6 +787,14 @@ void NetGame::traceCorrection(std::uint32_t seq, int replayed, const Vec3& dx, f replayed, dx.x, dx.y, dx.z, dv, snapped ? 1 : 0); } +void NetGame::traceLine(std::string_view line) { + std::FILE* f = m_impl->trace.get(); + if (!f || !m_impl->session) + return; + std::fwrite(line.data(), 1, line.size(), f); + std::fputc('\n', f); +} + 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 32e4cc1..01da6df 100644 --- a/src/game/net/NetGame.h +++ b/src/game/net/NetGame.h @@ -304,6 +304,9 @@ class NetGame { void traceDrawn(std::uint8_t id, bool own, const Mat34& transform, const Vec3& velocity); void traceImpact(std::uint8_t a, std::uint8_t b, const Vec3& position, float strength, double stateAgeMs); void traceCorrection(std::uint32_t seq, int replayed, const Vec3& dx, float dv, bool snapped); + // Another area's line in the same trace, written as given: a lowercase + // tag and its fields (the props', game::PropTrace). + void traceLine(std::string_view line); // --- The players' cars, simulated by the host (net/PlayerCars.h) ------------------- // Client: this machine's car's inputs. diff --git a/src/game/net/NetProps.cpp b/src/game/net/NetProps.cpp index 85218d4..fd13a6d 100644 --- a/src/game/net/NetProps.cpp +++ b/src/game/net/NetProps.cpp @@ -15,22 +15,8 @@ namespace mm2::game { // --- Trace --------------------------------------------------------------------------------------- -std::unique_ptr PropTrace::fromEnvironment() { - const char* path = std::getenv("OPENMM2_DEBUG_NETPROPS"); - if (!path || !*path) - return nullptr; - std::FILE* f = std::fopen(path, "w"); - if (!f) { - log::warn("netprops: cannot write the trace {}", path); - return nullptr; - } - std::fputs("# OpenMM2 network props trace 1\n", f); - return std::make_unique(f); -} - -PropTrace::~PropTrace() { - if (m_file) - std::fclose(m_file); +std::unique_ptr PropTrace::of(NetGame& net) { + return net.tracing() ? std::make_unique(net) : nullptr; } std::string describe(const net::PropDescriptor& what) { @@ -48,21 +34,19 @@ void PropTrace::placed(const bangers::BangerSet& set) { const std::size_t n = placedProps(set); for (std::size_t i = 0; i < n; ++i) { const auto& inst = set.instances()[i]; - std::fprintf(m_file, "P %zu %s %.2f %.2f %.2f\n", i, inst.model.c_str(), inst.ground.m3.x, - inst.ground.m3.y, inst.ground.m3.z); + const Vec3& p = inst.ground.m3; + m_net.traceLine(std::format("p {} {} {:.2f} {:.2f} {:.2f}", i, inst.model, p.x, p.y, p.z)); } } void PropTrace::knock(double t, std::size_t prop, std::string_view model, std::string_view cause) { - std::fprintf(m_file, "K %.0f %zu %.*s %.*s\n", t, prop, static_cast(model.size()), model.data(), - static_cast(cause.size()), cause.data()); + m_net.traceLine(std::format("k {:.0f} {} {} {}", t, prop, model, cause)); } -void PropTrace::undo(double t, std::size_t prop) { std::fprintf(m_file, "X %.0f %zu\n", t, prop); } +void PropTrace::undo(double t, std::size_t prop) { m_net.traceLine(std::format("x {:.0f} {}", t, prop)); } void PropTrace::impact(double t, std::string_view cause, float value, float damage) { - std::fprintf(m_file, "I %.0f %.*s %.1f %.1f\n", t, static_cast(cause.size()), cause.data(), value, - damage); + m_net.traceLine(std::format("i {:.0f} {} {:.1f} {:.1f}", t, cause, value, damage)); } bool PropTrace::tick(double t) { @@ -71,28 +55,27 @@ bool PropTrace::tick(double t) { m_next = std::ceil(t / kTick) * kTick; if (t < m_next) return false; - std::fprintf(m_file, "T %.0f %.1f\n", m_next, t); + m_net.traceLine(std::format("t {:.0f} {:.1f}", m_next, t)); m_next += kTick * (std::floor((t - m_next) / kTick) + 1.0); - std::fflush(m_file); return true; } void PropTrace::broken(const bangers::BangerSet& set) { - std::fputs("B", m_file); + std::string line = "b"; const std::size_t n = placedProps(set); for (std::size_t i = 0; i < n; ++i) if (!set.standing(i)) - std::fprintf(m_file, " %zu", i); - std::fputs("\n", m_file); + line += std::format(" {}", i); + m_net.traceLine(line); } void PropTrace::shown(const net::PropDescriptor& what, const Mat34& m, bool moving, bool local) { - std::fprintf(m_file, "H %s %.3f %.3f %.3f %d %d\n", describe(what).c_str(), m.m3.x, m.m3.y, m.m3.z, - moving ? 1 : 0, local ? 1 : 0); + m_net.traceLine(std::format("h {} {:.3f} {:.3f} {:.3f} {} {}", describe(what), m.m3.x, m.m3.y, m.m3.z, + moving ? 1 : 0, local ? 1 : 0)); } void PropTrace::car(std::string_view name, float level, float dents) { - std::fprintf(m_file, "D %.*s %.4f %.0f\n", static_cast(name.size()), name.data(), level, dents); + m_net.traceLine(std::format("d {} {:.4f} {:.0f}", name, level, dents)); } // --- NetProps ------------------------------------------------------------------------------------ @@ -104,7 +87,7 @@ void NetProps::setup(NetGame& net, bangers::BangerSet& set, std::function localToucher) { m_set = &set; m_catalog = propCatalog(set); - m_trace = PropTrace::fromEnvironment(); + m_trace = PropTrace::of(net); if (m_trace) m_trace->placed(set); set.recordKnocks(true); diff --git a/src/game/net/NetProps.h b/src/game/net/NetProps.h index d399eff..73866da 100644 --- a/src/game/net/NetProps.h +++ b/src/game/net/NetProps.h @@ -10,7 +10,6 @@ #include "game/net/PropSync.h" #include -#include #include #include #include @@ -24,23 +23,24 @@ namespace mm2::game { class NetGame; struct NetGameEvent; -// Development aid: OPENMM2_DEBUG_NETPROPS= writes, one line each, the -// placed props ("P ..."), every placed prop that broke loose on this machine ("K -// "), every knock this machine predicted and undid ("X ..."), -// and every 250 ms of session time ("T ") the props not standing -// ("B "), the knocked-over props and thrown parts shown ("H -// "), the cars' damage ("D ") -// and this machine's car's damaging impacts ("I -// "). `netprobe propdiff` compares two machines' files. +// Development aid: the props' lines of the OPENMM2_NET_TRACE file (lowercase +// tags; `netprobe syncreport` compares the machines' props from them): the +// placed props once ("p "), every placed prop that +// broke loose on this machine ("k "), every +// knock this machine predicted and undid ("x "), and every 250 ms +// of session time ("t ") the props not standing ("b "), +// the knocked-over props and thrown parts shown ("h +// "), the cars' damage ("d ") and this +// machine's car's damaging impacts ("i "). class PropTrace { public: - static std::unique_ptr fromEnvironment(); - explicit PropTrace(std::FILE* file) : m_file(file) {} - ~PropTrace(); + // Null unless the session traces (NetGame::tracing). + static std::unique_ptr of(NetGame& net); + explicit PropTrace(NetGame& net) : m_net(net) {} PropTrace(const PropTrace&) = delete; PropTrace& operator=(const PropTrace&) = delete; - // Every placed prop once ("P ", its ground point). + // Every placed prop once (its ground point). void placed(const bangers::BangerSet& set); void knock(double t, std::size_t prop, std::string_view model, std::string_view cause); void undo(double t, std::size_t prop); @@ -53,7 +53,7 @@ class PropTrace { void car(std::string_view name, float level, float dents); private: - std::FILE* m_file = nullptr; + NetGame& m_net; double m_next = -1.0; }; @@ -91,7 +91,7 @@ class NetProps { using Classifier = std::function; void afterStep(NetGame& net, double now, std::uint64_t nowMs, const Classifier& classify); - // The trace, when OPENMM2_DEBUG_NETPROPS is set; `traced()` tells whether + // The trace, when OPENMM2_NET_TRACE is set; `traced()` tells whether // afterStep began a tick this frame (the race adds its cars' lines). PropTrace* trace() { return m_trace.get(); } bool traced() const { return m_traced; } diff --git a/tools/netprobe/PropDiff.cpp b/tools/netprobe/PropDiff.cpp index d9db172..cbe82a8 100644 --- a/tools/netprobe/PropDiff.cpp +++ b/tools/netprobe/PropDiff.cpp @@ -1,7 +1,7 @@ -// netprobe propdiff : how two machines' props differ in a -// network race, from their OPENMM2_DEBUG_NETPROPS traces (game::PropTrace, -// docs/multiplayer.md "Props"). Same-named session times ("T" ticks every -// 250 ms) are compared: +// The props section of `netprobe syncreport`: how the machines' props differ +// in a network race, from the props lines of their OPENMM2_NET_TRACE files +// (game::PropTrace, docs/multiplayer.md "Props"). Same-named session times +// (the "t" ticks every 250 ms) are compared, pair by pair: // // knocks the placed props knocked by the end on both, on one only, and // how far apart in session time both knocked them @@ -10,7 +10,7 @@ // resting at the last common tick, how far apart the same knocked-over // prop or thrown part rests (both at rest), and the ones only // one machine shows -// damage each car's damage at the common ticks +// damage each car's damage level and dents at the common ticks // impacts the damaging impacts on each machine's own car, by cause #include "PropDiff.h" @@ -68,27 +68,27 @@ bool load(const std::string& path, Trace& t) { std::istringstream s(line); std::string kind; s >> kind; - if (kind == "K") { + if (kind == "k") { double time; long prop; Knock k; s >> time >> prop >> k.model >> k.cause; k.time = time; t.knocks.try_emplace(prop, k); - } else if (kind == "X") { + } else if (kind == "x") { double time; long prop; s >> time >> prop; t.undone[prop] = time; - } else if (kind == "T") { + } else if (kind == "t") { double at; s >> at; tick = &t.ticks[static_cast(at)]; - } else if (kind == "B" && tick) { + } else if (kind == "b" && tick) { long id; while (s >> id) tick->broken.insert(id); - } else if (kind == "H" && tick) { + } else if (kind == "h" && tick) { std::string what; Shown h; int moving = 0, local = 0; @@ -96,13 +96,13 @@ bool load(const std::string& path, Trace& t) { h.moving = moving != 0; h.local = local != 0; tick->shown[what] = h; - } else if (kind == "D" && tick) { + } else if (kind == "d" && tick) { std::string car; double level = 0, dents = 0; s >> car >> level >> dents; tick->damage[car] = level; tick->dents[car] = dents; - } else if (kind == "I") { + } else if (kind == "i") { double time, value, damage; std::string cause; s >> time >> cause >> value >> damage; @@ -132,21 +132,18 @@ std::string kindOf(const std::string& model) { } // namespace -int propDiff(const std::string& pathA, const std::string& pathB) { - Trace a, b; - if (!load(pathA, a) || !load(pathB, b)) { - std::println(stderr, "propdiff: cannot read the traces"); - return 1; - } +namespace { + +void comparePair(Trace& a, Trace& b) { std::vector common; for (const auto& [at, tick] : a.ticks) if (b.ticks.contains(at)) common.push_back(at); if (common.empty()) { - std::println(stderr, "propdiff: the traces share no session time"); - return 1; + std::println("\nA {}, B {}: no session time in common", a.name, b.name); + return; } - std::println("A {}\nB {}\n{} common ticks, session {} .. {} ms", pathA, pathB, common.size(), + std::println("\nA {}, B {}: {} common ticks, session {} .. {} ms", a.name, b.name, common.size(), common.front(), common.back()); // Knocks by the end (the last common tick's standing lists). @@ -292,7 +289,26 @@ int propDiff(const std::string& pathA, const std::string& pathB) { }; std::println("damaging impacts on A's own car:{}", impacts(a)); std::println("damaging impacts on B's own car:{}", impacts(b)); - return 0; +} + +} // namespace + +void propReport(const std::vector& paths, const std::vector& names) { + std::vector traces; + for (std::size_t i = 0; i < paths.size(); ++i) { + Trace t; + if (!load(paths[i], t) || t.ticks.empty()) + continue; + t.name = i < names.size() ? names[i] : paths[i]; + traces.push_back(std::move(t)); + } + if (traces.size() < 2) + return; + std::println("\nProps (the traces' props lines): knocked props, where knocked-over props and parts"); + std::println("rest, the cars' damage, pair by pair at the same session times:"); + for (std::size_t i = 0; i < traces.size(); ++i) + for (std::size_t j = i + 1; j < traces.size(); ++j) + comparePair(traces[i], traces[j]); } } // namespace mm2::netprobe diff --git a/tools/netprobe/PropDiff.h b/tools/netprobe/PropDiff.h index f647039..5b0f06f 100644 --- a/tools/netprobe/PropDiff.h +++ b/tools/netprobe/PropDiff.h @@ -1,11 +1,13 @@ #pragma once #include +#include namespace mm2::netprobe { -// netprobe propdiff: compares two machines' OPENMM2_DEBUG_NETPROPS traces -// (see PropDiff.cpp). Returns the process exit code. -int propDiff(const std::string& pathA, const std::string& pathB); +// The props section of `netprobe syncreport`: every pair of the machines' +// OPENMM2_NET_TRACE files compared by their props lines (see PropDiff.cpp), +// named `names`. Prints nothing when fewer than two traces have props lines. +void propReport(const std::vector& paths, const std::vector& names); } // namespace mm2::netprobe diff --git a/tools/netprobe/SyncReport.cpp b/tools/netprobe/SyncReport.cpp index 32a0b58..d902859 100644 --- a/tools/netprobe/SyncReport.cpp +++ b/tools/netprobe/SyncReport.cpp @@ -1,7 +1,8 @@ // netprobe syncreport: how far apart the machines of a network race show the // players' cars, how often a car jumps, and which collisions between players -// one machine had and another did not. Reads each machine's -// OPENMM2_NET_TRACE file (the D, K and C lines, see NetGame::traceDrawn). +// one machine had and another did not, and how their props differ. Reads +// each machine's OPENMM2_NET_TRACE file (the D, K and C lines, see +// NetGame::traceDrawn; the props' lowercase lines, game::PropTrace). // // The machines must have run on one computer: "the same moment" is the same // monotonic wall clock, which every process on a machine shares, so the @@ -9,6 +10,8 @@ #include "SyncReport.h" +#include "PropDiff.h" + #include #include #include @@ -373,6 +376,12 @@ int syncReport(const SyncReportOptions& o) { size.pct(0.5), size.pct(0.9), size.pct(0.99), size.pct(1.0), over10cm, over1, snaps, replayed.pct(0.5), replayed.pct(1.0)); } + + // 5. The props (game::PropTrace's lines). + std::vector names; + for (std::size_t m = 0; m < ms.size(); ++m) + names.push_back(who(ms, m)); + propReport(o.traces, names); return 0; } diff --git a/tools/netprobe/main.cpp b/tools/netprobe/main.cpp index 7734594..163599e 100644 --- a/tools/netprobe/main.cpp +++ b/tools/netprobe/main.cpp @@ -9,10 +9,8 @@ // [--seed N] [--seconds N] // netprobe syncreport ... [--skip S] [--teleport M] // divergence between machines (OPENMM2_NET_TRACE files) -// netprobe propdiff how two machines' props differ (OPENMM2_DEBUG_NETPROPS) // // --seconds 0 runs until Ctrl-C. Port mappings are removed on exit. -#include "PropDiff.h" #include "SyncReport.h" #include "core/Log.h" #include "core/StringUtil.h" @@ -384,7 +382,7 @@ int cmdRelay(const Args& a) { } 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" @@ -392,8 +390,7 @@ int usage() { " 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]\n" - " syncreport ... [--skip S] [--teleport M]\n" - " propdiff "); + " syncreport ... [--skip S] [--teleport M]"); return 2; } @@ -430,7 +427,5 @@ int main(int argc, char** argv) { o.teleportMetres = str::parseDouble(args.get("--teleport")).value_or(o.teleportMetres); return netprobe::syncReport(o); } - if (cmd == "propdiff" && args.positional.size() == 2) - return netprobe::propDiff(args.positional[0], args.positional[1]); return usage(); } From 07919964ffe6bc89296856ec9a2d66a39f6a27d9 Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Fri, 9 Oct 2026 14:07:45 -0700 Subject: [PATCH 027/129] Let a replayed car meet the props with their mass A client replays its car's samples when the host corrects it (World::replaySample), and everything it meets there holds still. A standing prop held like a wall (calcBangerImpact against a body without an ICS stops the car after the break limit) and a knocked-over piece like an infinitely heavy body: a replay through props took up to 6 m/s off the car that the real sample had not (1.5 m corrections after a hit on a street light). The held instance now meets the car with the mass a real hit gives it (Instance::heldInertia: the active dgBangerActive:: Attach would give the prop; a copy of a moving piece's phInertialCS), still taking nothing itself, and a light one (under a quarter of the car's mass) gives way after the first hit of a replay, as the real one flies off (World::beginReplay, which CarPrediction calls). A car replayed into a meter loses what a real one does (within 1 m/s), not all of its speed. A client driving alone through props had no correction in 40 s: the host's simulation of its car through the same props gives the same samples. The parts the host's record takes off a client's own car are no longer thrown again on the client (the host's ring shows them), and the props trace takes each car's dents from the host's records. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/app/RaceScreen.cpp | 36 ++++++++++++++++++++++++--------- src/game/bangers/BangerSet.cpp | 24 ++++++++++++++++++++++ src/game/bangers/BangerSet.h | 4 ++++ src/game/net/PlayerCars.cpp | 1 + src/phys/Level.h | 6 ++++++ src/phys/World.cpp | 37 ++++++++++++++++++++++++++++++---- src/phys/World.h | 6 ++++++ tests/game/test_prop_sync.cpp | 32 +++++++++++++++++++++++++++++ 8 files changed, 132 insertions(+), 14 deletions(-) diff --git a/src/app/RaceScreen.cpp b/src/app/RaceScreen.cpp index 23a7a52..c43ab5f 100644 --- a/src/app/RaceScreen.cpp +++ b/src/app/RaceScreen.cpp @@ -3800,7 +3800,7 @@ class RaceScreen final : public Screen { if (m_result.config.multiplayer && impact.damaging && m_traceNet && m_traceNet->isHost()) m_netDamage.own().impact(m_netStateTime, game::damageImpactOf(impact, m_player->sim())); if (impact.damaging) - if (auto* trace = m_netProps.trace()) // OPENMM2_DEBUG_NETPROPS + if (auto* trace = m_netProps.trace()) // OPENMM2_NET_TRACE trace->impact(m_netStateTime, propToucher(impact.otherBody), impact.value, m_player->sim().damage.currentDamage); if (impact.damaging) { @@ -4990,6 +4990,9 @@ class RaceScreen final : public Screen { t.texelRadius = sim.damage.params.textelDamageRadius; const std::string base = m_player->model().baseName; t.eject = [&](const game::VehicleRenderer::Breakable& b, float speed) { + // OpenMM2: the host threw it for everyone (game/net/NetProps). + if (m_netProps.hostThrowsParts()) + return false; return ejectCarPart(*m_vehicle, base, b, t.body, speed, sim.body.room); }; t.sound = [](const Vec3&, float, int) {}; @@ -5193,7 +5196,7 @@ class RaceScreen final : public Screen { return trailer && other == &trailer->body; } - // What set a prop moving, for OPENMM2_DEBUG_NETPROPS. + // What set a prop moving, for OPENMM2_NET_TRACE's props lines. std::string propToucher(const phys::Instance* by) const { if (!by) return "world"; @@ -5278,15 +5281,28 @@ class RaceScreen final : public Screen { auto* trace = m_netProps.trace(); if (!trace || !m_netProps.traced()) return; - // Each car's damage as this machine shows it: the replicated level - // (0..1 between MedDamage and MaxDamage) and the dents painted. + // Each car's damage as this machine shows it: its level (0..1 + // between MedDamage and MaxDamage) and its dents (the host's + // records, which it sends; a client's copies of them). + const std::uint8_t self = ctx.netGame->localId(); + auto dentsOf = [&](std::uint8_t id) { + if (!ctx.netGame->isHost()) + return m_netDamage.replica().recordSize(game::DamageReplica::playerKey(id)); + return static_cast(id == self ? m_netDamage.own().recorded() + : m_netDamage.player(id).recorded()); + }; if (m_player) - trace->car(std::format("player{}", ctx.netGame->localId()), m_player->sim().damage.damage, - static_cast(m_netDamage.own().recorded())); - for (const auto& rc : m_remoteCars) { - const auto dents = m_netDamage.replica().recordSize(game::DamageReplica::playerKey(rc.id)); - if (rc.hasState) - trace->car(std::format("player{}", rc.id), rc.damage, static_cast(dents)); + trace->car(std::format("player{}", self), m_player->sim().damage.damage, + static_cast(dentsOf(self))); + if (ctx.netGame->isHost()) { + for (const auto& [id, rv] : m_remotes) + if (rv.simulated && rv.placed && rv.sim) + trace->car(std::format("player{}", id), rv.sim->sim().damage.damage, + static_cast(dentsOf(id))); + } else { + for (const auto& rc : m_remoteCars) + if (rc.hasState) + trace->car(std::format("player{}", rc.id), rc.damage, static_cast(dentsOf(rc.id))); } } diff --git a/src/game/bangers/BangerSet.cpp b/src/game/bangers/BangerSet.cpp index 104f920..f27fcc7 100644 --- a/src/game/bangers/BangerSet.cpp +++ b/src/game/bangers/BangerSet.cpp @@ -232,6 +232,8 @@ class BangerSet::Prop final : public phys::Instance { // OpenMM2: a network client's props take contacts from its own car and // its own props only. bool acceptsContact(const phys::Instance& other) const override { return m_set.acceptsFrom(other); } + // OpenMM2: the active a car's hit would attach (World::replaySample). + bool heldInertia(phys::InertialCS& out) const override { return m_set.heldInertia(m_index, out); } bool banger = false; // lvlInstance flag 1: a placed prop still standing bool listed = false; // in m_rooms[room] @@ -1114,6 +1116,28 @@ bool BangerSet::moving(std::size_t i) const { return inWorld(*m_active[static_cast(m_instances[i].active)]); } +bool BangerSet::heldInertia(std::size_t i, phys::InertialCS& ics) const { + // dgBangerActive::Attach's body for instance i, as AttachEntity would + // make it now (a network client's mirror moving with the host's motion). + if (i >= m_instances.size()) + return false; + const Instance& inst = m_instances[i]; + if (!inst.data || inst.state == State::Gone || inst.active >= 0) + return false; + const BangerData& d = *inst.data; + ics = phys::InertialCS{}; + ics.matrix = inst.matrix; + ics.setMass(d.size.x, d.size.y, d.size.z, d.mass); + smoothAngInertia(ics, kInertiaRatio); + if (inst.mirror) { + ics.linearVelocity = inst.mirrorVelocity; + ics.linearMomentum = inst.mirrorVelocity * ics.mass; + ics.angularVelocity = inst.mirrorSpin; + ics.angularMomentum = rowTimes(inst.mirrorSpin, ics.worldInertia()); + } + return true; +} + bool BangerSet::isActiveBody(const phys::Instance* i) const { for (const auto& a : m_active) if (&a->body == i) diff --git a/src/game/bangers/BangerSet.h b/src/game/bangers/BangerSet.h index d104678..81c16b0 100644 --- a/src/game/bangers/BangerSet.h +++ b/src/game/bangers/BangerSet.h @@ -220,6 +220,10 @@ class BangerSet final : public InstanceSource { bool moving(std::size_t i) const; // Whether `i` is the body of one of this set's actives. bool isActiveBody(const phys::Instance* i) const; + // The body an active would give instance i if a car hit it now + // (phys::Instance::heldInertia, for World::replaySample); false for an + // instance with an active or out of the world. + bool heldInertia(std::size_t i, phys::InertialCS& ics) const; // A network client: only this machine's own car (`localToucher`) and the // props this set simulates itself may touch a prop (phys::Instance:: diff --git a/src/game/net/PlayerCars.cpp b/src/game/net/PlayerCars.cpp index f6e0dd6..a2b9bcf 100644 --- a/src/game/net/PlayerCars.cpp +++ b/src/game/net/PlayerCars.cpp @@ -402,6 +402,7 @@ CarPrediction::Correction CarPrediction::acknowledge(SimVehicle& car, NetCarDriv std::size_t count = 1; if (phys::Trailer* t = car.trailer()) bodies[count++] = &t->body; + world.beginReplay(); // OpenMM2: the props the replay meets (World::collideHeld) for (std::size_t k = index + 1; k < m_history.size(); ++k) { Entry& e = m_history[k]; for (const auto& c : e.commands) diff --git a/src/phys/Level.h b/src/phys/Level.h index a048ecc..c9033f9 100644 --- a/src/phys/Level.h +++ b/src/phys/Level.h @@ -8,6 +8,7 @@ #include "phys/Bound.h" #include "phys/Collider.h" +#include "phys/InertialCS.h" #include "phys/Material.h" #include @@ -73,6 +74,11 @@ class Instance { // this machine simulates itself (game::PropSync); MM2's answer is always // yes. virtual bool acceptsContact(const Instance& /*other*/) const { return true; } + // OpenMM2 (World::replaySample): the body this instance would take if a + // car hit it (a prop's active), at rest where it stands, so that a car + // replayed through it meets its mass as a real sample does. False: it + // holds like a wall. + virtual bool heldInertia(InertialCS& /*out*/) 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 diff --git a/src/phys/World.cpp b/src/phys/World.cpp index 4db1846..4959687 100644 --- a/src/phys/World.cpp +++ b/src/phys/World.cpp @@ -524,6 +524,7 @@ void World::step(float dt) { m_stepObserver(); if (m_beforeSample) m_beforeSample(); + m_replayYielded.clear(); const float invDt = 1.0f / dt; // datTimeManager::SetTempOverSampling: Seconds is the sample's length. sampleTime() = {dt, invDt}; @@ -712,14 +713,35 @@ bool World::collideHeld(Body& a, Instance& b) { const Vec3 relPos = m_tempMatrixB.m3 - a.matrix().m3; Collider* colA = &a.collider; // A body (moved by the simulation or from outside) as a kinematic one, - // a static instance as itself. + // a static instance as itself. OpenMM2 (the props of a network race): + // what gives way when a car really hits it meets the replayed car with + // its mass, though it still holds still and takes nothing: a simulated + // body (a knocked-over prop's active) with a copy of its ICS, an + // instance that takes a body when hit (a standing or resting prop) with + // the one it would take (Instance::heldInertia). Body* entity = b.entity(); - if (entity) + // Such a light one (under a quarter of the car's mass: the props, not a + // parked car) has given way once the replayed car has hit it: the real + // one flies off. Inferred stand-in for its motion. + if (std::ranges::find(m_replayYielded, &b) != m_replayYielded.end()) + return false; + InertialCS* held = nullptr; + if (entity && !entity->kinematic && entity->ics.mass > 0.0f) { + m_heldIcs = entity->ics; + held = &m_heldIcs; + } else if (!entity && b.heldInertia(m_heldIcs)) { + held = &m_heldIcs; + } + if (held) + m_tempB.init(boundB, &m_tempMatrixB, held); + else if (entity) m_tempB.init(boundB, &m_tempMatrixB, nullptr); else m_tempB.initStatic(boundB, &m_tempMatrixB); m_tempB.id = b.audioId; - if (entity && !entity->kinematic) { + if (held) { + // Its motion is its ICS's. + } else if (entity && !entity->kinematic) { m_tempB.moving = true; m_tempB.motionVelocity = entity->ics.linearVelocity; m_tempB.motionSpin = entity->ics.angularVelocity; @@ -735,17 +757,24 @@ bool World::collideHeld(Body& a, Instance& b) { return false; std::span impacts(m_impacts.data(), static_cast(n)); const float weight = 1.0f / static_cast(n); + bool broke = false; if (b.isBanger() && !entity) { const float limit2 = b.bangerImpulseLimit2(); for (Impact& im : impacts) - calcBangerImpact(im, weight, limit2); + broke = calcBangerImpact(im, weight, limit2) || broke; } else { for (Impact& im : impacts) calcImpact(im, weight); } + const bool gives = held && (broke || !b.isBanger()) && colA->ics && + held->mass < colA->ics->mass * 0.25f; + if (gives) + m_replayYielded.push_back(&b); return true; } +void World::beginReplay() { m_replayYielded.clear(); } + bool World::trivialCollide(const Instance& a, const Instance& b) const { // dgPhysManager::TrivialCollideInstances: bounding spheres. A banger // with a YRadius uses the point under its centre of gravity and that diff --git a/src/phys/World.h b/src/phys/World.h index e8642e4..5efede2 100644 --- a/src/phys/World.h +++ b/src/phys/World.h @@ -257,6 +257,10 @@ class World final : public GroundQuery { // them at its own velocity. A network client runs its own car's samples // again with it once the host's state has corrected an earlier one. void replaySample(std::span bodies, float dt); + // OpenMM2: the samples replaySample runs from here on are one replay + // (a light prop the replayed car has hit stays out of its way until the + // next replay or sample; see collideHeld). + void beginReplay(); bool probe(const Vec3& a, const Vec3& b, RayHit& hit) const override; // dgPhysManager::Collide(segment, mask 0x20) as vehWheel::ComputeDwtdw @@ -354,6 +358,8 @@ class World final : public GroundQuery { Mat34 m_identity; Collider m_levelCollider; Collider m_tempA, m_tempB; + InertialCS m_heldIcs; // replaySample: what a held body or prop would have (taken by nothing) + std::vector m_replayYielded; // replaySample: light props the replay has hit Mat34 m_tempMatrixA, m_tempMatrixB; std::vector m_isectsA, m_isectsB; std::vector m_impacts; diff --git a/tests/game/test_prop_sync.cpp b/tests/game/test_prop_sync.cpp index 60f8cd1..99ce60b 100644 --- a/tests/game/test_prop_sync.cpp +++ b/tests/game/test_prop_sync.cpp @@ -369,6 +369,38 @@ TEST(PropSync, OtherCarsPassThroughAClientsProps) { // Lost, late and reordered messages: the client still ends with the host's // props, and every knock is applied once. +// A client replays its car's samples when the host's state corrects it +// (World::replaySample): the props it meets there hold still, but with the +// mass a real hit gives them, so the replayed car loses what the real one +// does instead of stopping as against a wall. +TEST(PropSync, ReplayedCarMeetsAPropAsARealSampleDoes) { + TempBangers files; + bangers::BangerDataLibrary lib(files.vfs); + constexpr int kSteps = 20; + Machine real(lib); + real.place(layout()); + Car a({0, 1, 2.5f}, {0, 0, -10}); + real.world.add(&a.body); + for (int i = 0; i < kSteps; ++i) + real.step(); + ASSERT_FALSE(real.set.standing(0)); // the real car broke it loose + Machine replay(lib); + replay.place(layout()); + Car b({0, 1, 2.5f}, {0, 0, -10}); + replay.world.add(&b.body); + phys::Body* bodies[] = {&b.body}; + replay.world.beginReplay(); + for (int i = 0; i < kSteps; ++i) + replay.world.replaySample(bodies, kDt); + EXPECT_TRUE(replay.set.standing(0)); // a replay knocks nothing + const float realSpeed = a.body.ics.linearVelocity.mag(); + const float replayedSpeed = b.body.ics.linearVelocity.mag(); + EXPECT_GT(realSpeed, 5.0f); + EXPECT_NEAR(replayedSpeed, realSpeed, 1.0f) << "real " << realSpeed << ", replayed " << replayedSpeed; + real.world.remove(&a.body); + replay.world.remove(&b.body); +} + TEST(PropSync, LossyLinkConverges) { Race r; r.net.latencyMs = 120.0; From df5c7cf76355e058128c82097bc0d7cf7c063faa Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Fri, 9 Oct 2026 14:08:30 -0700 Subject: [PATCH 028/129] Keep a car the client knocked loose until it comes to rest With the host simulating the client's car from the same inputs, the car the client knocked loose moved 3 cm (median) from the host's while the client simulated it, but handing it to the host's messages as soon as they showed it knocked jumped it 3.7 m: the host's knocked car, a trip old, predicted along its velocity while it tumbles. The local body now leads until it comes to rest, the host's car is 5 m from it, or the host has it back on its rail; meanwhile the received car is not also a moving instance. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/app/RaceScreen.cpp | 3 +- src/game/net/NetTrafficCars.cpp | 48 ++++++++++++++++++++-------- src/game/net/NetTrafficCars.h | 19 ++++++++--- tests/game/test_net_traffic_cars.cpp | 30 ++++++++++++++--- 4 files changed, 76 insertions(+), 24 deletions(-) diff --git a/src/app/RaceScreen.cpp b/src/app/RaceScreen.cpp index 3b2f85d..7bb00b5 100644 --- a/src/app/RaceScreen.cpp +++ b/src/app/RaceScreen.cpp @@ -4274,7 +4274,8 @@ class RaceScreen final : public Screen { if (m_trafficProxies) { std::vector moving; for (const ai::AmbientCar& c : m_netCars) - if (!m_netTrafficCars || c.goal != ai::AmbientGoal::RandomDrive) + if (!m_netTrafficCars || + (c.goal != ai::AmbientGoal::RandomDrive && !m_netTrafficCars->knocked(c.id))) moving.push_back(c); m_trafficProxies->update(moving); } diff --git a/src/game/net/NetTrafficCars.cpp b/src/game/net/NetTrafficCars.cpp index 443a8b2..1cf5be8 100644 --- a/src/game/net/NetTrafficCars.cpp +++ b/src/game/net/NetTrafficCars.cpp @@ -16,16 +16,30 @@ void NetTrafficCars::update(std::span received, double now, doub present.insert(c.id); const bool onRail = c.goal == ai::AmbientGoal::RandomDrive; if (const auto it = m_knocks.find(c.id); it != m_knocks.end()) { - const Knock& k = it->second; + Knock& k = it->second; const double heard = static_cast(r.stateTime); + if (!onRail && !k.confirmed && k.generation == c.spawns) { + k.confirmed = true; // the host has knocked it too + ++m_stats.confirmed; + } if (k.generation != c.spawns) { m_knocks.erase(it); // another car in the slot - } else if (!onRail) { - // The host has knocked it too: its messages lead from now. - ++m_stats.confirmed; - handOver(c.id, k, true); - m_knocks.erase(it); - continue; + } else if (k.confirmed) { + // Knocked on the host too. Until its body here comes to rest + // the local one, which runs the host's physics from the same + // hit, is the better guess of the two; then (or when the + // host's car is far from it, or back on its rail) the host's + // messages lead. + if (k.resting || onRail || now > k.time + kMaxLocalMs || + c.transform.m3.dist(k.pose.m3) > kDivergeMetres) { + handOver(c.id, k, true); + m_knocks.erase(it); + if (!onRail) + continue; + } else { + keepLocal(c, k); + continue; + } } else if (heard > k.time + confirmMs || now > k.time + kMaxLocalMs) { // On its rail well after the hit on the host: it was not hit // there. @@ -33,12 +47,7 @@ void NetTrafficCars::update(std::span received, double now, doub handOver(c.id, k, false); m_knocks.erase(it); } else { - ai::AmbientCar a = c; - a.transform = k.pose; - a.goal = ai::AmbientGoal::Collision; - a.physical = true; - a.speed = 0.0f; - m_cars.push_back(a); + keepLocal(c, k); continue; } } @@ -49,6 +58,15 @@ void NetTrafficCars::update(std::span received, double now, doub std::erase_if(m_knocks, [&](const auto& e) { return !present.contains(e.first); }); } +void NetTrafficCars::keepLocal(const ai::AmbientCar& c, const Knock& k) { + ai::AmbientCar a = c; + a.transform = k.pose; + a.goal = ai::AmbientGoal::Collision; + a.physical = true; + a.speed = 0.0f; + m_cars.push_back(a); +} + void NetTrafficCars::handOver(int id, const Knock& knock, bool confirmed) { m_handovers.push_back({id, knock.pose, confirmed}); } @@ -67,8 +85,10 @@ void NetTrafficCars::impact(int carId) { void NetTrafficCars::detach(int carId, const Mat34& pose, bool) { // The body came to rest: the car stays there until the host's messages // take it over. - if (const auto it = m_knocks.find(carId); it != m_knocks.end()) + if (const auto it = m_knocks.find(carId); it != m_knocks.end()) { it->second.pose = pose; + it->second.resting = true; + } } void NetTrafficCars::setPhysicalTransform(int carId, const Mat34& transform) { diff --git a/src/game/net/NetTrafficCars.h b/src/game/net/NetTrafficCars.h index 6ac10f1..0f3ee25 100644 --- a/src/game/net/NetTrafficCars.h +++ b/src/game/net/NetTrafficCars.h @@ -14,11 +14,15 @@ // machine's car knocks a car loose here; the host's police, the other // players and the cars it knocked itself reach the client in its messages. // -// The knocked car is this machine's until the host's messages either show it -// off its rail too (it then follows them, the drawing blending from where -// the local body left it) or show it still on its rail at a time well after +// The knocked car is this machine's until either the host's messages show it +// off its rail too and its body here has come to rest (or the host's car is +// 5 m from it): it then follows them, the drawing blending from where the +// local body left it; or they show it still on its rail at a time well after // the hit, when the host would have knocked it (the host did not: it goes -// back to its rail, blended the same way). +// back to its rail, blended the same way). With the host simulating the +// client's car from the same inputs, the local body runs the host's physics +// from the same hit, so it is the better guess while it moves: the host's +// knocked car, a trip old and predicted along its velocity, is not. #include "ai/Traffic.h" #include "game/TrafficBodies.h" @@ -45,6 +49,9 @@ class NetTrafficCars final : public TrafficBodies::Source { // kMaxLocalMs whatever the host says. void update(std::span received, double now, double confirmMs); static constexpr double kMaxLocalMs = 3000.0; + // A car the host has knocked too stays this machine's until its body + // comes to rest, unless the host's car is this far from it. + static constexpr float kDivergeMetres = 5.0f; // A car this machine knocked loose and handed back to the host's // messages this frame, and where it was then. @@ -78,8 +85,12 @@ class NetTrafficCars final : public TrafficBodies::Source { int generation = 0; double time = 0.0; // session ms of the hit Mat34 pose; + bool confirmed = false; // the host has knocked it too + bool resting = false; // its body came to rest }; void handOver(int id, const Knock& knock, bool confirmed); + // The car listed where the local body has it. + void keepLocal(const ai::AmbientCar& c, const Knock& k); std::map m_knocks; std::vector m_cars; diff --git a/tests/game/test_net_traffic_cars.cpp b/tests/game/test_net_traffic_cars.cpp index 93c429d..0d5a63f 100644 --- a/tests/game/test_net_traffic_cars.cpp +++ b/tests/game/test_net_traffic_cars.cpp @@ -51,19 +51,27 @@ TEST(NetTrafficCars, AKnockIsConfirmedWhenTheHostKnocksTheCarToo) { cars.impact(1); // the local car hit it in the step EXPECT_TRUE(cars.knocked(1)); EXPECT_EQ(cars.takeKnocks(), std::vector{1}); - Mat34 rest = Mat34::translation({5, 0, 5}); - cars.setPhysicalTransform(1, rest); + Mat34 moved = Mat34::translation({2, 0, 1}); + cars.setPhysicalTransform(1, moved); // Still on its rail in the host's older messages: the local body leads. cars.update(std::vector{car(1, 3, 1150)}, 1200.0, 200.0); const ai::AmbientCar* c = listed(cars, 1); ASSERT_NE(c, nullptr); EXPECT_TRUE(c->physical); - EXPECT_EQ(c->transform.m3, rest.m3); + EXPECT_EQ(c->transform.m3, moved.m3); EXPECT_TRUE(cars.takeHandovers().empty()); - // The host knocked it too: its messages lead from now. + // The host knocked it too; while the local body moves it still leads + // (it runs the host's physics from the same hit). cars.update(std::vector{car(1, 3, 1250, false)}, 1350.0, 200.0); + EXPECT_TRUE(cars.knocked(1)); + EXPECT_EQ(cars.stats().confirmed, 1u); + EXPECT_TRUE(cars.takeHandovers().empty()); + // At rest: the host's messages lead from now. + const Mat34 rest = Mat34::translation({3, 0, 2}); + cars.detach(1, rest, true); + cars.update(std::vector{car(1, 3, 1400, false)}, 1500.0, 200.0); EXPECT_FALSE(cars.knocked(1)); - EXPECT_EQ(listed(cars, 1), nullptr); + EXPECT_EQ(listed(cars, 1), nullptr); // off its rail: a moving instance const auto h = cars.takeHandovers(); ASSERT_EQ(h.size(), 1u); EXPECT_TRUE(h[0].confirmed); @@ -71,6 +79,18 @@ TEST(NetTrafficCars, AKnockIsConfirmedWhenTheHostKnocksTheCarToo) { EXPECT_EQ(cars.stats().confirmed, 1u); } +// A local body far from where the host has the knocked car goes back to the +// host's messages at once. +TEST(NetTrafficCars, AKnockFarFromTheHostsGoesBackAtOnce) { + NetTrafficCars cars; + cars.update(std::vector{car(1, 3, 1000)}, 1100.0, 200.0); + cars.impact(1); + cars.setPhysicalTransform(1, Mat34::translation({20, 0, 0})); + cars.update(std::vector{car(1, 3, 1150, false)}, 1250.0, 200.0); + EXPECT_FALSE(cars.knocked(1)); + ASSERT_EQ(cars.takeHandovers().size(), 1u); +} + TEST(NetTrafficCars, AKnockTheHostNeverMakesIsWithdrawn) { NetTrafficCars cars; cars.update(std::vector{car(1, 3, 1000)}, 1100.0, 200.0); From 8e1fafd339fa74d407e04fc01506748911f693b2 Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Fri, 9 Oct 2026 14:09:51 -0700 Subject: [PATCH 029/129] Trace every simulated car's damaging impacts, by car The props trace's damaging impacts now name the car: this machine's own and, on the host, the other players' cars it simulates. syncreport sets each car's impacts as its own machine predicted them next to the host's, with their causes (a prop, the city, a player, the traffic) and the car's damage after them: what decides whether a prop dents a car the same on the host as in single player. Replayed samples are left out. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/app/RaceScreen.cpp | 18 ++++++++++++--- src/game/net/NetProps.cpp | 4 ++-- src/game/net/NetProps.h | 8 +++++-- tools/netprobe/PropDiff.cpp | 44 ++++++++++++++++++++++++++----------- 4 files changed, 54 insertions(+), 20 deletions(-) diff --git a/src/app/RaceScreen.cpp b/src/app/RaceScreen.cpp index c43ab5f..d0ec692 100644 --- a/src/app/RaceScreen.cpp +++ b/src/app/RaceScreen.cpp @@ -3799,10 +3799,10 @@ class RaceScreen final : public Screen { // OpenMM2: the others see it too (the host's: it decides every car's). if (m_result.config.multiplayer && impact.damaging && m_traceNet && m_traceNet->isHost()) m_netDamage.own().impact(m_netStateTime, game::damageImpactOf(impact, m_player->sim())); - if (impact.damaging) + if (impact.damaging && !m_netReplaying) if (auto* trace = m_netProps.trace()) // OPENMM2_NET_TRACE - trace->impact(m_netStateTime, propToucher(impact.otherBody), impact.value, - m_player->sim().damage.currentDamage); + trace->impact(m_netStateTime, std::format("player{}", m_netLocalId), impactCause(impact), + impact.value, m_player->sim().damage.currentDamage); if (impact.damaging) { ++(impact.otherIsBody ? m_vehicleImpacts : m_objectImpacts); m_ff.impact(impact.total, m_player->sim().speedMph()); // mmPlayer::FFImpactCallback @@ -4734,6 +4734,10 @@ class RaceScreen final : public Screen { rv.fx->impact(impact, rv.sim->sim()); if (impact.damaging) m_netDamage.player(id).impact(m_netStateTime, game::damageImpactOf(impact, rv.sim->sim())); + if (impact.damaging) + if (auto* trace = m_netProps.trace()) // OPENMM2_NET_TRACE + trace->impact(m_netStateTime, std::format("player{}", id), impactCause(impact), impact.value, + rv.sim->sim().damage.currentDamage); traceNetImpact(id, impact); } @@ -5218,6 +5222,14 @@ class RaceScreen final : public Screen { return "traffic"; // the traffic's bodies and rail cars (or anything else) } + // What a car's damaging impact was against, for OPENMM2_NET_TRACE: a + // player's car by its id, else as propToucher names it. + std::string impactCause(const phys::CarImpact& impact) const { + if (m_player && impact.otherBody == &m_player->sim().body) + return std::format("player{}", m_netLocalId); + return propToucher(impact.otherBody); + } + // The session time the simulation will have reached after this frame's // fixed steps (before them), or has reached (after them). double netSimTime(Context& ctx, float dt, bool afterSteps) const { diff --git a/src/game/net/NetProps.cpp b/src/game/net/NetProps.cpp index fd13a6d..e2a0139 100644 --- a/src/game/net/NetProps.cpp +++ b/src/game/net/NetProps.cpp @@ -45,8 +45,8 @@ void PropTrace::knock(double t, std::size_t prop, std::string_view model, std::s void PropTrace::undo(double t, std::size_t prop) { m_net.traceLine(std::format("x {:.0f} {}", t, prop)); } -void PropTrace::impact(double t, std::string_view cause, float value, float damage) { - m_net.traceLine(std::format("i {:.0f} {} {:.1f} {:.1f}", t, cause, value, damage)); +void PropTrace::impact(double t, std::string_view car, std::string_view cause, float value, float damage) { + m_net.traceLine(std::format("i {:.0f} {} {} {:.1f} {:.1f}", t, car, cause, value, damage)); } bool PropTrace::tick(double t) { diff --git a/src/game/net/NetProps.h b/src/game/net/NetProps.h index 73866da..447d08c 100644 --- a/src/game/net/NetProps.h +++ b/src/game/net/NetProps.h @@ -31,7 +31,8 @@ struct NetGameEvent; // of session time ("t ") the props not standing ("b "), // the knocked-over props and thrown parts shown ("h // "), the cars' damage ("d ") and this -// machine's car's damaging impacts ("i "). +// machine's simulated cars' damaging impacts ("i +// "). class PropTrace { public: // Null unless the session traces (NetGame::tracing). @@ -44,7 +45,10 @@ class PropTrace { void placed(const bangers::BangerSet& set); void knock(double t, std::size_t prop, std::string_view model, std::string_view cause); void undo(double t, std::size_t prop); - void impact(double t, std::string_view cause, float value, float damage); + // A damaging impact on `car` in this machine's simulation (its own car, + // and on the host the other players' cars), its value and the damage + // after it. + void impact(double t, std::string_view car, std::string_view cause, float value, float damage); // Starts a tick when `t` passed the next 250 ms boundary. bool tick(double t); void broken(const bangers::BangerSet& set); diff --git a/tools/netprobe/PropDiff.cpp b/tools/netprobe/PropDiff.cpp index cbe82a8..2e3728f 100644 --- a/tools/netprobe/PropDiff.cpp +++ b/tools/netprobe/PropDiff.cpp @@ -11,7 +11,9 @@ // prop or thrown part rests (both at rest), and the ones only // one machine shows // damage each car's damage level and dents at the common ticks -// impacts the damaging impacts on each machine's own car, by cause +// impacts each car's damaging impacts as each machine simulated it +// (its own player's machine and the host), by cause, and its +// damage after them #include "PropDiff.h" @@ -53,8 +55,11 @@ struct Trace { std::map knocks; // first knock of each prop std::map undone; std::map ticks; - std::map impacts; - std::map impactValue; + // By car, then cause: the damaging impacts, their values, and the + // car's damage after the last. + std::map> impacts; + std::map> impactValue; + std::map damageAfter; }; bool load(const std::string& path, Trace& t) { @@ -104,10 +109,11 @@ bool load(const std::string& path, Trace& t) { tick->dents[car] = dents; } else if (kind == "i") { double time, value, damage; - std::string cause; - s >> time >> cause >> value >> damage; - ++t.impacts[cause]; - t.impactValue[cause] += value; + std::string car, cause; + s >> time >> car >> cause >> value >> damage; + ++t.impacts[car][cause]; + t.impactValue[car][cause] += value; + t.damageAfter[car] = damage; } } return true; @@ -281,14 +287,26 @@ void comparePair(Trace& a, Trace& b) { car, meanLevel, percentile(v, 1.0), meanDents, percentile(d, 1.0), l[0], l[2], l[1], l[3]); } - auto impacts = [](const Trace& t) { + // Each car's damaging impacts as each machine simulated it: a client's + // car on its own machine (its prediction) and on the host. + std::set cars; + for (const auto& [car, m] : a.impacts) + cars.insert(car); + for (const auto& [car, m] : b.impacts) + cars.insert(car); + auto impacts = [](const Trace& t, const std::string& car) { std::string s; - for (const auto& [c, n] : t.impacts) - s += std::format(" {}:{} ({:.0f})", c, n, t.impactValue.at(c)); - return s.empty() ? std::string(" -") : s; + const auto it = t.impacts.find(car); + if (it == t.impacts.end()) + return std::string(" -"); + for (const auto& [c, n] : it->second) + s += std::format(" {}:{} ({:.0f})", c, n, t.impactValue.at(car).at(c)); + return s + std::format(", damage {:.0f}", t.damageAfter.at(car)); }; - std::println("damaging impacts on A's own car:{}", impacts(a)); - std::println("damaging impacts on B's own car:{}", impacts(b)); + for (const auto& car : cars) { + std::println("damaging impacts on {} as A simulated it:{}", car, impacts(a, car)); + std::println("{:>{}} as B simulated it:{}", "", 21 + car.size(), impacts(b, car)); + } } } // namespace From 4eb99e9412b6c10bd8a5b84ae6cf2c399925a863 Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Fri, 9 Oct 2026 14:11:32 -0700 Subject: [PATCH 030/129] Test that only the host's knocks count, and document the merged props A knocks event another player forges reaches a client through the host's relay and knocks nothing; the host's does. The docs follow the host's simulated players' cars (they knock and are dented by the props through single player's code), the replays' held props, the props' lines in OPENMM2_NET_TRACE and syncreport, and protocol 7. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/bangers.md | 7 ++-- docs/multiplayer.md | 60 +++++++++++++++++++++++------------ docs/parity/openmm2-only.md | 2 +- tests/game/test_prop_sync.cpp | 31 +++++++++++++++++- 4 files changed, 73 insertions(+), 27 deletions(-) diff --git a/docs/bangers.md b/docs/bangers.md index 0ebf578..d34008b 100644 --- a/docs/bangers.md +++ b/docs/bangers.md @@ -206,7 +206,6 @@ needs, all of it inert in a single-player race: of their own, drawn where the host has them. An active attached to a mirror starts from the host's motion (`dgBangerActive::Attach` starts a placed or resting prop at rest). -* A moving kinematic body allowed to (`phys::Body::kinematicBreaksBangers`, - the host's copies of the other players' cars) breaks a banger as a body of - its mass would (`calcBangerImpact`); every other kinematic body leaves it - standing, as before. +* A client replaying its car (`phys::World::replaySample`) meets a prop with + the body an active would give it (`heldInertia`: `dgBangerActive::Attach`'s + mass and inertia, a mirror's host motion), holding it still. diff --git a/docs/multiplayer.md b/docs/multiplayer.md index 94cf8ca..a14b01c 100644 --- a/docs/multiplayer.md +++ b/docs/multiplayer.md @@ -196,8 +196,9 @@ layouts must bump `kProtocolVersion`.** `Hello` carries the version again, along with a free-form build string. Version 2 added the shared cruise traffic, version 3 the race start handshake (`RaceLoad` in place of `Countdown`, `RaceLoaded`, `RaceStart`, the race in `Welcome`), version 4 the -cars' damage, version 5 the host's props (`PropState`, the `PropKnocks` -event). +cars' damage, version 5 the host's simulation of every player's car, version +7 the host's props (`PropState`, the `PropKnocks` event; 6 is the shared +traffic's). ### Handshake @@ -622,10 +623,8 @@ their owner's damage instead: order. The parts are the owner's: the ones it broke off (`vehBreakableMgr::Impact`'s nearest, `EjectOneshot`'s by speed and its own random numbers) come off on every machine. The host throws them as bangers - when the event is fresh (4 m/s, wheels at 1.3 times the car's speed) and - every machine shows the host's (see "Props"); a client takes them off - without throwing, and throws its own car's at once, which hand over to the - host's when they rest. The sparks, shards and impact sound of each + (4 m/s, wheels at 1.3 times the car's speed) and every machine shows the + host's (see "Props"); a client takes them off without throwing. The sparks, shards and impact sound of each damaging impact are replayed at the car as drawn. * **Timing.** Every entry happens when the receiving machine draws the car at the session time it happened on its owner's machine (the police: at the @@ -703,11 +702,9 @@ shared traffic knocked props on the host only. **OpenMM2** (the maintainer's decision: the host is the authority for everything; `game/net/PropSync`, `game/net/NetProps`, `net/PropState.h`): the host simulates the props for everyone, as a single-player race does, with -every car: its own, its copies of the other players' cars, the shared traffic -and police. Its copies of the others' cars are kinematic until they are -simulated on the host; they meet a banger as a car of their mass would -(`phys::Body::kinematicBreaksBangers`: dgImpact's break test and share of the -motion with the car's phInertialCS, only the car's own response left out). +every car it simulates: its own, the other players' (simulated from their +inputs, protocol 5), the shared traffic and police; the props meet +them, and dent them, through single player's code. * **Names.** Every machine places the same props in the same order (the street props restart the random generator per road, the instances and path sets @@ -760,9 +757,18 @@ motion with the car's phInertialCS, only the car's own response left out). (so a flying cone hits its car as on the host) and stays where it stopped until the host's has moved and rests again, or 2 s. A piece simulated here that the host's ring does not hold disappears 2 s after it was made once at - rest. The parts its replay of another car's damage would throw are taken - off without a throw (the host's ring shows them); its own car's are thrown - at once and hand over like a knock. + rest. The parts the host's damage records take off a car (its own + included) come off without a throw: the host's ring shows the parts it + threw. +* **Replays.** When the host's state corrects its car, a client runs the + car's later samples again (`World::replaySample`) with everything else + held still. The props meet the replayed car with the mass a real hit gives + them (`Instance::heldInertia`: the active the prop would take; a copy of a + flying piece's), and a light one (under a quarter of the car's mass) gives + way after the replay's first hit, as the real one flies off; nothing is + knocked in a replay. A prop a client's car hits in its own samples is the + same knock on the host, sample for sample: a client driving alone through + props had no correction. * **A different placement** (an altered city, a mismatched build): the client follows only the cars' parts and simulates its props itself, as 0.3 did, and logs it. @@ -1021,10 +1027,10 @@ own, or another's as drawn), `police:<400 + post>` a shared police car, `knocked:` the knocked traffic car with a body within 60 m of a player's car with the lowest id, `traffic:` a shared traffic car, `prop:` a placed prop or, once knocked, its first piece, so both -machines take the same view. For the props: `OPENMM2_DEBUG_NETPROPS=` -traces each machine's props (`netprobe propdiff ` compares -two), `OPENMM2_NETPROPS=local` leaves every machine with its own props, as -0.3 did (for comparison); `OPENMM2_DEBUG_INPUT` takes several inputs +machines take the same view. For the props: `OPENMM2_NET_TRACE` traces +each machine's props too (see "Diagnosing replication"), +`OPENMM2_NETPROPS=local` leaves every machine with its own props, as 0.3 did +(for comparison); `OPENMM2_DEBUG_INPUT` takes several inputs separated by `/` in turn, each for `OPENMM2_DEBUG_INPUT_MS` (2000) of race time (`1,0,0.2,0/0,1,-0.2,0` rams a wall again and again); `OPENMM2_DEBUG_RESPAWN_MS=[,...]` (or `+`) puts the car back at its @@ -1080,9 +1086,9 @@ messages, their sizes and limits), `test_fuzz.cpp` (both messages, decoded and sent to a client) and `tests/game/test_prop_sync.cpp` (a host and a client in one process through a lossy link: the host's knocks and pieces, a predicted knock handed over and one undone, other cars passing through a -client's props, moving kinematic cars breaking props as a car of their mass -would, loss and reordering, a big crash, the catch-up, thrown car parts, -refused input, and on retail data the same placement on every machine). +client's props, a replayed car meeting a prop as a real sample does, loss +and reordering, a big crash, the catch-up, thrown car parts, refused input, +and on retail data the same placement on every machine). ### Diagnosing replication @@ -1098,7 +1104,19 @@ each: * `R wall id stale x y z vx vy vz sampleTime`: each remote car as sampled for the frame. +* the props' lines (lowercase tags, `game::PropTrace`): every placed prop + once (`p`), every prop that broke loose here and what broke it (`k`), a + prediction undone (`x`), every 250 ms of session time (`t`) the props down + (`b`), the knocked-over props and parts shown (`h`) and each car's damage + level and dents (`d`), and each simulated car's damaging impacts with their + cause (`i`). + 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). +`netprobe syncreport ...` compares the machines' props pair by pair +at the same session times: the props knocked on one machine only, how far +apart in time both knocked them, where the knocked-over props and parts +rest, each car's damage, and each car's damaging impacts as its own machine +predicted them and as the host simulated them. 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/docs/parity/openmm2-only.md b/docs/parity/openmm2-only.md index 33fc685..7da8f4e 100644 --- a/docs/parity/openmm2-only.md +++ b/docs/parity/openmm2-only.md @@ -151,7 +151,7 @@ in the summary above. | `BangerSet::setReplica`, `phys::Instance::acceptsContact` | `dgPhysManager::CollideInstances` | deviation | A client's props take contacts from its own car and its own simulated props only; MM2's take them from every mover. | | `BangerSet::breakPlaced`, `restoreStanding`, `showMirror`, `hideMirror` | `dgUnhitBangerInstance::Impact`, `Reset`, `dgHitBangerInstance` | openmm2 | A client's placed prop leaves its room as Impact makes it leave (without a body), stands again as Reset makes it stand (a prediction the host did not confirm), and the host's pieces are hit instances of their own. | | `BangerSet::attachEntity` on a mirror | `dgBangerActive::Attach` | deviation | MM2 starts an active at rest (a placed or resting prop is); a client's mirror of a host piece in flight starts from the host's motion. | -| `phys::calcBangerImpact` with `Body::kinematicBreaksBangers` | `dgImpact::CalcImpact` | deviation | The host's kinematic copies of the other players' cars meet a banger with their phInertialCS (the same break test and share of the motion as a simulated car of their mass); their own response is left out. Without the flag a kinematic body cannot break a banger, as before. MM2's network cars are simulated vehCars. | +| `phys::World::collideHeld` with `Instance::heldInertia`, `World::beginReplay` | (none: MM2 does not replay) | openmm2 | A client's replayed car meets a prop with the body a real hit gives it (`dgBangerActive::Attach`'s), held still; a light one gives way after the replay's first hit (inferred stand-in for its flight). | | Thrown car parts (`ejectPart`'s tag, `game::carPartTag`) | `vehBreakableMgr::Eject` on every machine's simulation | deviation | The host throws them for everyone; a client takes another car's parts off without a throw and throws its own car's at once (handed over to the host's). | ## Files diff --git a/tests/game/test_prop_sync.cpp b/tests/game/test_prop_sync.cpp index 99ce60b..26037b7 100644 --- a/tests/game/test_prop_sync.cpp +++ b/tests/game/test_prop_sync.cpp @@ -12,6 +12,8 @@ #include "game/bangers/BangerData.h" #include "game/bangers/BangerSet.h" #include "game/bangers/PropPlacement.h" +#include "game/net/NetGame.h" +#include "game/net/NetProps.h" #include "game/net/PropSync.h" #include "game/world/Gizmos.h" #include "net/PropState.h" @@ -21,12 +23,13 @@ #include +#include #include #include #include #include -#include #include +#include using namespace mm2; using namespace mm2::game; @@ -651,6 +654,32 @@ Placement placeAsAMachine(const vfs::Vfs& v, const char* name, GameMode mode, in } // namespace +// A client takes knocks from the host only: another player's events reach it +// through the host's relay, and a forged one knocks nothing. +TEST(PropSync, KnocksCountFromTheHostOnly) { + TempBangers files; + bangers::BangerDataLibrary lib(files.vfs); + Machine client(lib); + client.place(layout()); + NetGame net(NetOptions{}); // in no session: a client's side + NetProps props; + props.setup(net, client.set, [](const phys::Instance&) { return false; }); + ASSERT_TRUE(props.client()); + net::PropKnocksEvent knocks; + knocks.catchUp = true; + knocks.knocks = {{1, 0}}; + NetGameEvent forged; + forged.from = 2; + forged.type = static_cast(net::kPropKnocksEvent); + forged.payload = net::encodePayload(knocks); + props.beforeStep(net, std::span(&forged, 1), 1000.0, nullptr); + EXPECT_TRUE(client.set.standing(1)); + NetGameEvent honest = forged; + honest.from = net::kHostPlayerId; + props.beforeStep(net, std::span(&honest, 1), 1100.0, nullptr); + EXPECT_FALSE(client.set.standing(1)); +} + // Retail data: every machine of a network race places the same props with // the same indices. The street props restart the random generator per road, // the parked cars (network races only) draw from the stream they leave, and From 276e5081de4f571b1fdc08af29b4a0ccfaeac69c Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Fri, 9 Oct 2026 14:12:51 -0700 Subject: [PATCH 031/129] Let the host alone decide Cops and Robbers' time and point limits mmMultiCR::UpdateLimit runs on the host, which tells the others (SendLimitReached). Every OpenMM2 machine checked the limits itself (sync review S6): the time agreed through the shared clock, the point limit only as long as the scores did. Now a client counts the time down and shows the warnings, but ends the game only when the host's limit message (0x8003, with the winner and its points) arrives. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/app/RaceScreen.cpp | 31 +++++++++++ src/game/session/CopsAndRobbers.cpp | 15 ++++-- src/game/session/CopsAndRobbers.h | 8 +++ tests/game/test_net_rules.cpp | 79 +++++++++++++++++++++++++++++ 4 files changed, 130 insertions(+), 3 deletions(-) create mode 100644 tests/game/test_net_rules.cpp diff --git a/src/app/RaceScreen.cpp b/src/app/RaceScreen.cpp index 52a5f09..2704de4 100644 --- a/src/app/RaceScreen.cpp +++ b/src/app/RaceScreen.cpp @@ -84,6 +84,21 @@ namespace { // ChangeSet) as OpenMM2 game events. constexpr auto kCrPickupRequest = static_cast(static_cast(net::GameEventType::Custom) + 1); constexpr auto kCrNewSet = static_cast(static_cast(net::GameEventType::Custom) + 2); +// mmMultiCR::SendLimitReached: the host's word that the time ran out or a +// point limit was reached (OpenMM2: with the winner and its points). +constexpr auto kCrLimit = static_cast(static_cast(net::GameEventType::Custom) + 3); +struct CrLimitEvent { + std::uint8_t pointLimit = 0; // 0: the time ran out + std::int32_t car = -1; + std::int32_t value = 0; +}; +template +bool serialize(S& s, CrLimitEvent& e) { + s.u8(e.pointLimit); + s.ranged(e.car, -1, static_cast(net::kMaxPlayers) - 1); + s.ranged(e.value, 0, 1 << 20); + return s.ok(); +} struct CrSetEvent { Vec3 gold, bank, hideout; }; @@ -2493,6 +2508,9 @@ class RaceScreen final : public Screen { st.goldMass = ctx.netGame->goldMass(); st.timeLimitSeconds = m_result.config.timeLimitMinutes * 60.0f; st.pointLimit = m_result.config.pointLimit; + // mmMultiCR::UpdateLimit: the host decides the limits and tells the + // others (sync review S6). + st.limitsFromHost = !ctx.netGame->isHost(); // Every machine starts with the same places (OpenMM2: the time the // host ordered the race seeds them, which every machine knows when it // loads; the host's sets follow by message). @@ -2651,6 +2669,14 @@ class RaceScreen final : public Screen { continue; m.type = Type::NewSet; m.set = {set->bank, set->gold, set->hideout}; + } else if (type == kCrLimit) { + // GameMessage: the host's limit (taken from the host only). + const auto limit = ev.as(); + if (limit && ev.from == net::kHostPlayerId && !host) + m_cr->limitReached(limit->pointLimit ? CopsAndRobbers::EventType::PointLimit + : CopsAndRobbers::EventType::TimeUp, + limit->car, limit->value); + continue; } else if (const auto g = ev.as(); g && (ev.type == net::GameEventType::GoldPickedUp || ev.type == net::GameEventType::GoldDropped || ev.type == net::GameEventType::GoldDelivered)) { @@ -2725,6 +2751,11 @@ class RaceScreen final : public Screen { } case E::TimeUp: case E::PointLimit: + // SendLimitReached: the host tells the others. + if (host) + ctx.netGame->sendEvent(kCrLimit, net::encodePayload(CrLimitEvent{ + static_cast(e.type == E::PointLimit), + e.car, e.value})); // UpdateLimit: the message for 3 s, then 3 s to the results // (state 9). m_session->showMessage(s.get(e.type == E::TimeUp ? 118 : 119, ""), 3.0f, false); diff --git a/src/game/session/CopsAndRobbers.cpp b/src/game/session/CopsAndRobbers.cpp index dbf69fe..b0a31e2 100644 --- a/src/game/session/CopsAndRobbers.cpp +++ b/src/game/session/CopsAndRobbers.cpp @@ -154,16 +154,18 @@ void CopsAndRobbers::deliver(int carId) { } void CopsAndRobbers::tickLimits(float dt) { - // UpdateLimit (the time and point limits) and UpdateTimeWarning. + // UpdateLimit (the time and point limits) and UpdateTimeWarning. A + // machine the host tells (limitsFromHost) only counts down and warns. if (m_settings.timeLimitSeconds > 0.0f) { m_timeLeft = std::max(0.0f, m_timeLeft - dt); - if (m_timeLeft < 0.1f) { + if (m_timeLeft < 0.1f && !m_settings.limitsFromHost) { m_over = true; m_events.push_back({EventType::TimeUp}); return; } } - checkLimits(); + if (!m_settings.limitsFromHost) + checkLimits(); if (m_over) return; if (m_settings.timeLimitSeconds > 0.0f) { @@ -177,6 +179,13 @@ void CopsAndRobbers::tickLimits(float dt) { } } +void CopsAndRobbers::limitReached(EventType type, int car, int value) { + if (m_over || (type != EventType::TimeUp && type != EventType::PointLimit)) + return; + m_over = true; + m_events.push_back({type, car, value}); +} + std::vector CopsAndRobbers::updateNetwork(float dt, int self, bool host, const std::vector& cars, const std::vector& impacts) { diff --git a/src/game/session/CopsAndRobbers.h b/src/game/session/CopsAndRobbers.h index 42ffe84..9fb9ae7 100644 --- a/src/game/session/CopsAndRobbers.h +++ b/src/game/session/CopsAndRobbers.h @@ -60,6 +60,10 @@ struct CrSettings { // UpdateBank / UpdateHideout: the base's room (found 3.75 m above it) is // covered or underground (level flags 0x0A), or is the car's room. std::function baseReachable; + // mmMultiCR::UpdateLimit runs on the host alone, which tells the others + // (SendLimitReached): a machine that is not the host counts the time + // down and shows the warnings, but ends only on limitReached(). + bool limitsFromHost = false; }; class CopsAndRobbers { @@ -125,6 +129,10 @@ class CopsAndRobbers { const std::vector& impacts); // A message from another machine (`from`); a host may answer with more. std::vector receive(const Message& message, int from, bool host); + // The host's word that a limit was reached (TimeUp or PointLimit, the + // winner's car and points as its event had them): the game is over here + // too. + void limitReached(EventType type, int car, int value); // A player left (mmMultiCR::SystemMessage 0x2d): the host drops a // leaver's gold where it is (2 m above the car), not back to its spawn. std::vector playerLeft(int id, bool host); diff --git a/tests/game/test_net_rules.cpp b/tests/game/test_net_rules.cpp new file mode 100644 index 0000000..54471b1 --- /dev/null +++ b/tests/game/test_net_rules.cpp @@ -0,0 +1,79 @@ +// The rules a network game's host decides and tells the others (OpenMM2's +// host authority; mmMultiCR::UpdateLimit / SendLimitReached for Cops and +// Robbers' limits, sync review S6). +#include "game/session/CopsAndRobbers.h" + +#include + +using namespace mm2; +using namespace mm2::game::session; + +namespace { + +CrLocations places() { + CrLocations loc; + for (int i = 0; i < 5; ++i) + loc.points.push_back({100.0f * static_cast(i), 0, 0}); + return loc; +} + +} // namespace + +TEST(NetRules, AClientCountsTheTimeDownButTheHostEndsTheGame) { + CrSettings settings; + settings.timeLimitSeconds = 120.0f; + settings.limitsFromHost = true; + CopsAndRobbers client(settings, places()); + client.addCar(1, CrTeam::Robber); + const std::vector cars{{1, CrTeam::Robber, {5000, 0, 0}, false, false}}; + // Past the limit (and through the 1 minute warning) without the host's word. + for (int i = 0; i < 1300; ++i) + client.updateNetwork(0.1f, 1, false, cars, {}); + EXPECT_FALSE(client.over()); + EXPECT_LT(client.timeRemaining(), 0.1f); + bool warned = false; + for (const auto& e : client.takeEvents()) { + EXPECT_NE(e.type, CopsAndRobbers::EventType::TimeUp); + warned = warned || (e.type == CopsAndRobbers::EventType::TimeWarning && e.value == 1); + } + EXPECT_TRUE(warned); + // The host's message ends it, once. + client.limitReached(CopsAndRobbers::EventType::TimeUp, -1, 0); + client.limitReached(CopsAndRobbers::EventType::TimeUp, -1, 0); + EXPECT_TRUE(client.over()); + const auto events = client.takeEvents(); + ASSERT_EQ(events.size(), 1u); + EXPECT_EQ(events[0].type, CopsAndRobbers::EventType::TimeUp); + // Nothing but a limit ends a game. + CopsAndRobbers other(settings, places()); + other.limitReached(CopsAndRobbers::EventType::GoldTaken, 1, 0); + EXPECT_FALSE(other.over()); +} + +TEST(NetRules, AClientLeavesThePointLimitToTheHost) { + CrSettings settings; + settings.pointLimit = 100; + settings.limitsFromHost = true; + CopsAndRobbers client(settings, places()); + client.addCar(1, CrTeam::Robber); + client.addCar(2, CrTeam::Robber); + // Player 2 scores 125 by the host's messages: the client does not end. + client.receive({CopsAndRobbers::Message::Type::GoldTaken, 2, {}, {}}, 0, false); + client.receive({CopsAndRobbers::Message::Type::GoldDelivered, 2, {}, {}}, 0, false); + const std::vector cars{{1, CrTeam::Robber, {5000, 0, 0}, false, false}}; + client.updateNetwork(0.1f, 1, false, cars, {}); + EXPECT_EQ(client.playerScore(2), 125); + EXPECT_FALSE(client.over()); + client.limitReached(CopsAndRobbers::EventType::PointLimit, 2, 125); + EXPECT_TRUE(client.over()); + + // The host itself still ends at the limit. + settings.limitsFromHost = false; + CopsAndRobbers host(settings, places()); + host.addCar(1, CrTeam::Robber); + host.addCar(2, CrTeam::Robber); + host.receive({CopsAndRobbers::Message::Type::GoldTaken, 2, {}, {}}, 2, true); + host.receive({CopsAndRobbers::Message::Type::GoldDelivered, 2, {}, {}}, 2, true); + host.updateNetwork(0.1f, 1, true, cars, {}); + EXPECT_TRUE(host.over()); +} From 438fd01a20c21fe94e3b9d9cc75eaf48eb8897f7 Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Fri, 9 Oct 2026 14:14:43 -0700 Subject: [PATCH 032/129] Let a car the client knocked loose knock the next one loose A client's knock and the host's happened within 6 ms of each other, yet the knocked car rear-ended into another drifted 2.8 m from the host's: on the host it knocked the next car loose, on the client that car was a wall, since only the client's own car knocks cars loose there. A client's knocked cars' bodies now count as its player's in what they hit (TrafficBodies::Source::bodiesHitAsPlayer; the AI's do not, as before). Also the docs: the hit reports gone, the traffic shown on the host's clock. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/multiplayer.md | 70 +++++++++++++++++------------------ src/game/TrafficBodies.cpp | 2 + src/game/TrafficBodies.h | 4 ++ src/game/net/NetTrafficCars.h | 7 +++- 4 files changed, 46 insertions(+), 37 deletions(-) diff --git a/docs/multiplayer.md b/docs/multiplayer.md index b4f892f..427fdc7 100644 --- a/docs/multiplayer.md +++ b/docs/multiplayer.md @@ -16,7 +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/AmbientState.h` | The shared cruise traffic: `AmbientStateMsg` | | `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) | @@ -306,9 +306,7 @@ packet to the game or discovery port, and whatever answers as the router. 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. + model once per car shown (a model that fails is not tried again). ### Session clock @@ -382,9 +380,9 @@ types, with payload structs in `Protocol.h`: `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: Cops and Robbers uses 0x8001 and 0x8002, the shared -cruise traffic's hit report (`TrafficHitEvent`, client to host) 0x8010, a -car's damage (`VehicleDamageEvent`, see "Damage") 0x8020. +up are free for the game: Cops and Robbers uses 0x8001 and 0x8002, a car's +damage (`VehicleDamageEvent`, see "Damage") 0x8020 (0x8010, the shared +traffic's hit report until protocol 6, is unused). ### Race start @@ -543,8 +541,13 @@ and, where it differs from its speed, its speed over the ground (`ai::Traffic` moves a car along its curves by their parameter: in a turn it covers 20-30 % more or less ground than its speed, and a car held at the end of its lane covers none). The client (`game::TrafficClient`) shows every car -at the session time its own car reaches in the frame's steps -(`RaceScreen::netTrafficPresentTime`), predicted from its newest state +at the session time its own car's state is at on the host's clock +(`RaceScreen::netTrafficCarTime`): the time its car reaches in the frame's +steps plus its lead over the host's simulation of it (its inputs' way to the +host and their wait in the host's queue, about half a round trip and a +sample or two), which it measures from the host's states of its car (the +host's session time after sample n against the time it ran sample n +itself, smoothed). Each car is predicted there from its newest state (`game::TrafficPrediction`): a rail car along an arc at its speed over the ground and acceleration, stopping at 0; a police car or a knocked car along its velocity, turning with its yaw rate, at most 500 ms ahead (a rail car @@ -566,27 +569,23 @@ client's level in `game::TrafficBodies`, as the host's rail cars are of its own, with the received cars as their traffic (`game::NetTrafficCars`): when the client's car hits one, it takes a body at once (`aiVehicleInstance::AttachEntity`, `aiVehicleActive`) and the collision runs -the host's physics, the two cars pushing each other. Only the client's own -car knocks a car loose there (the police and the other players' cars reach -the client in the host's messages). The car stays the client's until a host -message shows it off its rail too, or still on its rail at a time half a -round trip and 150 ms after the hit (the host did not knock it); the drawing -then blends from where the local body left it to the host's car. The cars -off their rails on the host meet the client's car as kinematic instances -moving at their predicted velocities (`game::TrafficProxies`), the police as -kinematic bodies. - -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. (With the host simulating every player's -car, the hits happen on the host and the reports go: see the review.) +the host's physics, the two cars pushing each other: the host simulates the +same hit from the same inputs at the same session time (see "Players' +cars"). Only the client's own car knocks a car loose there (the police and +the other players' cars reach the client in the host's messages), and not +while it runs its samples again after a correction (`World::replaySample` +holds everything else still). While the knocked car moves, the local body +leads, being the better guess (it runs the host's physics from the same +hit; the host's knocked car arrives a trip old and is predicted along its +velocity while it tumbles); once the host's messages show it off its rail +too and the local body has come to rest, or the host's car is 5 m from it, +or a host state stamped 150 ms after the hit still shows it on its rail (the +host did not knock it), the host's messages lead and the drawing blends +from where the local body left it. The cars off their rails on the host meet +the client's car as kinematic instances moving at their predicted velocities +(`game::TrafficProxies`), the police as kinematic bodies. The hits happen on +the host: until protocol 6 a client reported its hits (`TrafficHitEvent`), +because the host's copy of its car lagged. 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 @@ -883,8 +882,9 @@ When `config.multiplayer && ctx.netGame`: 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). + players; the clients' cars are its own simulated ones) and after the + physics step sends each client the cars round its car + (`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`). @@ -1017,9 +1017,9 @@ In a cruise with shared traffic the trace also has the traffic and police (`game/net/TrafficTrace.h`): the host's `TH` lines, each car near a player at the session time its state belongs to (the AI's step or the physics'); a client's `TC` lines, the cars where its car meets them each frame, and `TV`, -that frame's session time of its car; the client's hit reports (`TX`), the -host's knocks and refused reports (`TK`, `TR`) and the client's own knocks -going back to the host's messages (`TL`). `mm2tool nettrace +that frame's session time of its car on the host's clock; the cars the +client's car knocked loose (`TX`), the host's cars leaving their rails +(`TK`) and the client's knocks going back to the host's messages (`TL`). `mm2tool nettrace ` compares them: each car's error against the host's at the session time of the client's car, the cars near the client one machine had and the other did not, the police targets, the hits and knocks. diff --git a/src/game/TrafficBodies.cpp b/src/game/TrafficBodies.cpp index 81e21a6..b40f524 100644 --- a/src/game/TrafficBodies.cpp +++ b/src/game/TrafficBodies.cpp @@ -386,6 +386,8 @@ void TrafficBodies::Active::attach(RailCar& car) { // phCollider::Init with the ICS (the ICS matrix places the bound), // SetImpactCB, Reset; the collider's id is 0. body.audioId = 0; + // OpenMM2: on a shared-traffic client its hits count as the player's. + body.player = m_owner.m_source.bodiesHitAsPlayer(); body.resetCollider(); body.collider.matrix = &ics.matrix; body.collider.reset(); diff --git a/src/game/TrafficBodies.h b/src/game/TrafficBodies.h index 58f95f8..f473304 100644 --- a/src/game/TrafficBodies.h +++ b/src/game/TrafficBodies.h @@ -79,6 +79,10 @@ class TrafficBodies final : public InstanceSource { (void)byPlayer; return true; } + // Whether the cars' bodies count as the local player's in what they + // hit (a client's knocked car knocks the next one loose, as on the + // host). The AI's do not (nothing of theirs asks). + virtual bool bodiesHitAsPlayer() const { return false; } }; // The world's level (phys::World::setLevel) gives the rail cars their diff --git a/src/game/net/NetTrafficCars.h b/src/game/net/NetTrafficCars.h index 0f3ee25..39d81cf 100644 --- a/src/game/net/NetTrafficCars.h +++ b/src/game/net/NetTrafficCars.h @@ -11,8 +11,9 @@ // collision runs as it does on the host: the client's car pushes a car of // its mass instead of meeting a wall that never gives way, and the car // flies off when it is hit rather than a round trip later. Only this -// machine's car knocks a car loose here; the host's police, the other -// players and the cars it knocked itself reach the client in its messages. +// machine's car, and the cars it knocked loose, knock a car loose here; the +// host's police, the other players and the cars they knocked reach the +// client in its messages. // // The knocked car is this machine's until either the host's messages show it // off its rail too and its body here has come to rest (or the host's car is @@ -79,6 +80,8 @@ class NetTrafficCars final : public TrafficBodies::Source { void detach(int carId, const Mat34& pose, bool upright) override; void setPhysicalTransform(int carId, const Mat34& transform) override; bool attachable(int carId, bool byPlayer) const override; + // A car knocked loose here knocks the next one loose too, as on the host. + bool bodiesHitAsPlayer() const override { return true; } private: struct Knock { From 6764d4de4f410520061e1c2bf1ecc752694cf95a Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Fri, 9 Oct 2026 14:19:04 -0700 Subject: [PATCH 033/129] Keep a client's own pieces from moving the host's With a host and two clients, a traffic light's two pieces lay together on one client 6.4 m from where the host's rested: a piece the client simulated had woken the other, and the two kept each other moving down the hill (75 of 130 traces simulated there) while the host's slept. The host's pieces now move on a client only for its own car; its own predicted pieces still knock the placed props, as the host's would. A host piece the client's car set moving goes back to the host's after 4 s even if it still creeps, as a stand-in does, and a predicted piece the host's ring does not hold disappears then even if it never rests. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/game/bangers/BangerSet.cpp | 21 ++++++++++++++++----- src/game/bangers/BangerSet.h | 5 ++++- src/game/net/PropSync.cpp | 18 ++++++++++++++++-- src/game/net/PropSync.h | 1 + tests/game/test_prop_sync.cpp | 28 ++++++++++++++++++++++++++++ 5 files changed, 65 insertions(+), 8 deletions(-) diff --git a/src/game/bangers/BangerSet.cpp b/src/game/bangers/BangerSet.cpp index f27fcc7..bd21347 100644 --- a/src/game/bangers/BangerSet.cpp +++ b/src/game/bangers/BangerSet.cpp @@ -231,7 +231,7 @@ class BangerSet::Prop final : public phys::Instance { } // OpenMM2: a network client's props take contacts from its own car and // its own props only. - bool acceptsContact(const phys::Instance& other) const override { return m_set.acceptsFrom(other); } + bool acceptsContact(const phys::Instance& other) const override { return m_set.acceptsFrom(m_index, other); } // OpenMM2: the active a car's hit would attach (World::replaySample). bool heldInertia(phys::InertialCS& out) const override { return m_set.heldInertia(m_index, out); } @@ -1150,12 +1150,16 @@ void BangerSet::setReplica(std::function localTouch m_localToucher = std::move(localToucher); } -bool BangerSet::acceptsFrom(const phys::Instance& other) const { +bool BangerSet::acceptsFrom(std::size_t i, const phys::Instance& other) const { if (!m_replica) return true; - // The props this machine simulates (its predictions) go on hitting - // others as they would. - return isActiveBody(&other) || (m_localToucher && m_localToucher(other)); + if (m_localToucher && m_localToucher(other)) + return true; + // The props this machine simulates (its predictions) go on knocking + // the placed props as they would; the host's pieces move only for this + // machine's car (two of them lying together would otherwise keep each + // other moving here while the host's rest). + return !m_instances[i].mirror && isActiveBody(&other); } void BangerSet::breakPlaced(std::size_t i) { @@ -1241,6 +1245,13 @@ void BangerSet::showMirror(std::size_t slot, const MirrorSpec& spec, const Mat34 moveToRoom(i, room); } +void BangerSet::releaseMirror(std::size_t slot) { + if (slot >= m_mirrors.size() || m_mirrors[slot] == static_cast(-1)) + return; + if (Active* a = activeOf(m_mirrors[slot])) + detachMe(*a); +} + void BangerSet::hideMirror(std::size_t slot) { if (slot >= m_mirrors.size() || m_mirrors[slot] == static_cast(-1)) return; diff --git a/src/game/bangers/BangerSet.h b/src/game/bangers/BangerSet.h index 81c16b0..e49fbc2 100644 --- a/src/game/bangers/BangerSet.h +++ b/src/game/bangers/BangerSet.h @@ -266,6 +266,9 @@ class BangerSet final : public InstanceSource { const Vec3& velocity, const Vec3& spin); // Takes it out of the world (not drawn, not collidable, no body). void hideMirror(std::size_t slot); + // Ends this machine's simulation of it (its active detaches; it stays + // where it is). + void releaseMirror(std::size_t slot); std::size_t mirrorCount() const { return m_mirrors.size(); } private: @@ -296,7 +299,7 @@ class BangerSet final : public InstanceSource { bool inWorld(const Active& a) const; void unhitImpact(std::size_t i, const phys::Instance* by); void syncActiveList(); - bool acceptsFrom(const phys::Instance& other) const; + bool acceptsFrom(std::size_t i, const phys::Instance& other) const; const BangerDataLibrary& m_data; phys::World* m_world = nullptr; diff --git a/src/game/net/PropSync.cpp b/src/game/net/PropSync.cpp index 37e0704..05c9475 100644 --- a/src/game/net/PropSync.cpp +++ b/src/game/net/PropSync.cpp @@ -563,7 +563,17 @@ void PropClient::update(BangerSet& set, double now, const CarPartResolver& carPa // left where it stopped until the host has pushed it too and it rests // there (or the prediction's time is up). const auto& mine = set.instances()[mirror]; - if (mine.active >= 0) { + if (mine.active >= 0 && slot.local && now - slot.localSince >= m_options.handoverMs) { + // Still moving here after that long (creeping down a hill, which + // phSleep may never stop): back to the host's. + set.releaseMirror(index); + slot.local = false; + slot.blendFrom = mine.matrix; + slot.blendStart = now; + ++m_stats.handovers; + } else if (mine.active >= 0) { + if (!slot.local) + slot.localSince = now; slot.local = true; slot.holdUntil = now + m_options.predictTimeoutMs; slot.hostMoved = false; @@ -606,7 +616,11 @@ void PropClient::update(BangerSet& set, double now, const CarPartResolver& carPa if (inst.state == BangerSet::State::Gone || standIns.contains(i)) continue; const LocalPiece& piece = m_pieces[k]; - if (!piece.restedAt || now - piece.since < m_options.orphanMs) + // At rest that long after it was made; or, one that never rests + // (creeping), once a stand-in would have handed over. + const bool due = piece.restedAt ? now - piece.since >= m_options.orphanMs + : now - piece.since >= m_options.orphanMs + m_options.handoverMs; + if (!due) continue; if (inst.source >= 0 && m_predicted.contains(static_cast(inst.source)) && !m_predicted[static_cast(inst.source)].confirmed) diff --git a/src/game/net/PropSync.h b/src/game/net/PropSync.h index 1d5c40d..19af0bc 100644 --- a/src/game/net/PropSync.h +++ b/src/game/net/PropSync.h @@ -208,6 +208,7 @@ class PropClient { std::deque generations; // oldest first // Presentation. bool local = false; // the mirror was pushed by this machine's car + double localSince = 0.0; // ... at that time double holdUntil = 0.0; // ... and is left where it stopped until then bool hostMoved = false; // ... or until the host's has moved and rests again std::optional blendFrom; // hand-over from what was shown here diff --git a/tests/game/test_prop_sync.cpp b/tests/game/test_prop_sync.cpp index 26037b7..d5954dc 100644 --- a/tests/game/test_prop_sync.cpp +++ b/tests/game/test_prop_sync.cpp @@ -654,6 +654,34 @@ Placement placeAsAMachine(const vfs::Vfs& v, const char* name, GameMode mode, in } // namespace +// The host's pieces move on a client only for its own car: a piece the +// client simulates (a prediction) passes through them, so two pieces lying +// together cannot keep each other moving there while the host's rest. +TEST(PropSync, OnlyTheClientsCarMovesTheHostsPieces) { + Race r; + Car car({0, 1, 3.0f}, {0, 0, -10}); + r.host.world.add(&car.body); + r.run(1.0); + r.host.world.remove(&car.body); + r.run(4.0); + const auto shown = piecesOf(r.client.set, 0); + ASSERT_EQ(shown.size(), 1u); + std::size_t mirror = 0; + for (std::size_t i = 0; i < r.client.set.instances().size(); ++i) + if (r.client.set.instances()[i].mirror && r.client.set.instances()[i].source == 0) + mirror = i; + ASSERT_GT(mirror, 0u); + // A piece of the client's own dropped onto it. + const auto* data = r.lib.find("light"); + Mat34 above = Mat34::translation(shown.begin()->second + Vec3{0, 1.5f, 0}); + r.client.set.ejectPart(*data, "light", "", 0, above, 0.0f, 1); + for (int i = 0; i < 90; ++i) { + r.frame(); + ASSERT_LT(r.client.set.instances()[mirror].active, 0) << i; + } + expectSamePieces(r, 0, 0.01f); +} + // A client takes knocks from the host only: another player's events reach it // through the host's relay, and a forged one knocks nothing. TEST(PropSync, KnocksCountFromTheHostOnly) { From 921513d8f131a48ffab21af5e948682f90e198d6 Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Fri, 9 Oct 2026 14:27:48 -0700 Subject: [PATCH 034/129] Add a development hook that rounds a client's car differently OPENMM2_DEBUG_NETCARS_NOISE= nudges a network client's car's momentum by about that fraction each sample, standing in for a client built by another compiler, to measure how the host's corrections cope. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/app/RaceScreen.cpp | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/src/app/RaceScreen.cpp b/src/app/RaceScreen.cpp index 2704de4..ee9eb43 100644 --- a/src/app/RaceScreen.cpp +++ b/src/app/RaceScreen.cpp @@ -4803,9 +4803,25 @@ class RaceScreen final : public Screen { m_prediction.beginSample(*m_player, m_netDriver, in); } void afterNetSample() { - if (m_player && m_traceNet && !m_traceNet->isHost()) - m_prediction.endSample(*m_player, m_netDriver); + if (!m_player || !m_traceNet || m_traceNet->isHost()) + return; + // Development aid: OPENMM2_DEBUG_NETCARS_NOISE= nudges a + // client's car's momentum by about that fraction each sample, as a + // machine built by another compiler might round differently. + static const float noise = [] { + const char* v = std::getenv("OPENMM2_DEBUG_NETCARS_NOISE"); + return v ? static_cast(str::parseDouble(v).value_or(0.0)) : 0.0f; + }(); + if (noise > 0.0f) { + m_noiseState = m_noiseState * 1664525u + 1013904223u; + const float k = 1.0f + noise * (static_cast(m_noiseState >> 8) / 8388608.0f - 1.0f); + auto& ics = m_player->sim().body.ics; + ics.linearMomentum = ics.linearMomentum * k; + ics.linearVelocity = ics.linearMomentum * ics.invMass; + } + m_prediction.endSample(*m_player, m_netDriver); } + std::uint32_t m_noiseState = 1; // Host: a simulated player's car was reset or its damage cleared // (vehCar::ClearDamage): its damage starts again on every machine. From b01172e548c98076cd7bfe40b24ff06331e79a80 Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Fri, 9 Oct 2026 14:30:57 -0700 Subject: [PATCH 035/129] Hand a knocked car back to the host's messages 3 m apart, not 5 Where the client's car and the host's met a traffic car a little differently (the client's car was then corrected by 1.5-2.3 m), the car it knocked loose drifted from the host's, and handed back only at 5 m it jumped 7 m. At 3 m the jump stays smaller; where the two agree, 0.2-0.6 m apart, the local body still leads. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/multiplayer.md | 2 +- src/game/net/NetTrafficCars.h | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/multiplayer.md b/docs/multiplayer.md index 427fdc7..6933ada 100644 --- a/docs/multiplayer.md +++ b/docs/multiplayer.md @@ -578,7 +578,7 @@ holds everything else still). While the knocked car moves, the local body leads, being the better guess (it runs the host's physics from the same hit; the host's knocked car arrives a trip old and is predicted along its velocity while it tumbles); once the host's messages show it off its rail -too and the local body has come to rest, or the host's car is 5 m from it, +too and the local body has come to rest, or the host's car is 3 m from it, or a host state stamped 150 ms after the hit still shows it on its rail (the host did not knock it), the host's messages lead and the drawing blends from where the local body left it. The cars off their rails on the host meet diff --git a/src/game/net/NetTrafficCars.h b/src/game/net/NetTrafficCars.h index 39d81cf..e54cdad 100644 --- a/src/game/net/NetTrafficCars.h +++ b/src/game/net/NetTrafficCars.h @@ -17,7 +17,7 @@ // // The knocked car is this machine's until either the host's messages show it // off its rail too and its body here has come to rest (or the host's car is -// 5 m from it): it then follows them, the drawing blending from where the +// 3 m from it): it then follows them, the drawing blending from where the // local body left it; or they show it still on its rail at a time well after // the hit, when the host would have knocked it (the host did not: it goes // back to its rail, blended the same way). With the host simulating the @@ -52,7 +52,7 @@ class NetTrafficCars final : public TrafficBodies::Source { static constexpr double kMaxLocalMs = 3000.0; // A car the host has knocked too stays this machine's until its body // comes to rest, unless the host's car is this far from it. - static constexpr float kDivergeMetres = 5.0f; + static constexpr float kDivergeMetres = 3.0f; // A car this machine knocked loose and handed back to the host's // messages this frame, and where it was then. From 71d9738fd317cfffa64ffed975e63a5fb0363002 Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Fri, 9 Oct 2026 14:31:27 -0700 Subject: [PATCH 036/129] Record the props and damage desync work docs/review/multiplayer-desync-props.md: how it was measured, what MM2 does (nothing about props travels; every machine knocks its own), the findings before (no knock shared; the host's traffic's on the host only; separate hits resting 10.6 m apart; thrown parts per machine; parked cars and traffic lights alike; replays meeting props as walls; mirrors waking each other), the host-authoritative design, the damage, the bandwidth, the tests and what is left for the players' cars. The Props section of docs/multiplayer.md gets the final measurements. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/multiplayer.md | 14 +- docs/review/multiplayer-desync-props.md | 250 ++++++++++++++++++++++++ 2 files changed, 258 insertions(+), 6 deletions(-) create mode 100644 docs/review/multiplayer-desync-props.md diff --git a/docs/multiplayer.md b/docs/multiplayer.md index a14b01c..7a66035 100644 --- a/docs/multiplayer.md +++ b/docs/multiplayer.md @@ -776,13 +776,15 @@ them, and dent them, through single player's code. Measured on this machine through `netprobe relay` (80 ±20 ms each way, 2 % loss, reordering; and 150 ±20 ms, 5 % loss), a host and one or two clients driving into props, traffic lights and parked cars (the record: -`docs/review/multiplayer-desync-props.md`): before, no prop knocked on one -machine was knocked on another by the same hit, 11-16 props differed between -the screens at the end of a minute, and the props both cars hit separately -rested 8-21 m apart; after, every knock reached every machine (150-290 ms +`docs/review/multiplayer-desync-props.md`): with 0.3.1's props no knock on +one machine was the same knock on another, 6-16 props differed between the +screens at the end of a minute, and props two cars hit separately rested +10.6 m apart (0.1-2.1 m once the host simulated the client's car, in two +separate simulations); after, every knock reached every machine (150-260 ms apart, a playout delay), no prop differed at the end, and the knocked-over -props, parked cars and parts rested within 3 mm of the host's (the -position's quantization). The host sent 0.3-4.0 KB/s to each client (10 s +props, parked cars and parts rested within 5 mm of the host's (the +position's quantization is 4 mm). A client alone among props had no +correction of its car. The host sent 0.07-6 KB/s to each client (10 s averages; a big crash in the test, 27 props knocked by one car, 5 KB/s for a few seconds, at most 425 bytes a message); the ring bounds a message at 40 slots, about 1 KB. diff --git a/docs/review/multiplayer-desync-props.md b/docs/review/multiplayer-desync-props.md new file mode 100644 index 0000000..6f9709f --- /dev/null +++ b/docs/review/multiplayer-desync-props.md @@ -0,0 +1,250 @@ +# Multiplayer desync: props and damage + +Worked on 2026-10-09 from `integration` at cd5ae29 (release 0.3.1), merged +with integration at 3f73059 (the host simulates every player's car), on the +maintainer's report that in a multiplayer cruise over the Internet +"knocked-over props, parked cars, objects or car damage look different on +each screen", and on the maintainer's decision that the host is the +authority for everything: props simulated on the host and replicated, a +client predicting the knocks of its own car, props knocked earlier reaching +players who arrive later. + +## Summary + +Before, **no prop knock was shared**: in 0.3.1 a prop another player knocked +stood on every other screen, the props the host's shared traffic and police +knocked stood on every client, and props two cars hit separately rested +metres apart. Parked cars (network races) and traffic lights (cruise) are +props and behaved the same. With the host simulating the players' cars +(integration at 3f73059) a client's knocks happened on the host too, but in +two separate simulations (pieces 0.1-2.1 m apart), and the host's own knocks +and its traffic's still reached no client. + +After, the host simulates every prop for everyone. In every run every knock +reached every machine a playout delay later, no prop differed at the end, and +every knocked-over prop, piece, parked car and traffic light rested within +5 mm of the host's (the position's quantization is 4 mm). A client's own car +knocks props at once and hands them over to the host's when they rest; of 49 +such predictions in the final runs one was undone (the host's simulation of +the car missed the prop). A client driving alone through props had no correction of its car in +40 s, and its car's impacts and damage were the host's to the unit. + +| Run (a minute of driving; host and client) | Knocked on one machine only, at the end | Knocked on both | Knock times apart | Resting apart (both at rest) | +| --- | --- | --- | --- | --- | +| slalom, 80 ±20 ms, 2 % loss, 0.3.1 | 16 of 18 | 2 (separate hits) | 3.8 s | 10.6 m | +| slalom, 80 ±20 ms, 2 % loss, integration | 14 of 31 | 17 (separate simulations) | 172 ms median | 0.11 m median, 2.1 m max | +| slalom, 80 ±20 ms, 2 % loss, after | 0 of 8 | 8 | 161 ms median, 181 max | 2 mm max (8 pieces) | +| slalom, 150 ±20 ms, 5 % loss, 0.3.1 | 11 of 12 | 1 (separate hits) | 7.8 s | (still moving) | +| slalom, 150 ±20 ms, 5 % loss, after | 0 of 14 | 14 | 245 ms median, 260 max | 3 mm max (17) | +| follow, 80 ±20 ms, 2 % loss, 0.3.1 | 6 of 6 | 0 | | | +| follow, 80 ±20 ms, 2 % loss, after | 0 of 11 | 11 | 188 ms median, 219 max | 3 mm max (11) | +| follow, 150 ±20 ms, 5 % loss, 0.3.1 | 12 of 12 | 0 | | | +| follow, 150 ±20 ms, 5 % loss, after | 0 of 13 | 13 | 248 ms median, 519 max | 3 mm max (17) | +| parked cars (checkpoint race), 80 ±20 ms, 0.3.1 | 5 of 5 | 0 | | | +| parked cars (checkpoint race), 80 ±20 ms, after | 0 of 5 | 5 | 185 ms median, 816 max | 2 mm max (6) | +| host and two clients, 150 ±20 ms, 5 % loss, after | 0 of 20 (each pair) | 20 | 258-260 ms median | 5 mm max (13-15) | +| one client alone through props, 80 ±20 ms, after | 0 of 9 | 9 | 171 ms median | 3 mm max (6) | + +Props down on one machine and standing on the other, at any 250 ms tick: +6-16 at the end of the 0.3.1 runs (in 72-93 % of the ticks); after, at most +2 or 3 for a moment (a knock between the host's simulation and its display on +the client, 3-15 % of the ticks), 0 at every end. + +## How it was measured + +* Host and clients on this machine, UDP port 2400 (relays 2401, 2402), each + with its own config and data directories, `FrameCap=120`, `VSync=false`, + `OPENMM2_NO_PORTMAP`, `netprobe relay` in front of the host for each client + (80 ±20 ms each way, 2 % loss, reordering; and 150 ±20 ms, 5 % loss). +* The cars driven by `OPENMM2_DEBUG_INPUT` from fixed starts + (`OPENMM2_DEBUG_START` in cruise, `OPENMM2_DEBUG_SPAWN` in a race): + **slalom** (San Francisco, the host leading and the client 15 m behind, + weaving along a sidewalk of street lights, parking meters, bus stops and + bins through two signalled intersections, with the shared traffic and + police on), **follow** (the same with longer swerves), **parked cars** (a + checkpoint race, both cars driving into a row of parked cars in a San + Francisco car park), the slalom with **a second client** behind the first, + and the slalom by **one client alone** (the host parked, traffic off). +* `OPENMM2_NET_TRACE` writes the props' lines next to the cars' (added, + docs/multiplayer.md "Diagnosing replication": every placed prop once, + every knock and what made it, every undone prediction, and every 250 ms of + session time the props down, the pieces and parts shown, each car's damage + level and dents, and each simulated car's damaging impacts with their + cause). `netprobe syncreport ...` compares every pair of machines' + props at the same session times, after the cars. +* "0.3.1" is this work's first build (on cd5ae29) with `OPENMM2_NETPROPS=local` + (added), which keeps each machine's own props; "integration" the final + build with `OPENMM2_NETPROPS=local`, which is 3f73059's props; "after" the + final build. +* Same-moment screenshots (`OPENMM2_DEBUG_NET_SHOT_MS`) with + `OPENMM2_DEBUG_FOCUS=prop:` (added: a placed prop, or its first + piece once knocked, framed alike on every machine): a parking meter a + client knocked lies in the same place on both screens once at rest. + +The scripts that ran these (`run.sh`, `cmp.sh`, the scenario files) were kept +in the session's scratch directory; everything they set is listed above. + +## What MM2 does + +Read: `mmGameMulti::GameMessageCB`, `SystemMessageCB`, `Init`, +`mmMultiRoam::GameMessage`, `Init`, `InitGameObjects`, the other modes' +`GameMessage`, `mmNetObject` (`PositionUpdate`, `Update`, `SetPositionData`), +`mmGame::InitGizmos`, `dgBangerManager`, `dgBangerActiveManager`, +`dgUnhitBangerInstance::Impact`, `dgImpact::CalcImpact`, `dgPhysManager`, +`vehBreakableMgr::Eject`; every use of the network flag (`mmGame`, +`mmGameManager`, `mmPlayer`, `mmGameMulti`): none touches props. + +* **Nothing about props travels.** No game message names a prop, a banger or + a gizmo; `mmNetObject` carries the cars only. +* **Every machine knocks its own.** Each network car is a `vehCar` simulated + on every machine (`mmNetObject::Update` declares it a type-3 mover, which + also keeps its room's props alive in `dgPhysManager::Update`), pulled + toward its owner's positions. Its collisions knock that machine's props, + break its parts off (`vehBreakableMgr::Eject` with that machine's random + numbers) and dent it, so in MM2 a knocked prop shows on every screen only + roughly and rests where each machine's simulation leaves it. +* **What a network game has**: parked cars only in the network races + (`mmGame::InitGizmos` skips them in cruise and Cops and Robbers), no ferries, + no cable cars (`mmGameMulti::Init`), traffic lights wherever `aiMap::Init` + runs (cruise and Cops and Robbers; the races skip it). OpenMM2 already + followed these (`world::GizmoKinds::forSession`, `RaceScreen::loadAi`). + +What MM2's model gives when two machines simulate the same hits: in a build +where every machine's copies of the others' cars could knock props, 4 props +knocked on both machines rested 7.9-21.5 m apart; at integration, where the +host's simulation of a client's car repeats the client's knocks, 0.1-2.1 m. +This is why OpenMM2 does not follow MM2 here (the maintainer's decision). + +## Findings (before) + +| # | Severity | Location (cd5ae29) | Scenario | Confirmed | Fix | +| --- | --- | --- | --- | --- | --- | +| D1 | gameplay-breaking | `phys/Impact.cpp` `calcBangerImpact`, `RaceScreen.cpp` `updateRemoteCars` | A network car was a kinematic body with no phInertialCS in its collider: dgImpact's impulse to stop it was zero, never beyond a banger's limit, so it never broke one (and passed through it). Every prop another player knocked stood on every other screen, a solid obstacle at its old place. | traces: 0 of 18, 0 of 12, 0 of 6 knocks shared by the same hit | 0ab89fe: the host's props are everyone's; at 3f73059 the host simulates the players' cars | +| D2 | visible | `RaceScreen.cpp` `loadAi`, `TrafficProxies` | The shared traffic and police run on the host only: their knocks (2-4 a minute in the runs) happened on the host only; a client's received cars are kinematic proxies. | traces: "traffic" and "police" knocks on the host only | 0ab89fe: the host's knocks reach everyone | +| D3 | visible | `BangerSet` (every machine its own) | Props two cars hit separately were knocked on both machines by different hits, seconds apart, and rested 10.6 m apart; with the host simulating the client's car, 0.1-2.1 m apart. | slalom runs | 0ab89fe: one simulation, the host's | +| D4 | visible | `RaceScreen.cpp` `updateNetCarDamage`, `breakParts` | A car's thrown parts were bangers of each machine: thrown when the damage event was fresh, with each machine's own random direction, resting in different places (or not thrown at all when the event came late). | by reading; the parts' descriptors in the trace | 0ab89fe: the host throws them, every client shows the host's ring | +| D5 | visible | `world::initGizmos` (network races) | Parked cars are props: the same as D1-D3. | parked runs: 5 knocked, none on both machines | as D1 | +| D6 | visible | `RaceScreen::addTrafficLightProps` | Traffic lights are props (round 3): the same as D1-D3, the signal gone on one screen and standing on the other. | 1-5 traffic lights a run on one machine only | as D1 | +| D7 | minor | `DamageSync` (0.3.0) | The cars' damage converged (each car's dents and level were its owner's; at most 1-3 dents apart for an event in flight), but the prop hits that caused a client's dents were knocks no other screen showed. | traces: dents apart mean ≤ 0.2 | as D1; damage is the host's at 3f73059 | +| D8 | visible (found after the merge) | `World::collideHeld` (3f73059) | A client replays its car when the host corrects it, with everything else held still. A prop there held like a wall (calcBangerImpact against a body without an ICS stops the car past the break limit) and a knocked-over piece like an infinitely heavy body: corrections after a prop hit of up to 1.5 m and 6.6 m/s that the real samples had not had. | merged build, slalom: 5 corrections over 30 cm within 1.5 s of a prop hit, 6.6 m/s | 0791996: the replay meets a prop with the body a real hit gives it, a light one giving way after its first hit | +| D9 | visible (found after the merge) | `PropClient` mirrors | With three machines, a traffic light's two pieces lay together on one client 6.4 m from where the host's rested: a piece the client simulated woke the other and they kept each other creeping down the hill. | three-machine run | 6764d4d: the host's pieces move on a client only for its own car; a local simulation of one ends after 4 s | + +Damaging impacts on each machine's own car by cause, 0.3.1 runs: the city +47, props 3, traffic 2; no player-versus-player impact. After, each car's +impacts as its own machine predicted them and as the host simulated them: +identical (cause, value and damage) for a client alone among props; where +its car also met the host's car or the traffic, the counts differed by one +or two (the players' cars' corrections). + +## The design (OpenMM2 extra) + +See docs/multiplayer.md, "Props", for the protocol and the rules; in short: + +* **Host**: the props as in single player with every car it simulates (its + own, the players', the shared traffic and police). Each frame, after + `BangerSet::update`: the placed props that broke loose go to everyone as a + reliable `PropKnocks` event (a catch-up of all of them to a machine that + reports the race loaded), and the ring of knocked-over props + (`dgBangerManager`, 40 slots) as `PropState`, the same unreliable message to + every client: 20 a second while a prop flies, 5 while props only creep, 2 + otherwise. +* **Identity**: a placed prop is its index; every machine places the same + props in the same order (verified on retail data, `PropSyncRetail`, and in + the runs: 7068 props, catalog `f5672068`, in a San Francisco cruise on host + and clients; 6637, `bf4d06a4`, in its checkpoint race). A piece is its prop + and BREAKnn part, a car's part its car, part and paint job. +* **Client**: only its own car may touch its props; the host's knocks apply + when it shows the host's props at their time, a playout delay in the past + as the players' cars; the host's ring is shown in mirror slots; its own + car's knocks are predictions, handed over to the host's pieces when both + rest, undone after 2 s without the host's knock; its replays meet props + with their mass. +* **Thrown car parts** are the host's ring like the rest. + +Deviations from MM2: the host's props on every screen instead of each +machine's own simulation; a client's props ignore the other cars (the host +decides); a prediction the host does not confirm stands up again; a mirror +attached here starts from the host's motion (`dgBangerActive::Attach` starts +at rest). Recorded in docs/parity/openmm2-only.md. + +## Damage + +The damage is the host's at 3f73059 (the players' cars agent): every car's +dents, parts and level from the host's own collisions. This work's part: + +* **Props hitting cars and cars hitting props on the host are single + player's.** The host's props are a single-player `BangerSet` (its replica + mode is a client's only) and the players' cars are `CarSim`s of the host's + world set up as its own car (`options.player`, the polygonal bound, damage + on); `calcBangerImpact` is MM2's for every simulated body, bit for bit. The + only difference from a single-player car is the mover type (3, not 4), + which marks what it hits as "hit by the player" for the AI, not for damage. + Measured: a client driving alone through 9 props in 40 s had no + correction, and the trace's impacts of its car on the host and on the + client (a prop at 2369, the city eight times at 22850 in all, damage 36001) + were the same. +* A prop that dents a client's car is now a knock every screen shows, at the + same place, and rests where the host's does. +* A client's car meets the host's flying pieces with their motion (before, + they were ghosts to it), so its prediction takes the hits the host's + simulation of it does. +* Thrown parts are the host's: a client takes the parts the host's records + take off (its own car's included) without a throw. +* A client's corrections after a prop hit (within 1.5 s): at integration + (separate props) 184 of 336 corrections, 65 over 30 cm, the largest 5.6 m + and 13.4 m/s; after, slalom 80 ms: 55 of 395, 2 over 30 cm, the largest + 0.38 m and 2.4 m/s; none for the client alone. + +## Bandwidth + +Per client, from the host's 10-second logs over the final runs: 72 bytes/s +(props at rest, every 0.5 s) to 3.4 KB/s with two players, 5.95 KB/s to each +of two clients in the busiest 10 s (three cars knocking 14 props). The ring +bounds a message at 40 slots (a flying piece 26 bytes, about 1 KB); knocks +are 31 bits each in reliable events (250 per 1 KiB event). A big crash in the +test (a car through 60 props, 27 knocked) cost 5 KB/s for a few seconds, 425 +bytes at most a message. The host sends the same message to every client: for +seven clients about 0.5-42 KB/s in these conditions, at most about 140 KB/s +in a crash that keeps all 40 slots flying. + +## Tests + +* `tests/net/test_prop_state.cpp`: the messages' round trips, sizes (a full + ring in one datagram, 250 live and 500 catch-up knocks in one event) and + malformed input; `test_fuzz.cpp`: both in the corpus and the decoders' + checks, and sent mutated to a client. +* `tests/game/test_prop_sync.cpp`: a host and a client in one process through + a lossy link: the host's knocks and pieces on the client, a client's knock + predicted and handed over, one undone, other cars passing through a + client's props, a client's own pieces not moving the host's, a replayed + car meeting a prop as a real sample does, 30 % loss with reordering, a big + crash, the catch-up, thrown car parts, knocks counted from the host only, + refused input and a different placement, and on retail data the same + placement on every machine (cruise at the host's and a client's traffic + density, a checkpoint race with parked cars) for San Francisco and London. +* Full suite with game data: 964 of 964 (3 skipped as always); the opponent + sweep 516/517 finished, 508/517 across the line; single-player smoke + screenshots (a San Francisco cruise, a London circuit). + +## For the players' cars agent + +* A client's props accept contacts only from its own car and trailer + (`ownCarBody`) and its own predicted pieces (`BangerSet::setReplica`). +* `World::beginReplay` (called by `CarPrediction::acknowledge`) starts a + replay: a light prop the replayed car hits gives way for the rest of it. +* A replay through the sample in which the car knocked a prop meets the + prop where it is now (knocked, flying away), not where it stood: the + replayed car then keeps the speed the knock took off, until the next + state. This is the largest remaining source of corrections after a prop + hit among other cars (up to 0.4-0.5 m in the runs); replaying the knock + itself needs the prediction's history to keep it. + +## Open + +* The client's prediction covers its own car only; a prop another player's + car hits is shown when the host's knock comes, as that car is shown, a + playout delay in the past. +* Props are not interest-managed: every client gets every knock and the + whole ring; the ring's 40 slots bound it. +* The `_GLIBCXX_ASSERTIONS` suite: see the final report (the disk was full + for a second build directory; the build was redone in place). From e99472064ba2da7e3e05f08be29f075483c7d9fc Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Fri, 9 Oct 2026 14:32:34 -0700 Subject: [PATCH 037/129] Record the shared traffic's desync review docs/review/multiplayer-desync-traffic.md: how the divergence between the host's and the clients' traffic and police was measured, before and after, in the client's car's present and on top of the host simulating every car; what caused it (the cars shown a trip and a playout delay in the past, the messages' stamps, infinite-mass rail cars, the AI's speed in turns, the budget's edge, no traffic at all once the clients sent no snapshots), the fixes, bandwidth, and what is left. docs/multiplayer.md and docs/parity/openmm2-only.md follow. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/multiplayer.md | 17 +- docs/review/multiplayer-desync-traffic.md | 244 ++++++++++++---------- 2 files changed, 148 insertions(+), 113 deletions(-) diff --git a/docs/multiplayer.md b/docs/multiplayer.md index 6933ada..00bbe13 100644 --- a/docs/multiplayer.md +++ b/docs/multiplayer.md @@ -589,14 +589,15 @@ because the host's copy of its car lagged. 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); 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). +`nettraffic` log: 20 messages a second to the client, 60-70 cars a message, +10.5-11.6 KB/s (13.4-15.6 KB/s before a far car's state came every fourth +message beyond 160 m); at density 1, 100-128 cars and about 21 KB/s, where +the 1100-byte budget binds (about 22 KB/s per client, 150 KB/s for seven +clients). The client's moving cars within 150 m were 3 cm (median) from the +host's at the same moment, 12 cm at the 90th percentile, through a relay of +60 ± 20 ms each way with 2 % loss; 2.6 m and 3.7 m before they were shown at +the present (docs/review/multiplayer-desync-traffic.md, which has the other +cases). ### Damage diff --git a/docs/review/multiplayer-desync-traffic.md b/docs/review/multiplayer-desync-traffic.md index 19b606c..0f6a6fc 100644 --- a/docs/review/multiplayer-desync-traffic.md +++ b/docs/review/multiplayer-desync-traffic.md @@ -1,33 +1,54 @@ # Multiplayer desync review: shared traffic and police -Reviewed on 2026-10-09 from `main` at cd5ae29 (release 0.3.1). The +Reviewed on 2026-10-09 from `main` at cd5ae29 (release 0.3.1), then on top +of integration at 3f73059 (the host simulates every player's car). The maintainer's report for this area: in a multiplayer cruise over the Internet, "different cars, or the same cars in different places, on each screen". MM2's network cruise has no traffic and no police (`mmGameMulti::Init`), so the shared traffic is OpenMM2's own design (docs/multiplayer.md, "Shared -traffic"; docs/parity/openmm2-only.md). The choices below are recorded as -OpenMM2 extras. Where MM2 has a comparable mechanism, it predicts its -network cars forward from their last packet (`mmNetObject::PositionUpdate`); -the traffic now does the same. +traffic"; docs/parity/openmm2-only.md), and the choices below are OpenMM2 +extras. Where MM2 has a comparable mechanism it predicts its network cars +forward from their last packet (`mmNetObject::PositionUpdate`); the traffic +now does the same. -During the work the maintainer decided that the host is to be the authority -for everything (players' cars included: clients upload inputs, the host -simulates every car, each client predicts its own and reconciles). The -traffic was already host-run; the client side below is designed for that -model too (see "The host-authoritative model"). +During the work the maintainer decided that the host is the authority for +everything: clients upload inputs, the host simulates every player's car, +each client predicts its own car and reconciles. The traffic was already +host-run. The client side is built for that model (see "The +host-authoritative model"), and the last measurements are on top of it. ## Summary -| Measure (moving traffic cars within 150 m of the client's car) | Before | After | -| --- | --- | --- | -| Error against the host at the same moment, 60 ± 20 ms each way, 2 % loss | TBD | TBD | -| ... 150 ± 30 ms, 5 % loss | TBD | TBD | -| Police in a chase | TBD | TBD | -| Cars near the client on one machine only | TBD | TBD | -| A collision with a traffic car shows on the client | a round trip and a playout delay later | at once | -| Bandwidth per client (traffic density 0.5) | TBD | TBD | +Errors are on the ground plane, against the host's car at the session time +the client's car is at on the host's clock (what a player sees of a car next +to its own car, compared with the host's). Median / 90th / 99th percentile, +from `mm2tool nettrace`; "before" is 0.3.1, "present" the client's car in +the present with the players' cars as in 0.3.1 (217db24), "host-simulated" +the final build on top of integration (players' cars simulated by the host, +the client's car predicted about 95 ms ahead of the host's at 60 ms each +way). + +| Measure | Before | Present | Host-simulated | +| --- | --- | --- | --- | +| Moving traffic car within 150 m, 60 ± 20 ms each way, 2 % loss | 2.61 / 3.70 / 4.37 m | 0.02 / 0.04 / 0.20 m | 0.03 / 0.12 / 0.61 m | +| ... 150 ± 30 ms, 5 % loss | 4.02 / 5.63 / 6.51 m | 0.02 / 0.09 / 0.62 m | 0.05 / 0.45 / 1.59 m | +| ... three players, the client at 40 ms | 2.44 / 3.91 / 6.74 m | 0.02 / 0.04 / 0.25 m | 0.03 / 0.11 / 0.55 m | +| ... three players, the client at 120 ms | 3.63 / 5.12 / 5.88 m | 0.02 / 0.08 / 0.45 m | 0.04 / 0.30 / 1.11 m | +| Moving police car chasing the client | 1.55 / 2.20 / 2.40 m | 0.06 / 0.19 / 0.78 m | 0.33 / 0.77 / 3.66 m | +| A car the client's car hit, the second after (60 ms) | 0.08 / 0.37 / 1.12 m (rear-end runs) | 0.03 / 0.22 / 1.13 m | 0.23-0.73 / 0.35-2.61 m | +| ... 150 ms | 1.28 / 5.82 / 5.99 m | 0.02 / 0.16 / 0.84 m | 1.50 / 6.71 m (one hit) | +| A collision with a traffic car shows on the client | a round trip and a playout delay later | at once | at once, the host simulating the same hit (6 ms apart) | +| Cars within 130 m on one machine only (density 0.5) | 0.2-0.6 % | 0.2-0.4 % | 0.2-0.7 % | +| Density 1: cars within 200 m missing on the client / cars coming and going | 12 % / 45 in 100 s | 1.4 % / 190 | 15 % / 1 | +| Bandwidth to a client, density 0.5 (60-70 cars) | 13.4-15.6 KB/s | 11.0-11.5 KB/s | 10.5-11.6 KB/s | +| ... density 1 | 18.8-21.1 KB/s (96 cars) | 20.2-21.8 KB/s (98-120) | 19.7-21.0 KB/s (100-128) | + +The host-simulated column predicts further (the client's car's lead over the +host is added to the trip), so its tails are those of the longer horizon; +emulated before the merge with `OPENMM2_DEBUG_TRAFFIC_LEAD_MS`, 150 ms more +gave 0.03 / 0.15 / 0.63 m and 300 ms more 0.05 / 0.49 / 1.69 m. ## How it was measured @@ -35,116 +56,129 @@ model too (see "The host-authoritative model"). each with its own XDG directories, `[Display] VSync=off`, `FrameCap=120`, UDP port 2390, each client behind its own `netprobe relay` (60 ± 20 ms each way and 2 % loss with reordering; the harsh case 150 ± 30 ms and 5 %; three - players with 40 and 120 ms). Scripted driving (`OPENMM2_DEBUG_INPUT`, - `OPENMM2_DEBUG_SPAWN`, `OPENMM2_DEBUG_START_NEAR_POLICE`, - `OPENMM2_DEBUG_RESPAWN_MS`): through downtown traffic, weaving into it, - rear-ending cars again and again, ramming a police car and fleeing it, and at - traffic density 1. -* `OPENMM2_NET_TRACE` now writes the shared traffic (`game/net/TrafficTrace.h`): + players with 40 and 120 ms each way). Scripted driving + (`OPENMM2_DEBUG_INPUT`, `OPENMM2_DEBUG_SPAWN`, + `OPENMM2_DEBUG_START_NEAR_POLICE`, `OPENMM2_DEBUG_RESPAWN_MS`): through + downtown traffic, weaving into it, rear-ending cars again and again (full + throttle, put back every 4 s), ramming a police car and fleeing it, and at + traffic density 1. 100 s of each, about 12 300 client frames. +* `OPENMM2_NET_TRACE` writes the shared traffic (`game/net/TrafficTrace.h`): the host each car near a player at the session time its state belongs to, - a client each car where its car meets it every frame and its own car's - session time, and the hits, knocks and hand-overs. `mm2tool nettrace - ` (added) compares them. The error is measured on - the ground plane, against the host's car at the session time the client's - car was at: what one player sees of a car next to the other player's car. + a client each car where its car meets it every frame and its car's time on + the host's clock, the knocks and the hand-overs. `mm2tool nettrace [radius]` (added) compares them. * `tests/game/test_traffic_prediction.cpp` - (`TrafficPrediction.OverALossyLinkTheClientSeesTheHostsPresent`) - reproduces the measurement without game data: a host's AI steps, its - messages over a simulated 60 ± 20 ms link with 2 % loss, and the client's - cars against the true paths. + (`TrafficPrediction.OverALossyLinkTheClientSeesTheHostsPresent`) reproduces + the measurement without game data: a host's AI steps, its messages over a + simulated 60 ± 20 ms link with 2 % loss, the client's cars against the true + paths (prints 0.022 m median predicted against 1.29 m 160 ms back). +* The previous record of this area measured the client's cars against the + host's messages at the same stamps (2 cm median): that hides both the + playout delay (T1) and the stamps' own error (T2). ## Findings Severity: crash / gameplay-breaking / visible / minor / code quality. -Locations are at cd5ae29. +Locations are at cd5ae29 unless said otherwise. ### Fixed | # | Severity | Location | Scenario | Confirmed | Fix | | --- | --- | --- | --- | --- | --- | -| T1 | visible | `RaceScreen.cpp:4110` (`updateNetTraffic`) | A client drew the traffic and police a trip and a playout delay in the past (at least the host car's playout delay, 100 ms or more; 160-260 ms over the relays) while its own car was in the present, and its car met them there. Next to the other player's car a moving traffic car was 2.6 m (median, 60 ms each way) to 4.0 m (150 ms) from where the host had it at the same moment; a client rear-ending a car hit it where it had been. | traces: error in session time 165-264 ms at their speed; test `TrafficPrediction.OverALossyLinkTheClientSeesTheHostsPresent` (1.29 m median 160 ms back on a synthetic link) | 2c8679c: every car shown at the session time of the client's car, predicted from its newest state (rail cars along an arc, bodies along their velocity and yaw rate); the drawing blends corrections away | -| T2 | visible | `RaceScreen.cpp:3998` (`sendNetTraffic`) | The messages were stamped with the session time when they were sent, while the rail cars were at the AI's last 30 Hz step (up to a step and a frame earlier) and the police and knocked cars at the last physics step: every car was drawn 17 ms (median, 8-26 ms) behind on the client, the offset changing from message to message (a surge of up to 0.5 m at speed), and the light sets ran on the same wrong time. | traces: client behind along the rail by 17.2 ms (median) at the time it showed the car; after, -0.1 ms | 2c8679c: stamped with the AI step's time, the bodies moved to it along their velocity | -| T3 | visible | `game/net/TrafficProxies.cpp:19`, `RaceScreen.cpp:3877` | A client's car met the received rail cars as kinematic instances of infinite mass: it bounced off a car as off a moving wall, where the host's (and single player's) car pushes a rail car that takes a body (`aiVehicleInstance::AttachEntity`, `aiVehicleActive`); the traffic car drove on through the client's car until the host's knock came back, a round trip later, from wherever the host had knocked it. | traces of the rear-end runs; test `NetTrafficCars.*` | e6802c2: the client's `TrafficBodies` holds the received rail cars (`NetTrafficCars`); its own car knocks one loose at once with the host's physics, and the host's messages confirm or withdraw it | -| T4 | visible | `ai/Traffic.cpp:2617` (`publish`), `TrafficSync.cpp:373` | A rail car's published velocity is its AI speed along its heading, but `ai::Traffic` moves it along its curves by their parameter: in a turn the host's car covered 70-80 % of the ground its speed said, and a car held at the end of its lane covered none while its speed stood at 10-23 m/s. Predicted with that speed, such cars ran ahead of the host's. | host traces: ground speed against speed for every car (scratch analysis) | e6802c2: the host sends the speed over the ground where it differs; the prediction uses it, the velocity a hit gives the body stays the AI's | -| T5 | minor | `TrafficSync.cpp:374` | A car the AI drives off its rail (avoiding a player, regaining its lane) was sent without spin, so a client could not follow its curve. | by reading | 2c8679c: its yaw rate (curvature × speed) | -| T7 | visible | `TrafficSync.cpp:411-430` (`TrafficHost::build`), `net/AmbientState.h:39` | At traffic density 1 the cars within 200 m did not all fit in a message (at most 96, 1100 bytes): 12 % of the host's cars within 200 m of the client were missing on the client, and cars at the cut came and went (45 times in 100 s at 170-195 m) as the order changed. | traces at density 1; test `TrafficHost.TheCarsThatFitStayAndTheFarthestComeEveryFourthMessage` | 2a97bb0: up to 160 cars, a known far car's state every fourth message beyond 160 m, and the cars the client has count as 25 m nearer at the cut | -| T8 | visible | `TrafficSync.cpp:417-424` | Whether a far car's turn for its state fell in a message changed its size from 13 bits to 130, and with it which cars at the edge still fit; a car left out was gone for the client and came back new, needing its state and pushing out others. With more cars fitting (T7) this made the edge flicker nearly 2000 times in 100 s. | traces at density 1 | 217db24: the cars are chosen by their usual cost, then written, a far car whose turn it is going without its state when it would not fit | -| T6 | minor | `TrafficBodies` (host) | Once a knocked car's body stood still on the host for 40 s with 6.8 m/s left in its velocity; every client predicted it 1.2 m ahead all that time. Not reproduced in nine later runs; the cause (a body resting against something while its velocity is not cleared) is plausible only. | one trace | 8af6833: a body that moved less than 5 cm in 100 ms is shared standing | +| T1 | visible | `RaceScreen.cpp:4110` (`updateNetTraffic`) | A client drew the traffic and police a trip and a playout delay in the past (160-260 ms over the relays) while its own car was in the present, and its car met them there: a moving traffic car was 2.6 m (median, 60 ms each way) to 4.0 m (150 ms) from where the host had it at the same moment, and a client rear-ending a car hit it where it had been. | traces (165-264 ms at their speed); test `TrafficPrediction.OverALossyLinkTheClientSeesTheHostsPresent` | 2c8679c: every car shown at the client's car's time, predicted from its newest state (rail cars along an arc at their speed and acceleration, bodies along their velocity and yaw rate); collisions take the newest prediction, the drawing blends corrections away (100 ms) | +| T2 | visible | `RaceScreen.cpp:3998` (`sendNetTraffic`) | Messages were stamped with the session time they were sent at, while the rail cars were at the AI's last 30 Hz step and the police and knocked cars at the last physics step: every car was 17 ms (median, 8-26 ms) behind on the client, by a different amount in each message (a surge of up to 0.5 m at speed), and the light sets ran on the same wrong time. | traces: 17.2 ms behind along the rail; after, -0.1 ms | 2c8679c: stamped with the AI step's time, the bodies moved to it along their velocity | +| T3 | visible | `game/net/TrafficProxies.cpp:19`, `RaceScreen.cpp:3877` | A client's car met the received rail cars as kinematic instances of infinite mass: it bounced off a car as off a moving wall, where the host's (and single player's) car pushes a rail car that takes a body (`aiVehicleInstance::AttachEntity`, `aiVehicleActive`); the traffic car drove on through the client's car until the host's knock came back. | rear-end traces; tests `NetTrafficCars.*` | e6802c2: the client's `TrafficBodies` holds the received rail cars (`NetTrafficCars`); its car knocks one loose at once with the host's physics. df5c7cf, 438fd01, b01172e: the local body leads until it rests (the host's knocked car, a trip old and dead-reckoned while it tumbles, is the worse guess), a car it knocks knocks the next one loose as on the host, and it is handed back 3 m apart | +| T4 | visible | `ai/Traffic.cpp:2617` (`publish`), `TrafficSync.cpp:373` | A rail car's velocity is its AI speed along its heading, but `ai::Traffic` moves it along its curves by their parameter: in turns the host's cars covered 70-80 % of the ground their speed said, and a car held at the end of its lane none while its speed stood at 10-23 m/s; predicted with that speed they ran ahead. | host traces, ground speed against speed | e6802c2: the host sends the speed over the ground where it differs (12 bits); the prediction uses it, a hit body still takes the AI's speed | +| T5 | minor | `TrafficSync.cpp:374` | A car the AI drives off its rail (avoiding a player, regaining its lane) was sent without spin, so a client could not follow its curve. | by reading | 2c8679c: its yaw rate | +| T6 | minor | `TrafficBodies` (host) | Once a knocked car's body stood still for 40 s with 6.8 m/s left in its velocity, and every client predicted it 1.2 m ahead. Not reproduced in nine later runs; the cause (a body resting against something with its velocity not cleared) is plausible only. | one trace | 8af6833: a body that moved less than 5 cm in 100 ms is shared standing | +| T7 | visible | `TrafficSync.cpp:411-430` (`TrafficHost::build`), `net/AmbientState.h:39` | At traffic density 1 the cars within 200 m did not fit (at most 96 a message): 12 % of the host's cars within 200 m were missing on the client, and cars at the cut came and went (45 times in 100 s, at 170-195 m). | density-1 traces; test `TrafficHost.TheCarsThatFitStayAndTheFarthestComeEveryFourthMessage` | 2a97bb0: up to 160 cars, a known car's state every fourth message beyond 160 m, the client's cars 25 m nearer at the cut | +| T8 | visible | `TrafficSync.cpp:417-424` | Whether a far car's turn for its state fell in a message changed its size from 13 bits to 130, and with it which cars at the edge fit; a car left out was gone and came back new, needing its state and pushing out others (nearly 2000 times in 100 s once more cars fitted). Near cars' changing sizes did the same at 190. | density-1 traces | 217db24: cars chosen by their usual cost, then written (a far car whose turn it is goes without its state when it would not fit); bb00328: a new car waits while the known ones would leave less than a tenth of the budget. 1 in 100 s after | +| T9 | gameplay-breaking (integration) | `RaceScreen.cpp` `sendNetTraffic` at 3f73059 | With the host simulating the players' cars the clients send no snapshots, and the host chose each client's traffic round the snapshot it no longer had: no traffic was sent at all. | by reading (`NetGame::remoteCars` has no state without them) | 753c12b: round the host's simulated car of each client (`m_remoteCars`) | ### Measured and left as they are * **Spawns and despawns at the interest edges.** The cars near the client that only one machine had are cars the host populated or recycled on the - roads round the player (the AI's `AdjustAmbients`, MM2's behaviour) in the - time it takes a message to arrive: 0.1-0.6 % of the car-frames within - 130 m before, 0.1-0.3 % after (now one trip late instead of a trip and a - playout delay). The 200 m / 230 m hysteresis already keeps cars at the edge - from coming and going; the 1100-byte budget was never full (at most 75 - cars, 770 bytes a message at traffic density 0.5, TBD at 1). -* **Recycled slots, lost and reordered messages.** Unchanged and correct: a - new generation is a new car; an older message never brings a car back; the - prediction starts from the newest state, so a lost message only lengthens - its horizon (by 50 ms, 100 ms for a far car between its states). -* **Jumps the host's AI makes itself.** Rail cars that finish regaining - their lane, and some far from every player, are put 10-35 m away in one AI - step on the host; the client follows a trip later. This is the AI's - behaviour (not compared with MM2 here), not a desync. -* **Hit reports** (`RaceScreen.cpp:3877-3913`, `:4078-4101`): of 1, 2, 15 and - 14 reports in the four baseline runs 0, 1, 2 and 5 were applied; the others - were refused because the host had knocked the car itself already (its - delayed copy of the client's car reaching the car later). The once-a-second - limit kept a car the client was stuck against from being reported more - often. With the host simulating every player's car (below) the reports go, - so they were not changed beyond the client now reporting its own knocks. -* **The host's view of the clients for the police and the traffic's - avoidance** (`RaceScreen.cpp:1661`, `collectNetPlayers`): the host's - delayed copy of each client's car. Superseded by the host simulating every - car (the police and the traffic then see each car where it is on the host). -* **Police chases.** The police target travels with every message; the - client never disagreed with the host on who was chased (0 of 12 259 - car-frames in the chase). Their places: see the summary. + roads round the player (the AI's `AdjustAmbients`) in the time a message + takes: 0.2-0.7 % of the car-frames within 130 m, now one trip late rather + than a trip and a playout delay. The 200 m / 230 m hysteresis keeps cars at + the edge from coming and going. +* **Recycled slots, lost and reordered messages.** Correct as they were: a + new generation is a new car, an older message never brings a car back, and + the prediction starts from the newest state, so a lost message only + lengthens its horizon. +* **Jumps the host's AI makes itself.** Cars that finish regaining their + lane, and some far from every player, are put 10-35 m away in one AI step + on the host; the client follows a trip later. AI behaviour, not compared + with MM2 here. +* **Police chases.** The target travels with every message; the client and + the host agreed on who was chased in all but 11 of 7 240 car-frames (one + switch, a trip late). The sirens and pursuit flags likewise. +* **Hit reports** (gone with T9's merge): of 1, 2, 15 and 14 reports in the + baseline runs 0, 1, 2 and 5 were applied; the rest were refused because the + host's delayed copy of the client's car had already knocked the car, + later and elsewhere. ## The host-authoritative model -With the host simulating every player's car from uploaded inputs, a client's -own car is predicted ahead of the host (by about half a round trip and the -host's input buffer), while the traffic still arrives a trip old. The client -side is built for that: - -* **Present the traffic at the predicted car's time.** The client shows the - received cars at `RaceScreen::netTrafficPresentTime`, today its car's - session time (`netTrafficOwnTime`). With a predicted car it must return the - session time that car is at, the time the host will simulate the inputs - being applied now; the cars are then predicted that much further. - `OPENMM2_DEBUG_TRAFFIC_LEAD_MS` emulates the longer horizon today: TBD. +On top of integration the client's car is predicted ahead of the host's +simulation of it, while the traffic arrives a trip old: + +* **Present the traffic at the car's time on the host's clock.** The client + measures its car's lead over the host from the host's states of it (the + host's session time after sample n against the time it ran sample n, + smoothed; about 95 ms at 60 ms each way: the trip and a sample or two in the + host's queue) and shows the received cars at its car's time plus that lead + (`RaceScreen::netTrafficCarTime`), so its car meets them where the host's + simulation of it will. * **Collide as the host does.** The client's car meets the received rail - cars in `TrafficBodies`, as the host's car meets its own: a hit gives the - rail car a body with its rail velocity and the two push each other, so the - client's predicted car takes the host's response when their states agree, - and the knocked car moves off at once. With the host simulating the hit, - the host's knock is in the message stamped at the hit's time, so the window - after which the client withdraws a knock the host did not make should drop - the half round trip (`kNetKnockConfirmMs` alone, `NetTrafficCars::update`'s - `confirmMs`). -* **Correct quickly.** The collisions take each newer prediction at once; - the drawing blends the difference away in 100 ms (above 4 m or a radian it - jumps); a knock the host confirms or withdraws is handed back the same way. -* **Replays.** A client that rewinds its car to a host state and replays its - inputs can place the received cars at each replayed step's time with - `TrafficClient::poseAt` (the physics pose, without the drawing's - correction). The cars it knocked loose itself are not rewound. -* **What goes.** The hit reports (`TrafficHitEvent`, `applyNetTrafficHits`, - the report block of `updateNetTraffic`): the host sees every hit itself. - The host's police and traffic then see each player's car where the host - simulates it (`collectNetPlayers` reads `m_remotes`' cars). - -Where this depends on the players' cars work: the present time (above), the -confirmation window, the replays, and taking the hit reports out. + cars in `TrafficBodies` as the host's simulation of it does: a hit gives the + car a body and both push. The client's knock and the host's were 6 ms + apart in the traces; where the client's car had met the car as the host's + did, the knocked car moved 0.03-0.6 m (median) from the host's. Where they + met it differently (the client's car then corrected by 1.5-2.3 m) it + drifted 2-4 m, and is handed back at 3 m. +* **Correct quickly.** Collisions take each newer prediction at once; the + drawing blends corrections away in 100 ms (above 4 m it jumps); a knock the + host makes too is handed back when the local body rests (or 3 m apart), one + it does not make 150 ms after the hit on the host's clock. +* **Replays.** A client's replayed samples meet everything held still + (`World::replaySample`): its rail cars stand where this frame placed them + and are not knocked. `TrafficClient::poseAt` gives a car's pose at any + time, should replays want them where they were. +* **What went.** The hit reports (`TrafficHitEvent`, `applyNetTrafficHits`, + `TrafficBodies::knock`, the reports in `updateNetTraffic`; protocol 6); the + police and the traffic on the host see each client's simulated car. ## Bandwidth -TBD +A car on its rail costs 120 bits (136 turning or changing speed, 148 when +its ground speed differs), as many as 1100 bytes hold (at most 160) per +client and message, 20 a second. At the cruise's default density (0.5) +10.5-11.6 KB/s to each client (60-70 cars), a fifth less than before for +the same cars (a known car beyond 160 m now carries its state every fourth +message); at density 1 the budget binds at about 21 KB/s. Seven clients: +about 80 KB/s from the host at density 0.5, 150 KB/s at density 1, on top +of the players' cars. ## Open -TBD +| # | Severity | Location | What | Needs | +| --- | --- | --- | --- | --- | +| O1 | visible | `TrafficPrediction::predictBody` | The police are dead-reckoned along their velocity and yaw rate; over the host-simulated horizon (a trip plus the client's lead) a chasing police car is 0.33 m off (median), 3.7 m in the tail. | Simulating them on the client from their inputs (MM2's `mmNetObject` way) and pulling them to the host's states. | +| O2 | minor | `TrafficProxies`, `RaceScreen::updateNetCops` | The cars the host knocked loose and the police are kinematic on the client (infinite mass): the client's predicted car bounces off them where the host's pushes them, and is corrected. | Local bodies for them, as for the client's own knocks. | +| O3 | minor | `TrafficHost::build` | At traffic density 1 the farthest cars within 200 m (about 15 %, at 170-200 m) do not fit in a client's 1100 bytes and are not shown there; they stay out rather than come and go. | A larger datagram, or cheaper far cars. | +| O4 | minor | `ai::Traffic` (host) | The AI's own jumps of 10-35 m in one step (see "Measured and left as they are"). | The AI area. | +| O5 | minor | `TrafficBodies` (host) | T6's cause. | A reproduction. | + +## Edits outside this area's files + +`src/app/RaceScreen.cpp` (the hooks: tracing, stamping, rail motion, +present time and lead, the client's `TrafficBodies`, removed reports), +`src/game/TrafficBodies.{h,cpp}` (the `Source` interface: the AI's calls +unchanged, the opponent sweep 516/517 and 508/517; `bodiesHitAsPlayer`; +`knock` and `massOf` removed), `src/game/Interpolation.{h,cpp}` (a +`recordTrafficBodies` for the client's), `src/game/net/NetGame.{h,cpp}` +(`traceFile`), `src/net/AmbientState.h`, `src/net/Protocol.h` (6), +`tools/mm2tool/cmd_nettrace.cpp`. New: `game/net/TrafficPrediction`, +`NetTrafficCars`, `TrafficTrace` (parity class O). From 6c859c3b04ad75920ecd54a9e4b1abb32c7ead80 Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Fri, 9 Oct 2026 14:32:45 -0700 Subject: [PATCH 038/129] Bring the shared traffic's parity rows up to date The hit reports gone, protocol 6, the traffic shown on the host's clock, the client's knocks kept until they rest. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/parity/openmm2-only.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/parity/openmm2-only.md b/docs/parity/openmm2-only.md index 54bac14..a42dee8 100644 --- a/docs/parity/openmm2-only.md +++ b/docs/parity/openmm2-only.md @@ -126,9 +126,9 @@ counted in the summary above. | `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 off their rails as moving kinematic instances, and the client's hit reports. | -| `game::TrafficPrediction` (`RailMotionTracker`, `predictRailCar`, `predictBody`), `TrafficClient`'s prediction and corrections | `mmNetObject::PositionUpdate` (a network car predicted forward from its last packet) | openmm2 | Added 2026-10-09 (protocol 5, docs/review/multiplayer-desync-traffic.md): the host stamps the traffic with its AI step's time and sends a rail car's acceleration, curvature and speed over the ground; a client shows every car at its own car's time, a rail car predicted along an arc, a body along its velocity and yaw rate, the drawing blending corrections away. MM2's network cruise has no traffic; MM2 predicts its network cars forward, which this does for the host's. | -| `game::NetTrafficCars`, `TrafficBodies::Source` | `aiVehicleInstance::AttachEntity`, `aiVehicleAmbient::Impact`, `aiVehicleActive::Detach` / `PostUpdate` (the hand-over between the AI and the bodies) | openmm2 | Added 2026-10-09: `TrafficBodies` takes its traffic through an interface (the AI's for the host and single player: the same calls in the same order); a shared-traffic client gives it the received rail cars, which its own car knocks loose at once with the host's physics, until the host's messages confirm or withdraw the knock. | +| `game::TrafficHost`, `TrafficClient`, `TrafficCatalog`, `TrafficProxies`, `net::AmbientStateMsg` | | openmm2 | Replication: interest by distance with hysteresis, a packet budget (the client's cars kept at the edge), complete messages, generations for recycled slots, the client's car meeting the received cars off their rails as moving kinematic instances. The client's hit reports (`TrafficHitEvent`) went with protocol 6: the host simulates every player's car and sees the hits itself. | +| `game::TrafficPrediction` (`RailMotionTracker`, `predictRailCar`, `predictBody`), `TrafficClient`'s prediction and corrections | `mmNetObject::PositionUpdate` (a network car predicted forward from its last packet) | openmm2 | Added 2026-10-09 (protocol 6, docs/review/multiplayer-desync-traffic.md): the host stamps the traffic with its AI step's time and sends a rail car's acceleration, curvature and speed over the ground; a client shows every car at its own car's time on the host's clock (its lead over the host's simulation of it added), a rail car predicted along an arc, a body along its velocity and yaw rate, the drawing blending corrections away. MM2's network cruise has no traffic; MM2 predicts its network cars forward, which this does for the host's. | +| `game::NetTrafficCars`, `TrafficBodies::Source` | `aiVehicleInstance::AttachEntity`, `aiVehicleAmbient::Impact`, `aiVehicleActive::Detach` / `PostUpdate` (the hand-over between the AI and the bodies) | openmm2 | Added 2026-10-09: `TrafficBodies` takes its traffic through an interface (the AI's for the host and single player: the same calls in the same order); a shared-traffic client gives it the received rail cars, which its own car (and a car it knocked) knocks loose at once with the host's physics; the local body leads until it rests or is 3 m from the host's car, then the host's messages (at once when the host did not knock the car). | | `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). | From 20c0030f8be5224c267d2c3b74a2f69ac95e3e1c Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Fri, 9 Oct 2026 14:32:57 -0700 Subject: [PATCH 039/129] Note protocol 6 in the multiplayer docs 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 00bbe13..9f4ace6 100644 --- a/docs/multiplayer.md +++ b/docs/multiplayer.md @@ -196,8 +196,10 @@ layouts must bump `kProtocolVersion`.** `Hello` carries the version again, along with a free-form build string. Version 2 added the shared cruise traffic, version 3 the race start handshake (`RaceLoad` in place of `Countdown`, `RaceLoaded`, `RaceStart`, the race in `Welcome`), version 4 -the cars' damage, version 5 what the clients predict the shared traffic -with (a rail car's acceleration, curvature and speed over the ground). +the cars' damage, version 5 the players' cars simulated by the host, +version 6 what the clients predict the shared traffic with (a rail car's +acceleration, curvature and speed over the ground), up to 160 shared cars +a message and no traffic hit reports. ### Handshake From 127ac41c2a234e632d911243f77dee113311fe83 Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Fri, 9 Oct 2026 14:33:47 -0700 Subject: [PATCH 040/129] Let the host drop a client's inputs that piled up while it was busy A client that loaded a race before the host drove (or sat on the grid) for the seconds the host still loaded: the host then started its car at the first of those inputs and stayed seconds behind it, which the client's 2% slower pace took minutes to work off. When a client's queue had more than 12 inputs in hand at every sample for a second, the host now keeps only the newest three (one correction on the client). Co-Authored-By: Claude Opus 5.5 (1M context) --- src/app/RaceScreen.cpp | 3 ++- src/game/net/PlayerCars.cpp | 12 ++++++++++++ src/game/net/PlayerCars.h | 14 +++++++++++--- tests/game/test_player_cars.cpp | 20 ++++++++++++++++++++ 4 files changed, 45 insertions(+), 4 deletions(-) diff --git a/src/app/RaceScreen.cpp b/src/app/RaceScreen.cpp index ee9eb43..82e7d8f 100644 --- a/src/app/RaceScreen.cpp +++ b/src/app/RaceScreen.cpp @@ -5026,7 +5026,8 @@ class RaceScreen final : public Screen { std::string queues; for (const auto& [id, rv] : m_remotes) if (rv.simulated) - queues += std::format(" player {}: {} inputs missed;", id, rv.inputs.missed()); + queues += std::format(" player {}: {} inputs missed, {} dropped to catch up;", id, + rv.inputs.missed(), rv.inputs.skipped()); log::info("netcars: host sent {} states ({:.0f} B/s);{}", m_netStatesSent, static_cast(m_netStatesBytes) / seconds, queues); } else if (verbose) { diff --git a/src/game/net/PlayerCars.cpp b/src/game/net/PlayerCars.cpp index 38177a7..4433696 100644 --- a/src/game/net/PlayerCars.cpp +++ b/src/game/net/PlayerCars.cpp @@ -164,6 +164,18 @@ std::optional HostInputQueue::next() { const std::int32_t waiting = static_cast(m_newest) - static_cast(m_next); m_leastWaiting = std::min(m_leastWaiting, waiting); ++m_next; + // Too far behind its client for a whole second: the newest few only. + m_slackLeast = std::min(m_slackLeast, waiting); + if (++m_slackSamples >= kSlackSamples) { + if (m_slackLeast > kMaxSlack) { + const std::uint32_t to = m_newest + 1 - kStartMargin; + m_skipped += to - m_next; + m_frames.erase(m_frames.begin(), m_frames.lower_bound(to)); + m_next = to; + } + m_slackLeast = 1 << 20; + m_slackSamples = 0; + } return n; } diff --git a/src/game/net/PlayerCars.h b/src/game/net/PlayerCars.h index cf4c56d..9084334 100644 --- a/src/game/net/PlayerCars.h +++ b/src/game/net/PlayerCars.h @@ -80,6 +80,12 @@ class HostInputQueue { // (the margin it keeps against late ones; the client's pace keeps it). static constexpr std::uint32_t kMaxAhead = 240; static constexpr std::uint32_t kStartMargin = 3; + // A queue that had more than this many inputs in hand at every sample + // for a second (a client whose inputs piled up while the host was busy + // loading, or a client that stalled and caught up) drops all but the + // newest few: its car would otherwise lag its player by that much. + static constexpr std::int32_t kMaxSlack = 12; + static constexpr int kSlackSamples = 60; // Samples a missing input repeats the last one before the car coasts // (no throttle, brake or steering). static constexpr int kRepeatSamples = 15; @@ -109,9 +115,9 @@ class HostInputQueue { // (negative: samples the queue ran short); for CarStatesMsg::waiting. std::int32_t takeLeastWaiting(); // Inputs applied that the client did not send in time (repeated or - // coasted), and inputs that came too late, since the start. + // coasted), and inputs dropped to catch up (kMaxSlack), since the start. std::uint64_t missed() const { return m_missed; } - std::uint64_t late() const { return m_late; } + std::uint64_t skipped() const { return m_skipped; } private: std::map m_frames; @@ -122,7 +128,9 @@ class HostInputQueue { net::CarInputFrame m_last; int m_starved = 0; std::int32_t m_leastWaiting = 1 << 20; - std::uint64_t m_missed = 0, m_late = 0; + std::int32_t m_slackLeast = 1 << 20; // the fewest in hand over the current second + int m_slackSamples = 0; + std::uint64_t m_missed = 0, m_skipped = 0; }; // The car's state as the host sends it to its player, and the car put there diff --git a/tests/game/test_player_cars.cpp b/tests/game/test_player_cars.cpp index aebdf1e..5d066a2 100644 --- a/tests/game/test_player_cars.cpp +++ b/tests/game/test_player_cars.cpp @@ -200,6 +200,26 @@ TEST(PlayerCars, TheHostQueueRepeatsThenCoastsAndSkipsLateInputs) { EXPECT_TRUE(n->commands.empty()); // the command was carried out } +TEST(PlayerCars, AQueueFarBehindItsClientCatchesUp) { + // A backlog (the client drove while the host was loading): a second + // later the queue drops all but the newest few. + HostInputQueue q; + net::PlayerInputMsg m; + m.first = 1; + m.frames.resize(net::kMaxInputFrames); + q.receive(m); + std::uint32_t newest = net::kMaxInputFrames; + for (int i = 0; i < HostInputQueue::kSlackSamples; ++i) { + ASSERT_TRUE(q.next()); + m.first = ++newest; // the client keeps sending one a sample + m.frames.resize(1); + q.receive(m); + } + EXPECT_GT(q.skipped(), 0u); + EXPECT_EQ(q.lastApplied() + HostInputQueue::kStartMargin + 1, newest); // before the last one came + EXPECT_EQ(q.missed(), 0u); +} + TEST(PlayerCars, AnInputFarAheadIsIgnored) { HostInputQueue q; net::PlayerInputMsg m; From 65733d34365b41194d34f024b371681cc419651a Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Fri, 9 Oct 2026 14:38:46 -0700 Subject: [PATCH 041/129] Log what a network client's corrections cost OPENMM2_DEBUG_NETCARS's client line gives the time the samples run again took, per second and at most in one frame. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/app/RaceScreen.cpp | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/app/RaceScreen.cpp b/src/app/RaceScreen.cpp index 82e7d8f..2cdd275 100644 --- a/src/app/RaceScreen.cpp +++ b/src/app/RaceScreen.cpp @@ -4873,6 +4873,7 @@ class RaceScreen final : public Screen { } const game::VehiclePose before = drawnPose(game::Drawn::Player, 0, *m_player); const auto now = proxyPoses(); + const auto replayStart = std::chrono::steady_clock::now(); m_netReplaying = true; const auto c = m_prediction.acknowledge( *m_player, m_netDriver, *m_world, ack, *newest, @@ -4891,6 +4892,10 @@ class RaceScreen final : public Screen { }); placeProxies(now); m_netReplaying = false; + const double replayMs = + std::chrono::duration(std::chrono::steady_clock::now() - replayStart).count(); + m_netReplayMs += replayMs; + m_netReplayWorstMs = std::max(m_netReplayWorstMs, replayMs); if (!c.corrected) return; const game::VehiclePose after = drawnPose(game::Drawn::Player, 0, *m_player); @@ -5033,9 +5038,12 @@ class RaceScreen final : public Screen { } else if (verbose) { const auto& st = m_prediction.stats(); log::info("netcars: client sent {} inputs ({:.0f} B/s); {} acks, {} corrections, {} samples " - "replayed, {} too old; running at {:.3f}", + "replayed ({:.1f} ms a second, at most {:.2f} ms a frame), {} too old; " + "running at {:.3f}", m_netInputsSent, static_cast(m_netInputBytes) / seconds, st.acks, - st.corrections, st.replayedSamples, st.unreplayable, m_netDilation); + st.corrections, st.replayedSamples, m_netReplayMs / seconds, m_netReplayWorstMs, + st.unreplayable, m_netDilation); + m_netReplayMs = m_netReplayWorstMs = 0.0; } m_netStatesBytes = m_netStatesSent = m_netInputBytes = m_netInputsSent = 0; } @@ -5919,6 +5927,7 @@ class RaceScreen final : public Screen { bool m_ownDamageFresh = true; // client: its car's damage record not shown yet float m_netDilation = 1.0f; // client: the rate its samples run at double m_netLead = -1.0; // client: how far (ms) its car runs ahead of the host + double m_netReplayMs = 0.0, m_netReplayWorstMs = 0.0; // client: what its corrections cost struct ProxyHistory { std::uint32_t seq = 0; std::vector poses; From f92979f7633478beefe8fe09ee620a115d922b0a Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Fri, 9 Oct 2026 14:40:04 -0700 Subject: [PATCH 042/129] Record the players' cars' desync and the host-authoritative cars docs/review/multiplayer-desync-cars.md: how the divergence between machines was measured, MM2's network cars (mmNetObject: what is sent, how often, how it predicts and corrects, a physical body on every machine), what diverged in 0.3.1, the host-authoritative model's numbers against it, the corrections, builds that round differently, the experiment with the other cars placed ahead, bandwidth, deviations and what remains open. docs/multiplayer.md describes the players' cars, protocol 5, the damage the host decides, the new trace lines, syncreport and the development hooks; the sync review's S2, S3 and S6 point to it. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/multiplayer.md | 228 ++++++++++++++++--- docs/review/multiplayer-desync-cars.md | 300 +++++++++++++++++++++++++ docs/review/multiplayer-sync.md | 6 +- 3 files changed, 501 insertions(+), 33 deletions(-) create mode 100644 docs/review/multiplayer-desync-cars.md diff --git a/docs/multiplayer.md b/docs/multiplayer.md index ff428a9..f44bfb0 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/PlayerCars.h` | The players' cars simulated by the host: `PlayerInputMsg`, `CarStatesMsg` | | `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 | @@ -44,12 +45,17 @@ for the lobby: settings, the player list, ready flags, kicks and the race start. 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 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: +In game the host is the authority for every player's car (protocol 5, see +"Players' cars"): each client sends its car's inputs, the host simulates +every player's car from them and sends each client, 20 times a second, the +last input it applied to that client's car with the car's state after it, +and every other player's car. A client runs its own car ahead on its inputs +(no latency) and corrects it from the host's states. (Before protocol 5 +each machine simulated its own car and sent its state, which the host +passed on; `VehicleState` and `WorldState` remain in the protocol, unused by +the game.) +The other players' 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 @@ -195,7 +201,10 @@ incompatible builds never parse each other's messages. **Any change to message layouts must bump `kProtocolVersion`.** `Hello` carries the version again, along with a free-form build string. Version 2 added the shared cruise traffic, version 3 the race start handshake (`RaceLoad` in place of -`Countdown`, `RaceLoaded`, `RaceStart`, the race in `Welcome`). +`Countdown`, `RaceLoaded`, `RaceStart`, the race in `Welcome`), version 4 +the cars' damage, version 5 the host-simulated players' cars +(`PlayerInput`, `CarStates`, damage events about a player's car from the +host). ### Handshake @@ -240,8 +249,10 @@ attempt within `connectTimeoutMs` (8 s). | ReturnToLobby | H→C | — (ready flags reset) | | 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)` pairs: the host's car each tick, a joiner's as it arrives | +| VehicleState | C→H | own `VehicleSnapshot` (unused by the game since protocol 5) | +| WorldState | H→C | `(id, VehicleSnapshot)` pairs (unused by the game since protocol 5) | +| PlayerInput | C→H | the inputs of the samples the host has not acknowledged and the commands (resets) it has not (see "Players' cars") | +| CarStates | H→C | the last input applied to the client's car, its inputs in hand, the car's state, every other player's 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") | @@ -290,6 +301,18 @@ 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. +* **The players' cars.** A `PlayerInput` holds 1-48 input frames numbered + from 1 (a first number that would wrap is refused), switch and key bits + masked, the gold's mass at most 2000 kg, and at most 4 commands whose + positions lie within ±16384 m and rotations within ±64 rad. The host takes + a joiner's inputs during a race only, within a budget of 90 messages a + second (burst 180), queues at most 256, ignores inputs for samples already + applied or more than 240 ahead, holds every car before the start whatever + its input says, and lets a command move a car only inside the city's box + (with 200 m to spare) and at most four times a second. A `CarStates` + holds at most 16 cars; its own-car state must be a rotation matrix (entries + within ±2), a position within ±16384 m and every other value finite and + within ±10^7, or the whole message is refused. * **Damage.** A `VehicleDamage` event has at most 16 patches and 8 impacts, record indices below 1024, 20 part bits, and every value quantized to its range (points ±8 m, normals ±1, speeds 0-128 m/s, strengths 0-10^6 on a log @@ -380,7 +403,9 @@ types, with payload structs in `Protocol.h`: `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: Cops and Robbers uses 0x8001 and 0x8002, the shared +up are free for the game: Cops and Robbers uses 0x8001 to 0x8003 (0x8003 the +host's word that a limit was reached, `mmMultiCR::SendLimitReached`: the +host alone checks the time and point limits, `mmMultiCR::UpdateLimit`), the shared cruise traffic's hit report (`TrafficHitEvent`, client to host) 0x8010, a car's damage (`VehicleDamageEvent`, see "Damage") 0x8020. @@ -491,6 +516,110 @@ and both went at 22993 for 22988. A joiner that never finished loading was waited for 60 s, the host raced alone from then, and when the host returned to the lobby the joiner left its loading screen for the lobby. +### Players' cars + +**MM2** (`mmNetObject`, `mmGameMulti`) ran every network car on every +machine as a `vehCar` of the level, declared each frame as a type-3 mover +that collides with everything (`mmNetObject::Update`: DeclareMover(3, +0x1b)) and drivable once the race starts (`mmGameMulti::EnableRacers`). +Each machine sent its own car to every other player +(`mmGameMulti::SendPosition`: to each player in the first frame after 0.05 s +since the last, 0.1 s for a player whose session data carry a flag that +`mmNetObject::Init` is given (inferred: a slow link); a dial-up session one +broadcast at its own interval) in a 68-byte packet +(`mmNetObject::SetPositionData`, message 0x1f5): its time, the steering, +throttle and brake as bytes, the manual gear, the orientation as Euler +angles, the position as three floats, the acceleration (averaged over ten +packets by `mmAccelCompute`), velocity and spin as a magnitude and three +signed bytes of direction each, the damage, the score, the horn, siren, +handbrake, gearbox and drivable flags, a reset bit and a packet counter. The +receiver (`mmNetObject::PositionUpdate`) dropped a packet older than the +newest, gave its car the packet's velocity and momentum, pedals, damage, +horn, siren and gearbox, and predicted it forward by the average interval of the last ten packets +(position plus velocity and half the acceleration, orientation turned by +the spin); a car more than 10 m from the packet (or with the reset bit) was +put there at once, a nearer one was given the packet's orientation and a +Hermite path (`mmNetPath`) from where it was to the predicted place, which +`mmNetObject::Predict` followed every frame by the share of the average +packet interval since the packet when the car was further than its radius +from the packet; otherwise the car's place and orientation moved toward the +predicted ones by the frame's seconds as a fraction (`Matrix34::Interpolate`). +Between packets the car drove itself on the last pedals. Every machine +thus had its own version of every collision between players: each car +bounced off where the other was on that machine. + +**OpenMM2** (the maintainer's decision: the host is the authority) runs +every player's car on the host: + +* **Inputs.** Every player's car takes its input once a physics sample + (`game::NetCarDriver`, the same code on every machine): the gearbox + switch and keys (`mmGame::UpdateGameInput`), the gold's mass + (`mmMultiCR::FondleCarMass`) and throttle cap, the finish brake + (mmPlayer +0x2258), the grid hold (`vehCar::SetDrivable(0, 1)`), then + `mmGame::UpdateSteeringBrakes` (MM2 applies these once a frame; a sample + is a frame at 60 fps). A client numbers its samples from 1 and sends, + every frame that ran one, all the inputs the host has not acknowledged + (the newest 48; a frame like the one before it costs one bit, so a second + of driving costs a few bytes): a lost packet loses nothing. +* **Commands.** What the client's rules decide about its car travels with + the inputs, numbered with the sample it applies at, until the host + acknowledges it: the first sample's placement (where that machine + started the car), mmPlayer::Reset (a fall, the debug respawn), the + `HitWaterHandler`s' respawn at a checkpoint and `vehCar::ClearDamage` + (Cops and Robbers' repairs). The client applies it at once and the host at + the same sample, so a reset is no correction. +* **The host** (`game::HostInputQueue` per client) starts a client's car + once three of its inputs are in hand, applies one per sample, repeats the + last one (without its keys) for a quarter of a second when the next is + missing and then lets the car coast, ignores inputs for samples it has + passed, and reports in each state how many inputs it had in hand at the + least. The cars are full cars of the level (polygonal bound, type-3 + movers, the player's input overrides, damage on with the race), so + collisions between players, with the traffic and with the props happen + there, once. Each car draws its wheels' bump numbers from its own random + stream (`CarSim::ownRandom`) on every machine, so the host and the car's + own machine simulate it alike whatever else each simulates (deviation: + MM2 has one `rand()` for the game). +* **States.** 20 times a second the host sends each client its + `CarStates`: the number of the last input applied to its car, the car's + state after it at full precision (the body's matrix, momenta, velocities + and last push; the wheels' spin, turn, springs and tyre deflections; the + engine, gearbox, drivetrains, stuck watcher, damage and random stream; the + pedal swap and the hold: about 270 bytes), and every other player's car + as a `VehicleSnapshot`. +* **Prediction** (`game::CarPrediction`). A client runs its car on its + inputs at once and keeps each sample's input and the car's whole state + after it (`CarSim::saveState`, 3 s). When a host state for a sample + differs from the prediction for it (by 3 mm, 3 cm/s, 0.0015 in the + matrix, or in the damage, gear or hold), the client puts the car back to + its saved state for that sample with the host's on top and runs the later + samples again on their inputs (`phys::World::replaySample`: the car alone, + everything it touches held still and moving at its own velocity, the + other players' cars where each sample first met them; no sound or effect), + at most 120 samples. The drawing keeps the car where it was and eases it + onto the corrected place with a 60 ms half-life (`game::CorrectionBlend`; + more than 4 m is a jump, drawn at once). The client's simulation runs up + to 3% faster when the host had fewer than one of its inputs in hand over + the last second and 2% slower above three, which keeps its inputs a + sample or three ahead of the host's need. +* **The other cars** on a client are drawn interpolated from the host's + states a playout delay in the past, as before, and its own car collides + with them there as kinematic bodies moving at their velocity; the host's + collision is the one that counts. (`OPENMM2_NET_OTHERS`, a development + aid, places them ahead instead; see + `docs/review/multiplayer-desync-cars.md`.) +* **The host's own car** has no latency and no prediction. + +Measured through `netprobe relay` (60 ± 20 ms each way, 2% loss, +reordering), a host and a client ramming each other: the client's car was +corrected about 4 times a second, by 4 cm at the median, 1 m or more about +4 times a minute (at collisions); the host never ran short of its inputs. +The inputs cost a client about 0.9 KB/s of payload (3.3 KB/s on the wire), +the states about 0.3 KB/s per client plus 35 bytes per other car per +message (6 KB/s on the wire for two players; about 9 KB/s per client and +60 KB/s for the host with eight). See +`docs/review/multiplayer-desync-cars.md` for the measurements against 0.3.1. + ### Shared traffic An OpenMM2 extra (MM2's network cruise has no traffic and no police, @@ -577,9 +706,16 @@ fenders thrown off by speed (`vehCarModel::EjectOneshot`). The dents are texel damage only: `mmDamage::Apply` is empty (no vertex damage), and no light ever breaks (`vehCarModel::BreakElectrics` has no caller). -**OpenMM2** (`game/net/DamageSync`, `net/VehicleDamage.h`) draws the network -cars kinematically, so they collide with nothing of their own, and replays -their owner's damage instead: +**OpenMM2** (`game/net/DamageSync`, `net/VehicleDamage.h`) decides every +player's car's damage on the host (protocol 5), which simulates them all: +its own collisions of each car paint, break and dent it, and the host sends +what they did about each car (subject 513 + the player's id; its own car +512) to everyone, the car's own player included, whose machine shows its +own car's dents and lost parts from them and keeps only the sparks and +sounds it predicted itself. A player's word on its own car's damage is +refused. (Protocol 4 sent each car's damage from its owner; the text below +keeps that layout, with the host as every car's owner.) The clients draw the +other players' cars kinematically and replay the host's record of them: * **The level** is the snapshot's 10-bit fraction between MedDamage and MaxDamage (as before; the police's in `AmbientState`, now also 10 bits). @@ -593,7 +729,7 @@ their owner's damage instead: | Field | Encoding | | --- | --- | - | subject | 0-512: 512 the sender's own car, below it a shared police car's id (from the host only) | + | subject | 0-528: 513 + a player's id that player's car, 512 the host's own car, below it a shared police car's id (all from the host only) | | epoch | u8: the car's damage resets so far | | time | u32: session ms the entries' delays count from | | first, patches | the record index of the first patch (0-1023), then up to 16: delay (u8 ms), the impact point in model space (3 × 16 bits over ±8 m, 0.24 mm), the texel damage's random state (u32) | @@ -807,20 +943,21 @@ When `config.multiplayer && ctx.netGame`: `raceStarted()` becomes true at the start time. While the race loads the screen calls `update()` between its steps and leaves when `backToLobby(number)` or the session ends. -3. **Local car:** after stepping the simulation, - `submitLocalState(car.modelMatrix(), linearVelocity, angularVelocity, - 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). +3. **The players' cars** (see "Players' cars"): the frame builds this + machine's car's input (`net::CarInputFrame`), and the physics' sample + hooks (`phys::World::setSampleHooks`) apply each player's input once a + sample. A client reconciles with `takeOwnCarStates()` before the frame's + samples (`game::CarPrediction::acknowledge`) and sends + `sendPlayerInput()` after them; the host takes `takePlayerInputs()` + before its samples and sends each client `sendCarStates()` every 50 ms + after them. 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 the cars' bodies, + `phys::World::remainderAfter`; on the host, the cars it simulates). Use + that one sample for the cars' bodies, the rules and the HUD: the local car and every simulated object are where the frame's fixed steps leave them, and a car sampled at the frame's own time shook against them by up to a step's travel (29 cm rms at 35 m/s and @@ -848,12 +985,12 @@ When `config.multiplayer && ctx.netGame`: `takeAmbientStates()` and places the received cars, and its light sets follow the host's (`World::advanceLightsTo`). 7. **Damage** (`game::NetDamage`, see "Damage"): the frame's game events go - to `NetDamage::receive`; each network car (and a client's police car) is - brought up to date with `NetDamage::update` after it is placed (`fresh` - when shown afresh), the others with `settle`; this machine's car (and a - shared-traffic host's police) record their patches, parts, impacts and - resets (`DamageRecorder`), sent with `NetDamage::send` after the frame's - effects. + to `NetDamage::receive`; each network car (and a client's police car, + and a client's own car) is brought up to date with `NetDamage::update` + after it is placed (`fresh` when shown afresh), the others with + `settle`; the host records every car's patches, parts, impacts and + resets (`DamageRecorder`: `own()`, `player(id)`, `police(id)`), sent with + `NetDamage::send` after the frame's effects. 8. **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)`. @@ -889,6 +1026,15 @@ order); a comma-separated list of times saves a numbered picture at each but the last (`-1.png`, ...) and ends at the last. For the damage: `OPENMM2_DEBUG_NETDAMAGE` logs every damage event sent and received, and every 2 s the network cars' and this machine's damage levels; +`OPENMM2_DEBUG_NETCARS` logs every correction of a client's car (how far the +prediction was off, how many samples ran again) and every 10 s what the +players' cars cost and how often the host ran short of a client's inputs; +`OPENMM2_NET_OTHERS=ahead` (an experiment) places and draws the other +players' cars where the host will have them when it runs this machine's +sample, `=ghost` keeps this machine's car from touching them; +`OPENMM2_DEBUG_NETCARS_NOISE=` nudges a client's car's momentum by +about that fraction each sample, as a machine whose compiler rounds +differently might (the prediction's tolerance); `OPENMM2_DEBUG_FOCUS=net:` frames a player's car (this machine's own, or another's as drawn), `police:<400 + post>` a shared police car, `knocked:` the knocked traffic car with a body within 60 m of a @@ -936,7 +1082,13 @@ message, malformed input, delivery per client), `tests/game/test_traffic_sync.cp (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`. -The damage by `tests/net/test_vehicle_damage.cpp` (the event's encoding, the +The players' cars by `tests/net/test_player_cars.cpp` (the messages, their +limits, mutated messages, delivery and the input budget through real +sessions), `tests/game/test_player_cars.cpp` (the pedals' bytes, the host's +queue, a client predicting a host's car through a simulated network with +and without losses) and `tests/phys/test_net_prediction.cpp` (a car's +state saved and run again, a car's sample alone, the sample hooks, a car's +own random stream). The damage by `tests/net/test_vehicle_damage.cpp` (the event's encoding, the point arriving bit for bit, non-finite values, malformed events, the police damage and wheels in `AmbientState`), `tests/game/test_damage_sync.cpp` (the recorder's batches, splits, resets and budget; each entry at its time, the @@ -960,6 +1112,22 @@ each: * `R wall id stale x y z vx vy vz sampleTime`: each remote car as sampled for the frame. +* `D clock frameTime id own x y z vx vy vz`: every player's car as the frame + draws it (`own` 1 for this machine's); +* `K clock sessionTime a b x y z strength`: a collision between players `a` + and `b`'s cars in this machine's simulation (the host's are the ones that + count; a client's are its prediction); +* `C clock frameTime sample replayed dx dy dz dv snapped`: a correction of + this machine's car by the host's state. + +`D`, `K` and `C` use the machine's monotonic clock (shared by every process +on it), so the traces of instances on one computer compare the same +moments: `netprobe syncreport ...` prints how far +apart each machine draws each player's car from its own player's screen, +the frames in which a car moves further than its velocity explains, the +collisions between players each machine had (and how close every machine +drew the two cars around them) and the corrections. + 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 diff --git a/docs/review/multiplayer-desync-cars.md b/docs/review/multiplayer-desync-cars.md new file mode 100644 index 0000000..4451f49 --- /dev/null +++ b/docs/review/multiplayer-desync-cars.md @@ -0,0 +1,300 @@ +# Multiplayer desync: the players' cars + +Reviewed and reworked on 2026-10-09 from 0.3.1 (cd5ae29). + +The maintainer's report, for multiplayer cruise over the internet with +everyone on 0.3.1: "another player's car is somewhere other than where they +are, teleports, or a collision happens on one screen but not the other". +While this work was under way the maintainer decided that the host is the +authority for everything ("I think the server needs to be the authority of +everything"), which replaced porting MM2's peer-to-peer network cars: the +host now simulates every player's car from the players' inputs, each client +predicts its own car and is corrected by the host's states, and the host +decides every car's damage and Cops and Robbers' limits. MM2's model was +read first and is recorded below, because it explains the original game's +behaviour and what OpenMM2 does differently. + +## How it was measured + +* Host and one or two clients on one machine (UDP port 2380, own config and + data directories, `[Display] VSync=off`, `FrameCap=120`), each client + through `netprobe relay` (60 ± 20 ms each way, 2% loss, reordering; the + harsher case 150 ± 20 ms and 5% loss), a multiplayer cruise in San + Francisco (the shared traffic off, and once on), 75 s of race. +* `OPENMM2_DEBUG_INPUT` drives every car; `OPENMM2_DEBUG_START` places them. + Layouts: **ram** (host and client face each other 26 m apart on the hill + at intersection 150 and ram, back off and ram again; with three players + the second client starts behind the host), **chase** (the client starts + 10 m behind and 3 m beside the host and they weave), **rear** (the client + starts 12 m behind the host in the same lane at full throttle and keeps + shunting it while the host slows and brakes). +* `OPENMM2_NET_TRACE` (added: `D`, `K`, `C` lines, see docs/multiplayer.md + "Diagnosing replication") on every machine and `netprobe syncreport` + (added) on the traces: **screen divergence** is where a machine draws a + player's car against where that player's own screen draws it at the same + moment of the machine's monotonic clock (shared by the processes on one + computer); **jumps** are frames in which a drawn car moves more than its + velocity explains; **collisions** are each machine's collision episodes + between two players' cars (impacts of a pair less than 500 ms apart), + whether the machines that report the pair had one within 750 ms and 4 m, + and how close every machine drew the two cars within 0.6 s of it; + **corrections** are a client's own car moved by the host's states. +* The 0.3.1 numbers come from 0.3.1 with only the trace added (80b8b8e and + the clock fix below), built separately. + +A first round of "before" numbers was wrong: the trace's clock was each +process's own (`net::monotonicMs` counts from the process's start), so +traces of instances started seconds apart compared moments seconds apart +and showed divergences of 5-30 m. The divergence lines now use the +machine's monotonic clock. + +The scenario scripts are kept with the trace format: the commands are in +"Reproducing" below. + +## MM2's network cars + +Read from MM2Recomp (`mmNetObject`, `mmGameMulti`, `mmMultiRoam`, the +decompile and the asm of `SendPosition` and `PositionUpdate`): + +* **What is sent** (`mmNetObject::SetPositionData`, message 0x1f5, 68 + bytes): the car's time, steering, throttle and brake as bytes, the + manual gear, the orientation as Euler angles, the position as three + floats, the acceleration (`mmAccelCompute`: the velocity's change per + packet averaged over ten packets), the velocity and the spin each as a + magnitude and three signed bytes of direction, the CurrentDamage, the + score, flags (horn, siren, neutral, handbrake over 0.8, a manual gearbox + with its gear, not drivable), a reset bit and a packet counter. +* **How often** (`mmGameMulti::SendPosition`, called from `UpdateGame`): + to each other player separately, in the first frame more than 0.05 s + after the last packet to that player (0.1 s for a player whose session + data carry a flag `mmNetObject::Init` is given, inferred to be a slow + link); a dial-up session sends one broadcast at its own interval. +* **Prediction and correction** (`mmNetObject::PositionUpdate`): a packet + older than the newest is dropped. The network car takes the packet's + velocity and momentum (a global switch, on), pedals, damage (clearing it + when the level drops from above 1 to 0), horn, siren and gearbox. The + average interval of the last ten packets, T, predicts the car forward: + position + velocity T + ½ acceleration T², velocity + acceleration T, the + orientation turned by the spin times T. With the reset bit, or more than + 10 m from the packet (`GetPositionApproach`, distances under 2 m count as + 0), the car is put at the packet at once. Otherwise it keeps its place, + takes the packet's orientation, and gets a Hermite path (`mmNetPath`) from + where it is to the predicted place, its tangents along the two forward + axes; `mmNetObject::Predict`, every frame, puts the car on the path at + the share of the average packet interval the frames since the packet + make, when the car was further than its radius from the packet, and + otherwise moves its place and orientation toward the predicted ones by + the frame's seconds as a fraction (`Matrix34::Interpolate`). +* **A physical body** (`mmNetObject::Update`, `mmGameMulti::EnableRacers`): + the network car is a `vehCar` of the level, declared every frame as a + type-3 mover with flags 0x1b (it updates and collides with the city, the + instances and the other movers), drivable from the start with its damage + on. Between packets it drives itself on the last pedals and is pushed by + whatever it touches; the next packet's path or snap pulls it back. +* **Two players colliding** therefore happens on every machine separately: + each machine's own car hits the other's car as that machine shows it (a + path behind the true car), both give way there, and each owner's next + packets carry its own outcome to the others, whose copies are pulled or + snapped to it. + +## What diverged in 0.3.1, and why + +| | 0.3.1 | cause | +| --- | --- | --- | +| A player's car on another screen | 0.2-0.9 m from its own screen at the median, 1-3 m at the 99th percentile (table below) | every remote car is drawn a playout delay in the past (50-500 ms, what its snapshots need to arrive in time) | +| Collisions on one screen only | ramming head-on: 1 of 4 and 2 of 8 of the host's collisions had none on the client; shunting from behind: 13 of 21 of the host's had none on the client, 6 of 15 of the client's none on the host, 3 of those drawn more than 5 m apart on the host | each machine resolves its own car against the other's past image (sync review S3), which never gives way (S2): the two machines have different collisions at different places and times | +| Teleports | none over 1 m in any run (the remote cars are interpolated, the own car never corrected) | (the maintainer's teleports were not reproduced on these links) | + +## What changed + +1. **Trace and report** (80b8b8e): the `D`/`K`/`C` trace lines and + `netprobe syncreport`. +2. **Physics support** (48d1a70): `CarSim`/`Trailer` save and restore their + whole state; `World::replaySample` runs one car (and its trailer) alone + with everything else held still; `World::setSampleHooks` for per-sample + inputs; `CarSim::ownRandom` (each player's car draws its wheels' bump + numbers from its own stream on every machine). +3. **Protocol 5** (5f2f10d): `PlayerInput` and `CarStates` + (net/PlayerCars.h), checked within ranges, with a budget. +4. **Game layer** (24dbd3d): `NetCarDriver`, `HostInputQueue`, + `CarPrediction`, `CorrectionBlend` (game/net/PlayerCars). +5. **The race** (3f73059): the host simulates every client's car from its + inputs; clients predict their own car, reconcile and draw the other cars + interpolated from the host's states; resets travel as commands with the + inputs; the host decides every car's damage. +6. **Replays meet the other cars where they were** (19e8271), plus the + `OPENMM2_NET_OTHERS` experiment. +7. **Cops and Robbers' limits on the host** (276e508, sync review S6). +8. **Catch-up** (127ac41): a client's backlog (it loaded before the host) + is dropped after a second instead of lagging its car by seconds. +9. Development hooks: `OPENMM2_DEBUG_NETCARS` (corrections and costs), + `OPENMM2_DEBUG_NETCARS_NOISE` (921513d, 65733d3). + +The design is in docs/multiplayer.md, "Players' cars". + +## Before and after + +Screen divergence (metres: median / 99th percentile; "A ← B" is A's screen +showing B's car): + +| Scenario | view | 0.3.1 | host authority | +| --- | --- | --- | --- | +| ram | host ← client | 0.60 / 1.53 | 0.60 / 1.29 | +| ram | client ← host | 0.58 / 1.50 | 0.59 / 1.66 | +| chase | host ← client | 0.21 / 2.85 | 0.43 / 2.18 | +| chase | client ← host | 0.19 / 1.56 | 0.65 / 1.57 | +| ram, 150 ms, 5% | host ← client | 0.95 / 2.46 | 1.04 / 14.9 (a fall through the city, see below) | +| ram, 150 ms, 5% | client ← host | 0.90 / 2.45 | 0.93 / 2.44 | +| ram, 3 players | host ← clients | 0.53-0.56 / 1.41-1.42 | 0.42-0.46 / 1.27-1.33 | +| ram, 3 players | clients ← host | 0.56 / 1.43 | 0.57 / 1.44-1.57 | +| ram, 3 players | client ← client | 0.80-0.85 / 2.08-2.12 | 0.88-0.96 / 2.60-2.82 | +| ram, shared traffic | host ← client | 0.49 / 1.47 | 0.54 / 1.30 | +| ram, shared traffic | client ← host | 0.58 / 1.44 | 0.58 / 1.77 | +| rear | host ← client | 0.65 / 1.89 | 1.74 / 4.26 | +| rear | client ← host | 0.31 / 1.05 | 0.15 / 2.24 | + +The other players' cars are still drawn in the past (the maintainer's +choice: interpolated from the host's states), and a client's own car now +runs ahead of the host's by its inputs' trip and the host's margin, so the +distance between screens is about what it was. Where one car shunts another +the client's own car now shows where its prediction put it and the host +corrects it (below), which is the one view that grew. + +Collisions between players (host authority: the host's collisions are the +ones that happen; the client's are its prediction of them): + +| Scenario | 0.3.1: one screen only | host authority: host's collisions the client predicted / client predictions the host did not have | +| --- | --- | --- | +| ram | 1 of 4 | 18 of 18 / 0 of 18 | +| ram, 150 ms, 5% | 2 of 8 | 11 of 11 / 0 of 11 | +| ram, 3 players | 1 of 9, 1 of 4 | 4 of 4 and 2 of 4 / 0 and 2 | +| ram, shared traffic | 0 of 3 | 15 of 15 / 0 of 14 | +| rear | 13 of 21 (host), 6 of 15 (client) | 4 of 4 / 5 of 9 | + +Every collision now happens once, on the host, with both cars giving way by +their masses, and every machine shows its outcome: a client draws its own +car through the host's states and the others' from them. A collision the +client did not predict reaches its car as a correction a round trip later; +one it predicted that the host did not have (the other car was elsewhere by +then: shunting from behind against its past image) is corrected away. + +Jumps over 1 m in a frame: none in 0.3.1 and none after except one real +teleport (the harsh run: the client's car fell through the city at the foot +of the hill, about z = 255, and the fall rule reset it on both machines at +the same sample) and one 4.2 m correction drawn at once (3 players). + +Corrections of a client's own car (host authority only): + +| Scenario | a minute | median | 99th pct. | over 10 cm | over 1 m | samples run again | +| --- | --- | --- | --- | --- | --- | --- | +| chase (no contact) | 0 | | | 0 | 0 | | +| ram | 262 | 6.9 cm | 0.81 m | 137 | 1 | 12 | +| ram, 150 ms, 5% | 227 | 2.3 cm | 2.25 m | 86 | 9 | 24 | +| ram, 3 players | 193-331 | 2.5-2.9 cm | 1.1-1.3 m | 59-115 | 6-7 | 12 | +| ram, shared traffic | 257 | 3.4 cm | 0.82 m | 97 | 2 | 12 | +| rear | 543 | 6.9 cm | 2.15 m | 256 | 29 | 13 | + +A car that touches nothing is never corrected: the host and the client run +the same code on the same inputs from the same state (chase: 75 s without +one). Corrections come in bursts around contacts with another player's car +(and with props, which each machine still runs itself until the props work +lands); most are millimetres (the state after a correction is the host's +body and main parts, not every last field), and the drawing eases each one +away over about 60 ms, so the client's own car showed no jump over 1 m in +any run but one. Running the samples again cost at most 12.8 ms a second +and 2.5 ms in a frame (shunting, 13 samples a correction); it is bounded at +120 samples. + +The host never ran short of a client's inputs in these runs (0 repeated or +coasted samples); the client ran at 1.000 speed throughout. + +### Builds that round differently + +* A release host against a client built with `-D_GLIBCXX_ASSERTIONS` (and + no debug information): ram, 277 corrections a minute, 2 over 1 m: the + same as two identical builds (GCC at -O2 both, the floats come out the + same). +* `OPENMM2_DEBUG_NETCARS_NOISE=0.000001` on the client (its car's momentum + nudged by about a millionth every sample, as a compiler or a maths + library that rounds differently might): chase, which needs no correction + at all with identical builds, gave 418 corrections a minute, 54 over + 10 cm and 8 over 1 m (at most 2.2 m, eased away) where the cars crashed + into the city. Small differences stay small while driving and grow in a + crash, which is where an MSVC client against a GCC host would be + corrected. That pairing could not be tested here. + +### Where the other cars are placed (an experiment) + +`OPENMM2_NET_OTHERS=ahead` places and draws the other players' cars where +the host will have them when it runs this machine's sample (extrapolated +from the newest states by the samples not yet acknowledged less half the +round trip) instead of in the past; `=ghost` keeps the client's car from +touching them at all: + +| | rear: corrections over 1 m | rear: other car's jumps over 1 m a minute | ram: corrections over 1 m | ram: other car's jumps over 1 m a minute | +| --- | --- | --- | --- | --- | +| in the past (default) | 29 | 0 | 1 | 0 | +| ahead | 6 | 5.9 | 27 | 28.6 | +| ghost | 4 (cars drawn overlapping, 0.4 m apart) | 0 | | | + +Placing the others ahead helps a shunt and hurts a head-on crash (the +extrapolation overshoots where the other car brakes or swerves, and the +newest state then moves it back); not touching them lets the cars drive +into each other on screen. The default stays as the maintainer chose. + +## Bandwidth + +* A client's inputs: one message a frame that ran a sample (60 a second), + about 15 bytes of payload (0.9 KB/s), about 3.5 KB/s on the wire with + UDP and ENet's headers. A frame like the one before costs one bit. +* The host's states: 20 a second to each client, 270 bytes for the + client's own car plus 35 for each other car: 6.2 KB/s of payload with two + players (measured), about 11 KB/s down per client and 78 KB/s up for the + host with eight (0.3.1: about 6.5 and 46 KB/s), plus the shared traffic in + cruise. + +## Deviations from MM2 + +* The host simulates every player's car; MM2 ran every network car on every + machine with its own collisions (the maintainer's decision). +* A player's car takes its input once a physics sample (MM2: once a + frame; at 60 fps the same), and Cops and Robbers' regeneration with it. +* Each player's car draws its wheels' bump numbers from its own random + stream (MM2: one `rand()` for the game). +* The host decides every car's damage and Cops and Robbers' limits (MM2: + each machine its own car's damage; the limits were already the host's in + MM2, `UpdateLimit` / `SendLimitReached`). + +## Findings + +| # | Severity | Location (0.3.1) | Finding | Status | +| --- | --- | --- | --- | --- | +| C1 | visible | `RaceScreen::updateRemoteCars` | Each machine resolved its own car against the other's past image, which never gave way (sync review S2, S3): collisions happened at different places and times on each machine, and up to 13 of 21 on one screen only. | fixed (3f73059): the host's collision is the only one; every car gives way by its mass | +| C2 | visible | `RaceScreen::sendLocalState`, `DamageSync` | Each car's damage was decided by its owner. | fixed (3f73059): the host decides every car's damage | +| C3 | minor (parity) | `CopsAndRobbers::tickLimits` | Every machine checked the limits (sync review S6). | fixed (276e508) | +| C4 | visible | `HostInputQueue` (new) | A client that loaded before the host started seconds behind its car. | fixed (127ac41) | +| C5 | visible | `CarPrediction::acknowledge` (new) | Replays against the other cars' newest places made shunting corrections swing by up to 4 m. | fixed (19e8271) | +| O1 | visible | the prediction | Corrections of up to 2-3 m while shunting another player's car, which the client sees in the past. | open: inherent to drawing the others in the past and predicting one's own car; the experiment above trades it for the other cars jumping | +| O2 | visible | props | Each machine runs its own props, so a car knocking them is corrected (the props work makes them the host's). | for the props agent | +| O3 | minor | rules | Checkpoints, laps, finishes and Cops and Robbers' gold are still decided on each player's own machine and relayed (a client could claim them); only the limits are the host's. | open: the host would run each car's gates (`game::session::playerGateHit` on its simulated cars, the laps and the finish) and Cops and Robbers' `CopsAndRobbers::update` over every car (it has every car and every collision now), and send the results; the clients would show them | +| O4 | minor | commands | The host checks a client's resets only against the city's box and four a second, not against the race's checkpoints and the water. | open (needs the rules on the host) | +| O5 | minor | `CarStatesMsg` | The own car's state is 270 bytes at 20 Hz to every client (78 KB/s for the host with eight players). | open: it could go only when it changed beyond the tolerance | +| O6 | minor | MSVC against GCC | Not testable here; the noise test suggests corrections in crashes, eased away. | open | + +## Reproducing + +``` +# per machine, with its own XDG directories and port 2380: +OPENMM2_NET_TRACE=.trace OPENMM2_DEBUG_INPUT= OPENMM2_DEBUG_INPUT_MS=1000 \ + OPENMM2_DEBUG_START= OPENMM2_DEBUG_NETCARS=1 openmm2 ... +netprobe relay 127.0.0.1:2380 --port 2381 --delay 60 --jitter 20 --loss 2 --reorder +netprobe syncreport host.trace c1.trace [c2.trace] +``` + +The layouts above: host at (-1149.945, 111.9, 163.194) facing -Z (angle 0); +ram: client at (-1149.345, 114.9, 137.194), angle π, inputs +`1,0,0,0/1,0,0,0/0,1,0,0/0,0.6,0,0/1,0,0.1,0/1,0,0.1,0/0,1,0,0/0,0.6,0,0` +(host) and the same with steering -0.08 (client); rear: client at +(-1149.945, 111.9, 175.194), angle 0, host +`0.5,0,0,0/0.5,0,0,0/0,0.7,0,0/0.6,0,0.08,0/0.6,0,-0.08,0/0,1,0,0/0.5,0,0,0`, +client `1,0,0,0/1,0,0.02,0/1,0,0,0/1,0,-0.03,0/0.8,0,0,0/1,0,0.04,0/0,1,0,0`. diff --git a/docs/review/multiplayer-sync.md b/docs/review/multiplayer-sync.md index 85b5d1d..81d7a9b 100644 --- a/docs/review/multiplayer-sync.md +++ b/docs/review/multiplayer-sync.md @@ -181,11 +181,11 @@ a cruise never took them, so they piled up (also capped by d7fb35b). | # | 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. | fixed in 5b27203 (rendering, docs/rendering.md "Drawing between simulation steps"): every simulated object is drawn between its last two steps (`game::StepHistory`, blended by `interpolationAlpha`), and the remote cars and a client's shared traffic from a sample one step further back, where that scene is; at 144 fps the host's car as a client draws it moves by 1.5 mm (median second difference per frame) instead of 112 mm. | -| 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. | +| 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`). | superseded (protocol 5, docs/review/multiplayer-desync-cars.md): the host simulates every player's car, so collisions between players happen there once and both cars give way. | +| 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, the maintainer's choice for the host-authoritative model too); measured with the others placed ahead instead in docs/review/multiplayer-desync-cars.md. | | 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). | fixed in 1523740 (docs/multiplayer.md "Damage"): the network car's CurrentDamage follows the snapshot's level and `vehCarDamage::Update` makes its smoke, exhaust and tyre wobble; what the owner's `vehCarDamage::ApplyImpact` painted and broke travels as a reliable `VehicleDamage` event (each texel patch's point and random state, the parts, the impacts' sparks, shards and sound, a new epoch on every reset) and is replayed at the time it happened, so both machines show the same dents texel for texel (two instances, same-moment screenshots; test `DamageSync.ReplayedDentsMatchTheOwnersTexelForTexel`). The shared police get the same, and a client's knocked traffic cars their wheels. Deviation: a machine no longer paints its own collisions of a network car (MM2's did). Open: no tyre tracks or wheel particles on network cars. | -| 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. | +| 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. | fixed in 276e508 (docs/review/multiplayer-desync-cars.md C3): the host alone ends the game and tells the others. | | 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 From 52aa6014aff35a296a8801ce5d9f1fe7e844e61a Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Fri, 9 Oct 2026 14:40:54 -0700 Subject: [PATCH 043/129] List the network and interpolation files in the parity manifest Eighteen source files added since 0.2.0 had no manifest row: the race start (M, session; it ports MM2's start handshake) and the OpenMM2-only drawing between steps, damage, host-authoritative cars and shared traffic files. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/parity/README.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/docs/parity/README.md b/docs/parity/README.md index dcd7791..f68b635 100644 --- a/docs/parity/README.md +++ b/docs/parity/README.md @@ -206,6 +206,8 @@ audited in [openmm2-only.md](openmm2-only.md). | `src/app/Context.h` | 103 | O | - | | `src/app/Controls.cpp` | 105 | P | session | | `src/app/Controls.h` | 104 | P | session | +| `src/app/DrawTrace.cpp` | 45 | O | - | +| `src/app/DrawTrace.h` | 35 | O | - | | `src/app/ForceFeedback.cpp` | 311 | P | mm2-input-ff | | `src/app/ForceFeedback.h` | 114 | P | mm2-input-ff | | `src/app/GameData.cpp` | 153 | O | - | @@ -359,6 +361,8 @@ audited in [openmm2-only.md](openmm2-only.md). | `src/game/CityLevel.h` | 105 | P | rendering-fx | | `src/game/CityRenderer.cpp` | 391 | P | rendering-fx | | `src/game/CityRenderer.h` | 137 | P | rendering-fx | +| `src/game/Interpolation.cpp` | 251 | O | - | +| `src/game/Interpolation.h` | 160 | O | - | | `src/game/MeshDraw.cpp` | 88 | P | rendering-fx | | `src/game/MeshDraw.h` | 53 | P | rendering-fx | | `src/game/ModelLibrary.cpp` | 131 | P | rendering-fx | @@ -408,12 +412,24 @@ audited in [openmm2-only.md](openmm2-only.md). | `src/game/fx/VehicleEffects.h` | 102 | P | rendering-fx | | `src/game/fx/Weather.cpp` | 37 | P | rendering-fx | | `src/game/fx/Weather.h` | 44 | P | rendering-fx | +| `src/game/net/DamageSync.cpp` | 503 | O | - | +| `src/game/net/DamageSync.h` | 283 | O | - | | `src/game/net/NetGame.cpp` | 659 | M | session | | `src/game/net/NetGame.h` | 229 | M | session | +| `src/game/net/NetTrafficCars.cpp` | 101 | O | - | +| `src/game/net/NetTrafficCars.h` | 106 | O | - | +| `src/game/net/PlayerCars.cpp` | 483 | O | - | +| `src/game/net/PlayerCars.h` | 235 | O | - | +| `src/game/net/RaceStart.cpp` | 108 | M | session | +| `src/game/net/RaceStart.h` | 106 | M | session | +| `src/game/net/TrafficPrediction.cpp` | 164 | O | - | +| `src/game/net/TrafficPrediction.h` | 112 | O | - | | `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/net/TrafficTrace.cpp` | 55 | O | - | +| `src/game/net/TrafficTrace.h` | 44 | 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 | @@ -444,6 +460,7 @@ audited in [openmm2-only.md](openmm2-only.md). | `src/net/NatPmpBackend.cpp` | 279 | O | - | | `src/net/Net.cpp` | 113 | O | - | | `src/net/Net.h` | 62 | O | - | +| `src/net/PlayerCars.h` | 315 | O | - | | `src/net/PortMapper.cpp` | 324 | O | - | | `src/net/PortMapper.h` | 164 | O | - | | `src/net/Protocol.cpp` | 31 | O | - | @@ -457,6 +474,7 @@ audited in [openmm2-only.md](openmm2-only.md). | `src/net/Transport.cpp` | 270 | O | - | | `src/net/Transport.h` | 109 | O | - | | `src/net/UpnpBackend.cpp` | 190 | O | - | +| `src/net/VehicleDamage.h` | 160 | O | - | | `src/phys/AgeMath.cpp` | 241 | P | phys-core | | `src/phys/AgeMath.h` | 77 | P | phys-core | | `src/phys/Bound.cpp` | 879 | P | phys-bounds | From ecd675cfa7fb56a4175b84a1d1a6841843313a56 Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Fri, 9 Oct 2026 14:49:02 -0700 Subject: [PATCH 044/129] Keep the props' lines within 110 columns Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/multiplayer.md | 2 +- src/game/bangers/BangerSet.cpp | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/multiplayer.md b/docs/multiplayer.md index 7a66035..8e12453 100644 --- a/docs/multiplayer.md +++ b/docs/multiplayer.md @@ -732,7 +732,7 @@ them, and dent them, through single player's code. | --- | --- | | header | u32 session time, u32 placement checksum, count | | slot, generation | 6 + 4 bits; without a state only these travel (still there, at rest) | - | what | 2 bits: a placed prop (15 bits), a piece (15 + 4 bits), a car part (1 + 6 + 5 + 4 bits: owner kind and id, part, paint job) | + | what | 2 bits; a prop 15, a piece 15 + 4, a car part 1 + 6 + 5 + 4 (owner, part, paint) | | moving | 1 bit | | frame at the CG | 3 × 22 bits over ±8 km (4 mm), smallest-three quaternion (32 bits) | | motion (moving only) | velocity 3 × 13 bits (±128 m/s), spin 3 × 12 bits (±64 rad/s) | diff --git a/src/game/bangers/BangerSet.cpp b/src/game/bangers/BangerSet.cpp index bd21347..490885f 100644 --- a/src/game/bangers/BangerSet.cpp +++ b/src/game/bangers/BangerSet.cpp @@ -231,7 +231,9 @@ class BangerSet::Prop final : public phys::Instance { } // OpenMM2: a network client's props take contacts from its own car and // its own props only. - bool acceptsContact(const phys::Instance& other) const override { return m_set.acceptsFrom(m_index, other); } + bool acceptsContact(const phys::Instance& other) const override { + return m_set.acceptsFrom(m_index, other); + } // OpenMM2: the active a car's hit would attach (World::replaySample). bool heldInertia(phys::InertialCS& out) const override { return m_set.heldInertia(m_index, out); } From c36728827461157874b586a5a7da5ab39d55e04f Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Fri, 9 Oct 2026 14:56:17 -0700 Subject: [PATCH 045/129] Run a client's corrected samples against every body where it stood The samples a client runs again after a correction now meet every body within 40 m of its car where that sample first met it (the police, the traffic cars knocked loose, the knocked props), not only the other players' cars: World::bodiesNear lists them each sample, and the replay puts them back there, then where they stand. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/app/RaceScreen.cpp | 85 +++++++++++++++++++++++++----------------- src/phys/World.cpp | 7 ++++ src/phys/World.h | 3 ++ 3 files changed, 61 insertions(+), 34 deletions(-) diff --git a/src/app/RaceScreen.cpp b/src/app/RaceScreen.cpp index 72d931e..942373e 100644 --- a/src/app/RaceScreen.cpp +++ b/src/app/RaceScreen.cpp @@ -4891,11 +4891,12 @@ class RaceScreen final : public Screen { } return; } - // A client: what the other players' cars stood at for this sample - // (the samples run again meet them there). - m_proxyHistory.push_back({m_prediction.nextSeq(), proxyPoses()}); - while (m_proxyHistory.size() > 240) - m_proxyHistory.pop_front(); + // A client: where the bodies around its car (the other players' + // cars, the police, knocked traffic cars and props) stood for this + // sample: the samples run again meet them there. + m_bodyHistory.push_back({m_prediction.nextSeq(), bodyPoses()}); + while (m_bodyHistory.size() > 240) + m_bodyHistory.pop_front(); // Its car's first sample puts it where this machine started it, on // the host too. if (m_prediction.nextSeq() == 1) @@ -4993,7 +4994,14 @@ class RaceScreen final : public Screen { m_netLead = m_netLead < 0.0 ? lead : m_netLead + (lead - m_netLead) * 0.1; } const game::VehiclePose before = drawnPose(game::Drawn::Player, 0, *m_player); - const auto now = proxyPoses(); + // The bodies the samples run again may move, as they stand now. + std::vector now; + for (const auto& h : m_bodyHistory) + if (h.seq > ack) + for (const auto& p : h.poses) + if (std::ranges::none_of(now, [&](const BodyPose& q) { return q.body == p.body; }) && + m_world->contains(p.body)) + now.push_back(poseOf(*p.body)); const auto replayStart = std::chrono::steady_clock::now(); m_netReplaying = true; const auto c = m_prediction.acknowledge( @@ -5007,11 +5015,11 @@ class RaceScreen final : public Screen { resets); }, [this](std::uint32_t seq) { - for (const auto& h : m_proxyHistory) + for (const auto& h : m_bodyHistory) if (h.seq == seq) - placeProxies(h.poses); + placeBodies(h.poses); }); - placeProxies(now); + placeBodies(now); m_netReplaying = false; const double replayMs = std::chrono::duration(std::chrono::steady_clock::now() - replayStart).count(); @@ -5033,33 +5041,42 @@ class RaceScreen final : public Screen { c.damage ? ", damage" : "", c.held ? ", held" : "", c.gear ? ", gear" : ""); } - // Client: the other players' cars' bodies as they stand, and put back - // there (the samples run again, reconcileNetCar). - struct ProxyPose { - std::uint8_t id = 0; - Mat34 ics; - Vec3 velocity, spin; + // Client: the bodies within reach of its car as they stand, and put + // back there (the samples run again, reconcileNetCar, hold them still). + // A body that has left the world since is skipped (compared by address + // against the world's, never read). + struct BodyPose { + phys::Body* body = nullptr; + Mat34 ics, bound; + Vec3 velocity, spin, kinematicVelocity, kinematicSpin; }; - std::vector proxyPoses() const { - std::vector out; - for (const auto& [id, rv] : m_remotes) - if (rv.sim && !rv.simulated) { - const auto& body = rv.sim->sim().body; - out.push_back({id, body.ics.matrix, body.kinematicVelocity, body.kinematicSpin}); - } + static BodyPose poseOf(const phys::Body& b) { + return {const_cast(&b), b.ics.matrix, b.boundMatrix, b.ics.linearVelocity, + b.ics.angularVelocity, b.kinematicVelocity, b.kinematicSpin}; + } + std::vector bodyPoses() const { + std::vector out; + if (!m_world || !m_player) + return out; + std::vector near; + m_world->bodiesNear(m_player->sim().body.ics.matrix.m3, 40.0f, near); + const phys::Trailer* trailer = m_player->trailer(); + for (phys::Body* b : near) + if (b != &m_player->sim().body && (!trailer || b != &trailer->body)) + out.push_back(poseOf(*b)); return out; } - void placeProxies(const std::vector& poses) { + void placeBodies(const std::vector& poses) { for (const auto& p : poses) { - const auto it = m_remotes.find(p.id); - if (it == m_remotes.end() || !it->second.sim || it->second.simulated) + if (!m_world->contains(p.body)) continue; - auto& body = it->second.sim->sim().body; - body.place(p.ics); - body.ics.linearVelocity = p.velocity; - body.ics.angularVelocity = p.spin; - body.kinematicVelocity = p.velocity; - body.kinematicSpin = p.spin; + phys::Body& b = *p.body; + b.ics.matrix = p.ics; + b.boundMatrix = p.bound; + b.ics.linearVelocity = p.velocity; + b.ics.angularVelocity = p.spin; + b.kinematicVelocity = p.kinematicVelocity; + b.kinematicSpin = p.kinematicSpin; } } @@ -6059,11 +6076,11 @@ class RaceScreen final : public Screen { float m_netDilation = 1.0f; // client: the rate its samples run at double m_netLead = -1.0; // client: how far (ms) its car runs ahead of the host double m_netReplayMs = 0.0, m_netReplayWorstMs = 0.0; // client: what its corrections cost - struct ProxyHistory { + struct BodyHistory { std::uint32_t seq = 0; - std::vector poses; + std::vector poses; }; - std::deque m_proxyHistory; // client: the other cars at each recent sample + std::deque m_bodyHistory; // client: the bodies around its car at each recent sample std::deque m_netWaiting; // client: the host's latest counts of its inputs in hand std::uint64_t m_netCarsSentAt = 0; // host: the last CarStates std::uint64_t m_netStatsAt = 0; // the last log of the statistics diff --git a/src/phys/World.cpp b/src/phys/World.cpp index 6902308..e8d8df5 100644 --- a/src/phys/World.cpp +++ b/src/phys/World.cpp @@ -699,6 +699,13 @@ void World::replaySample(std::span bodies, float dt) { m_stepping = stepping; } +void World::bodiesNear(const Vec3& at, float radius, std::vector& out) const { + const float r2 = radius * radius; + for (const Mover& m : m_movers) + if (m.body && live(m) && m.body->ics.matrix.m3.dist2(at) <= r2) + out.push_back(m.body); +} + bool World::collideHeld(Body& a, Instance& b) { // collideInstances with B as a static instance whatever it is: a // temporary collider without a body, moving at B's own velocity, so diff --git a/src/phys/World.h b/src/phys/World.h index e8642e4..4eb82ce 100644 --- a/src/phys/World.h +++ b/src/phys/World.h @@ -257,6 +257,9 @@ class World final : public GroundQuery { // them at its own velocity. A network client runs its own car's samples // again with it once the host's state has corrected an earlier one. void replaySample(std::span bodies, float dt); + // OpenMM2 (network prediction): the bodies in the world (not removed) + // whose centre lies within `radius` of `at`, appended to `out`. + void bodiesNear(const Vec3& at, float radius, std::vector& out) const; bool probe(const Vec3& a, const Vec3& b, RayHit& hit) const override; // dgPhysManager::Collide(segment, mask 0x20) as vehWheel::ComputeDwtdw From 784b3b1ae36ae706b5bb54d33cee8cdc435dace5 Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Fri, 9 Oct 2026 15:06:25 -0700 Subject: [PATCH 046/129] Let a client's replay take the knocks its car really took A correction that went back to before a knock replayed the car through the sample where it had hit the prop, but the prop was gone by then (knocked, its pieces flying elsewhere): the replayed car kept the speed the knock had taken off, and the next state corrected it again (1.4-1.9 m and 5-6 m/s after a street light). A client's replay now meets a placed prop its car broke loose in the last second where it stood (a ghost, for the replay only; its pieces are left out), and a prop the replayed car hits takes the body a real hit gives it and moves on through the replay as the hits push it, instead of giving way after the first: the replay takes the same knock as the real samples (test: within 0.5 m/s, from 1.2 m/s without). A prop that holds stays where it stands. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/game/bangers/BangerSet.cpp | 46 +++++++++++++++++++++++--- src/game/bangers/BangerSet.h | 12 +++++++ src/phys/World.cpp | 60 ++++++++++++++++++++++++---------- src/phys/World.h | 12 +++++-- tests/game/test_prop_sync.cpp | 36 ++++++++++++++++++++ 5 files changed, 143 insertions(+), 23 deletions(-) diff --git a/src/game/bangers/BangerSet.cpp b/src/game/bangers/BangerSet.cpp index 490885f..06e3928 100644 --- a/src/game/bangers/BangerSet.cpp +++ b/src/game/bangers/BangerSet.cpp @@ -133,6 +133,9 @@ struct BangerSet::DataBounds { struct BangerSet::ActiveBody final : phys::Body { Active* owner = nullptr; void detach() override; + // OpenMM2: a replayed car meets the prop this piece broke off as it + // stood (a replay ghost), not the piece. + bool acceptsContact(const phys::Instance& other) const override; }; // Its body's phInertialCS is dgBangerActive::GetICS. @@ -180,7 +183,7 @@ class BangerSet::Prop final : public phys::Instance { // the prop stops colliding this way instead. const phys::Bound* bound(int which) const override { const BangerSet::Instance& inst = m_set.m_instances[m_index]; - if (inst.state == State::Gone || !inst.data) + if ((inst.state == State::Gone && !m_set.replayGhost(m_index)) || !inst.data) return nullptr; const DataBounds& b = m_set.boundsOf(*inst.data); if (which == 1 && b.box) @@ -203,7 +206,7 @@ class BangerSet::Prop final : public phys::Instance { } phys::Body* attachEntity() override { return m_set.attachEntity(m_index); } - bool isBanger() const override { return banger; } + bool isBanger() const override { return banger || m_set.replayGhost(m_index); } float bangerImpulseLimit2() const override { const BangerSet::Instance& inst = m_set.m_instances[m_index]; return inst.data ? inst.data->impulseLimit2 : 0.0f; @@ -232,7 +235,7 @@ class BangerSet::Prop final : public phys::Instance { // OpenMM2: a network client's props take contacts from its own car and // its own props only. bool acceptsContact(const phys::Instance& other) const override { - return m_set.acceptsFrom(m_index, other); + return !m_set.replayGhostPiece(m_index) && m_set.acceptsFrom(m_index, other); } // OpenMM2: the active a car's hit would attach (World::replaySample). bool heldInertia(phys::InertialCS& out) const override { return m_set.heldInertia(m_index, out); } @@ -420,6 +423,12 @@ void BangerSet::instancesIn(int room, std::vector& out) const { for (Prop* p : m_rooms[static_cast(room)]) if (p->collidable) out.push_back(p); + // OpenMM2: in a network client's replay, the props its car broke loose + // lately, where they stood. + if (m_world && m_world->replaying()) + for (const Ghost& g : m_ghosts) + if (m_instances[g.prop].room == room && m_instances[g.prop].state == State::Gone) + out.push_back(m_props[g.prop].get()); } // --- Setup ----------------------------------------------------------------------------------- @@ -738,6 +747,10 @@ void BangerSet::detachMe(Active& a) { void BangerSet::ActiveBody::detach() { owner->set->worldDetach(*owner); } +bool BangerSet::ActiveBody::acceptsContact(const phys::Instance&) const { + return owner->instance < 0 || !owner->set->replayGhostPiece(static_cast(owner->instance)); +} + void BangerSet::worldDetach(Active& a) { // dgPhysManager::Update detaches a type-1 mover outside the active rooms // through its instance (lvlInstance vtable +0x28). @@ -802,6 +815,10 @@ void BangerSet::unhitImpact(std::size_t i, const phys::Instance* by) { Prop& unhitProp = *m_props[i]; if (m_recordKnocks && !m_instances[i].everHit) m_knocks.push_back({i, by}); // OpenMM2: network games (game/net/PropSync) + // OpenMM2: a network client's own car broke it loose: its replays meet + // it where it stood for a while (replayGhost). + if (m_replica && m_world && !m_instances[i].everHit && by && m_localToucher && m_localToucher(*by)) + m_ghosts.push_back({i, m_world->time()}); if (d.numParts == 0) { // A hit instance from the ring takes the prop's place in its room @@ -993,6 +1010,11 @@ void BangerSet::declare(Active& a, float dt) { } void BangerSet::update(float dt) { + // OpenMM2: a client's replays meet a prop its car broke loose for this + // long (as long as the host's correction of the car may reach back). + if (m_world) + std::erase_if(m_ghosts, + [now = m_world->time()](const Ghost& g) { return now - g.time > kGhostSeconds; }); // dgBangerActive::PostUpdate, which the physics manager calls on its // movers after the frame's samples: an active that fell asleep or below // the city detaches. @@ -1124,7 +1146,7 @@ bool BangerSet::heldInertia(std::size_t i, phys::InertialCS& ics) const { if (i >= m_instances.size()) return false; const Instance& inst = m_instances[i]; - if (!inst.data || inst.state == State::Gone || inst.active >= 0) + if (!inst.data || (inst.state == State::Gone && !replayGhost(i)) || inst.active >= 0) return false; const BangerData& d = *inst.data; ics = phys::InertialCS{}; @@ -1140,6 +1162,21 @@ bool BangerSet::heldInertia(std::size_t i, phys::InertialCS& ics) const { return true; } +bool BangerSet::replayGhost(std::size_t i) const { + if (!m_world || !m_world->replaying() || m_ghosts.empty()) + return false; + return std::ranges::any_of(m_ghosts, [i](const Ghost& g) { return g.prop == i; }); +} + +bool BangerSet::replayGhostPiece(std::size_t i) const { + if (!m_world || !m_world->replaying() || m_ghosts.empty() || i >= m_instances.size()) + return false; + const int source = m_instances[i].source; + return m_instances[i].everHit && source >= 0 && std::ranges::any_of(m_ghosts, [source](const Ghost& g) { + return g.prop == static_cast(source); + }); +} + bool BangerSet::isActiveBody(const phys::Instance* i) const { for (const auto& a : m_active) if (&a->body == i) @@ -1190,6 +1227,7 @@ void BangerSet::restoreStanding(std::size_t i) { Prop& prop = *m_props[i]; if (prop.banger) return; + std::erase_if(m_ghosts, [i](const Ghost& g) { return g.prop == i; }); // dgUnhitBangerInstance::Reset: back in its room, standing. Instance& inst = m_instances[i]; if (Active* a = activeOf(i)) diff --git a/src/game/bangers/BangerSet.h b/src/game/bangers/BangerSet.h index e49fbc2..efef342 100644 --- a/src/game/bangers/BangerSet.h +++ b/src/game/bangers/BangerSet.h @@ -224,6 +224,13 @@ class BangerSet final : public InstanceSource { // (phys::Instance::heldInertia, for World::replaySample); false for an // instance with an active or out of the world. bool heldInertia(std::size_t i, phys::InertialCS& ics) const; + // A client's replay of its car (phys::World::replaying) meets a placed + // prop its car broke loose in the last kGhostSeconds as it stood there + // (a "ghost", standing again for the replay only), and not the pieces it + // became: the replayed car takes the knock the real one took. + bool replayGhost(std::size_t i) const; + bool replayGhostPiece(std::size_t i) const; + static constexpr double kGhostSeconds = 1.0; // A network client: only this machine's own car (`localToucher`) and the // props this set simulates itself may touch a prop (phys::Instance:: @@ -324,6 +331,11 @@ class BangerSet final : public InstanceSource { bool m_replica = false; std::function m_localToucher; std::vector m_mirrors; // a client's instance per host ring slot (npos: none yet) + struct Ghost { + std::size_t prop = 0; + double time = 0.0; // world time it broke loose + }; + std::vector m_ghosts; // a client's own car's knocks, for its replays std::size_t m_skipped = 0; bool m_ageMode = false; // dgBangerDataManager +0x2a8a8 (cleared by mmGame::Init) fx::FixedTicker m_ticker; diff --git a/src/phys/World.cpp b/src/phys/World.cpp index 4959687..883c411 100644 --- a/src/phys/World.cpp +++ b/src/phys/World.cpp @@ -525,6 +525,7 @@ void World::step(float dt) { if (m_beforeSample) m_beforeSample(); m_replayYielded.clear(); + m_replayBodies.clear(); const float invDt = 1.0f / dt; // datTimeManager::SetTempOverSampling: Seconds is the sample's length. sampleTime() = {dt, invDt}; @@ -632,7 +633,11 @@ void World::replaySample(std::span bodies, float dt) { sampleTime() = {dt, invDt}; const bool stepping = m_stepping; m_stepping = true; + m_replaying = true; const auto replayed = [&](const Body* b) { return std::ranges::find(bodies, b) != bodies.end(); }; + // OpenMM2: the props the replay has pushed move on (collideHeld). + for (ReplayBody& r : m_replayBodies) + r.ics.update(dt, invDt); // The update, as step() runs it for these bodies. for (Body* b : bodies) if (b->updates && b->controller) @@ -698,6 +703,7 @@ void World::replaySample(std::span bodies, float dt) { if (b->updates && b->controller) b->controller->afterCollisions(*b, dt, *this); m_stepping = stepping; + m_replaying = false; } bool World::collideHeld(Body& a, Instance& b) { @@ -705,33 +711,45 @@ bool World::collideHeld(Body& a, Instance& b) { // temporary collider without a body, moving at B's own velocity, so // that only A takes the impulse and the push; nothing is attached, no // banger breaks loose and nothing is marked hit. + if (!b.acceptsContact(a)) + return false; // OpenMM2: as collideInstances (a network client's props) const Bound* boundA = a.bound(0); const Bound* boundB = b.bound(0); if (!boundA || !boundB || boundB->type == BoundType::ForceSphere) return false; - m_tempMatrixB = b.matrix(); - const Vec3 relPos = m_tempMatrixB.m3 - a.matrix().m3; Collider* colA = &a.collider; // A body (moved by the simulation or from outside) as a kinematic one, // a static instance as itself. OpenMM2 (the props of a network race): // what gives way when a car really hits it meets the replayed car with - // its mass, though it still holds still and takes nothing: a simulated - // body (a knocked-over prop's active) with a copy of its ICS, an - // instance that takes a body when hit (a standing or resting prop) with - // the one it would take (Instance::heldInertia). + // its mass, though the world is not changed. A simulated body (a + // knocked-over prop's active) with a copy of its ICS; a light one (under + // a quarter of the car's mass) has given way once hit, as the real one + // flies off (inferred stand-in for its flight). An instance that takes a + // body when hit (a standing or resting prop, Instance::heldInertia) with + // the body it would take, which then moves for the rest of the replay + // as the hits push it (no gravity, no city: a replay is short). Body* entity = b.entity(); - // Such a light one (under a quarter of the car's mass: the props, not a - // parked car) has given way once the replayed car has hit it: the real - // one flies off. Inferred stand-in for its motion. if (std::ranges::find(m_replayYielded, &b) != m_replayYielded.end()) return false; InertialCS* held = nullptr; + bool replayBody = false; if (entity && !entity->kinematic && entity->ics.mass > 0.0f) { m_heldIcs = entity->ics; held = &m_heldIcs; - } else if (!entity && b.heldInertia(m_heldIcs)) { - held = &m_heldIcs; + } else if (!entity) { + const auto it = std::ranges::find(m_replayBodies, &b, &ReplayBody::instance); + if (it != m_replayBodies.end()) { + held = &it->ics; + replayBody = true; + } else if (b.heldInertia(m_heldIcs)) { + m_heldIcs.gravity = {}; + m_replayBodies.push_back({&b, m_heldIcs}); + held = &m_replayBodies.back().ics; + replayBody = true; + } } + m_tempMatrixB = replayBody ? held->matrix : b.matrix(); + const Vec3 relPos = m_tempMatrixB.m3 - a.matrix().m3; if (held) m_tempB.init(boundB, &m_tempMatrixB, held); else if (entity) @@ -757,23 +775,31 @@ bool World::collideHeld(Body& a, Instance& b) { return false; std::span impacts(m_impacts.data(), static_cast(n)); const float weight = 1.0f / static_cast(n); - bool broke = false; - if (b.isBanger() && !entity) { + // A standing prop's break test, until it has broken loose in this replay. + const bool banger = b.isBanger() && !entity && !(replayBody && held->linearMomentum.mag2() > 0.0f); + if (banger) { const float limit2 = b.bangerImpulseLimit2(); + bool broke = false; for (Impact& im : impacts) broke = calcBangerImpact(im, weight, limit2) || broke; + if (!broke && replayBody) { + // It held (dgBangerActive::DetachMe): it stays where it stands. + held->linearImpulse = {}; + held->angularImpulse = {}; + } } else { for (Impact& im : impacts) calcImpact(im, weight); } - const bool gives = held && (broke || !b.isBanger()) && colA->ics && - held->mass < colA->ics->mass * 0.25f; - if (gives) + if (held && !replayBody && colA->ics && held->mass < colA->ics->mass * 0.25f) m_replayYielded.push_back(&b); return true; } -void World::beginReplay() { m_replayYielded.clear(); } +void World::beginReplay() { + m_replayYielded.clear(); + m_replayBodies.clear(); +} bool World::trivialCollide(const Instance& a, const Instance& b) const { // dgPhysManager::TrivialCollideInstances: bounding spheres. A banger diff --git a/src/phys/World.h b/src/phys/World.h index 5efede2..5687be1 100644 --- a/src/phys/World.h +++ b/src/phys/World.h @@ -258,9 +258,11 @@ class World final : public GroundQuery { // again with it once the host's state has corrected an earlier one. void replaySample(std::span bodies, float dt); // OpenMM2: the samples replaySample runs from here on are one replay - // (a light prop the replayed car has hit stays out of its way until the + // (the props the replayed car has pushed move on through it, until the // next replay or sample; see collideHeld). void beginReplay(); + // Inside replaySample (the level's sources may show what a replay meets). + bool replaying() const { return m_replaying; } bool probe(const Vec3& a, const Vec3& b, RayHit& hit) const override; // dgPhysManager::Collide(segment, mask 0x20) as vehWheel::ComputeDwtdw @@ -359,7 +361,13 @@ class World final : public GroundQuery { Collider m_levelCollider; Collider m_tempA, m_tempB; InertialCS m_heldIcs; // replaySample: what a held body or prop would have (taken by nothing) - std::vector m_replayYielded; // replaySample: light props the replay has hit + std::vector m_replayYielded; // replaySample: light bodies the replay has hit + struct ReplayBody { + const Instance* instance = nullptr; + InertialCS ics; // the body the prop takes in the replay, pushed by its hits + }; + std::vector m_replayBodies; + bool m_replaying = false; Mat34 m_tempMatrixA, m_tempMatrixB; std::vector m_isectsA, m_isectsB; std::vector m_impacts; diff --git a/tests/game/test_prop_sync.cpp b/tests/game/test_prop_sync.cpp index d5954dc..1a4883c 100644 --- a/tests/game/test_prop_sync.cpp +++ b/tests/game/test_prop_sync.cpp @@ -376,6 +376,42 @@ TEST(PropSync, OtherCarsPassThroughAClientsProps) { // (World::replaySample): the props it meets there hold still, but with the // mass a real hit gives them, so the replayed car loses what the real one // does instead of stopping as against a wall. +// A client that replays its car through the sample where its car knocked a +// prop (the host's state came from before the knock) meets the prop where it +// stood, not knocked and gone: the replay takes the same knock and ends where +// the real samples did. +TEST(PropSync, ReplayThroughAKnockTakesTheSameKnock) { + TempBangers files; + bangers::BangerDataLibrary lib(files.vfs); + Machine client(lib); + client.place(layout()); + Car car({0, 1, 4.5f}, {0, 0, -10}); + client.set.setReplica([&car](const phys::Instance& other) { return &other == &car.body; }); + client.world.add(&car.body); + // The state a correction would go back to: before the hit. + for (int i = 0; i < 5; ++i) + client.step(); + ASSERT_TRUE(client.set.standing(0)); + const Mat34 matrix = car.body.ics.matrix; + const Vec3 velocity = car.body.ics.linearVelocity; + for (int i = 0; i < 25; ++i) + client.step(); + ASSERT_FALSE(client.set.standing(0)); // the real samples knocked it + const float real = car.body.ics.linearVelocity.z; + // Back to the earlier state, and the same samples again as a replay. + car.body.place(matrix); + car.body.ics.linearVelocity = velocity; + car.body.ics.linearMomentum = velocity * car.body.ics.mass; + phys::Body* bodies[] = {&car.body}; + client.world.beginReplay(); + for (int i = 0; i < 25; ++i) + client.world.replaySample(bodies, kDt); + EXPECT_FALSE(client.set.standing(0)); // a replay changes no prop + EXPECT_NEAR(car.body.ics.linearVelocity.z, real, 0.5f) << "real " << real; + EXPECT_GT(car.body.ics.linearVelocity.z, -9.9f); // it met the prop + client.world.remove(&car.body); +} + TEST(PropSync, ReplayedCarMeetsAPropAsARealSampleDoes) { TempBangers files; bangers::BangerDataLibrary lib(files.vfs); From 5e68affedac797f83acb8f0f3d1c655e132c2647 Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Fri, 9 Oct 2026 15:17:39 -0700 Subject: [PATCH 047/129] Test a replay just after a knock, and document the replays' props A replay that starts just after the car knocked a prop, the car still across its place, meets the prop where it stood and pushes it on, and ends within 0.3 m/s of the real samples (which went on pushing the falling prop). The docs describe the replays' props as they now are. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/bangers.md | 4 +++- docs/multiplayer.md | 19 +++++++++++-------- docs/parity/openmm2-only.md | 2 +- tests/game/test_prop_sync.cpp | 30 ++++++++++++++++++++++++++++++ 4 files changed, 45 insertions(+), 10 deletions(-) diff --git a/docs/bangers.md b/docs/bangers.md index d34008b..fd4fb20 100644 --- a/docs/bangers.md +++ b/docs/bangers.md @@ -208,4 +208,6 @@ needs, all of it inert in a single-player race: placed or resting prop at rest). * A client replaying its car (`phys::World::replaySample`) meets a prop with the body an active would give it (`heldInertia`: `dgBangerActive::Attach`'s - mass and inertia, a mirror's host motion), holding it still. + mass and inertia, a mirror's host motion), which the world moves through + the replay only; a prop its car broke loose in the last second stands + again for the replay (`replayGhost`), its pieces left out. diff --git a/docs/multiplayer.md b/docs/multiplayer.md index 7414d3c..bba4ab8 100644 --- a/docs/multiplayer.md +++ b/docs/multiplayer.md @@ -802,14 +802,17 @@ them, and dent them, through single player's code. included) come off without a throw: the host's ring shows the parts it threw. * **Replays.** When the host's state corrects its car, a client runs the - car's later samples again (`World::replaySample`) with everything else - held still. The props meet the replayed car with the mass a real hit gives - them (`Instance::heldInertia`: the active the prop would take; a copy of a - flying piece's), and a light one (under a quarter of the car's mass) gives - way after the replay's first hit, as the real one flies off; nothing is - knocked in a replay. A prop a client's car hits in its own samples is the - same knock on the host, sample for sample: a client driving alone through - props had no correction. + car's later samples again (`World::replaySample`) with the world held + still. A prop meets the replayed car with the body a real hit gives it + (`Instance::heldInertia`: the active `dgBangerActive::Attach` would give + it) and moves on through the replay as the hits push it (without gravity + or the city: a replay is short); a placed prop the car broke loose in the + last second stands again for the replay where it stood (its pieces left + out), so the replay takes the knock the real samples took; a flying piece + meets it with a copy of its body and gives way after one hit if light. + Nothing is knocked or moved in the world by a replay. A prop a client's + car hits in its own samples is the same knock on the host, sample for + sample: a client driving alone through props had no correction. * **A different placement** (an altered city, a mismatched build): the client follows only the cars' parts and simulates its props itself, as 0.3 did, and logs it. diff --git a/docs/parity/openmm2-only.md b/docs/parity/openmm2-only.md index ae77f3b..f7585fc 100644 --- a/docs/parity/openmm2-only.md +++ b/docs/parity/openmm2-only.md @@ -153,7 +153,7 @@ in the summary above. | `BangerSet::setReplica`, `phys::Instance::acceptsContact` | `dgPhysManager::CollideInstances` | deviation | A client's props take contacts from its own car and its own simulated props only; MM2's take them from every mover. | | `BangerSet::breakPlaced`, `restoreStanding`, `showMirror`, `hideMirror` | `dgUnhitBangerInstance::Impact`, `Reset`, `dgHitBangerInstance` | openmm2 | A client's placed prop leaves its room as Impact makes it leave (without a body), stands again as Reset makes it stand (a prediction the host did not confirm), and the host's pieces are hit instances of their own. | | `BangerSet::attachEntity` on a mirror | `dgBangerActive::Attach` | deviation | MM2 starts an active at rest (a placed or resting prop is); a client's mirror of a host piece in flight starts from the host's motion. | -| `phys::World::collideHeld` with `Instance::heldInertia`, `World::beginReplay` | (none: MM2 does not replay) | openmm2 | A client's replayed car meets a prop with the body a real hit gives it (`dgBangerActive::Attach`'s), held still; a light one gives way after the replay's first hit (inferred stand-in for its flight). | +| `phys::World::collideHeld` with `Instance::heldInertia`, `World::beginReplay`, `BangerSet::replayGhost` | (none: MM2 does not replay) | openmm2 | A client's replayed car meets a prop with the body a real hit gives it (`dgBangerActive::Attach`'s), which moves through the replay only as the hits push it (no gravity or city: inferred stand-in for its flight); a prop the car broke loose in the last second stands again for the replay, so the replay takes the same knock. | | Thrown car parts (`ejectPart`'s tag, `game::carPartTag`) | `vehBreakableMgr::Eject` on every machine's simulation | deviation | The host throws them for everyone; a client takes another car's parts off without a throw and throws its own car's at once (handed over to the host's). | ## Files diff --git a/tests/game/test_prop_sync.cpp b/tests/game/test_prop_sync.cpp index 1a4883c..db73134 100644 --- a/tests/game/test_prop_sync.cpp +++ b/tests/game/test_prop_sync.cpp @@ -412,6 +412,36 @@ TEST(PropSync, ReplayThroughAKnockTakesTheSameKnock) { client.world.remove(&car.body); } +// A replay that starts just after the knock, the car still across the +// prop's place and pushing it, ends where the real samples did: it meets the +// prop where it stood and pushes it on. +TEST(PropSync, ReplayJustAfterAKnockEndsAsTheRealSamples) { + TempBangers files; + bangers::BangerDataLibrary lib(files.vfs); + Machine client(lib); + client.place(layout()); + Car car({0, 1, 4.5f}, {0, 0, -10}); + client.set.setReplica([&car](const phys::Instance& other) { return &other == &car.body; }); + client.world.add(&car.body); + while (client.set.standing(0)) + client.step(); + client.step(); // just after the knock, the car still across the prop's place + const Mat34 matrix = car.body.ics.matrix; + const Vec3 velocity = car.body.ics.linearVelocity; + for (int i = 0; i < 15; ++i) + client.step(); + const float real = car.body.ics.linearVelocity.z; + car.body.place(matrix); + car.body.ics.linearVelocity = velocity; + car.body.ics.linearMomentum = velocity * car.body.ics.mass; + phys::Body* bodies[] = {&car.body}; + client.world.beginReplay(); + for (int i = 0; i < 15; ++i) + client.world.replaySample(bodies, kDt); + EXPECT_NEAR(car.body.ics.linearVelocity.z, real, 0.3f) << "real " << real; + client.world.remove(&car.body); +} + TEST(PropSync, ReplayedCarMeetsAPropAsARealSampleDoes) { TempBangers files; bangers::BangerDataLibrary lib(files.vfs); From cae6989152016ef3a0ceb409569e0b6c222fe918 Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Fri, 9 Oct 2026 15:18:20 -0700 Subject: [PATCH 048/129] Record the final runs of the host-authoritative players' cars The review's tables now come from c367288 merged with the shared traffic's work, with the rear-end runs given twice, and the shake a corrected car can show inside another player's past image is recorded under O1. The prediction notes name every body the replays put back, and the drawn-car trace sits above the impact comment it had split. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/multiplayer.md | 8 +- docs/review/multiplayer-desync-cars.md | 101 +++++++++++++++---------- src/app/RaceScreen.cpp | 10 ++- 3 files changed, 72 insertions(+), 47 deletions(-) diff --git a/docs/multiplayer.md b/docs/multiplayer.md index 1cb1416..9fd2f17 100644 --- a/docs/multiplayer.md +++ b/docs/multiplayer.md @@ -594,9 +594,11 @@ every player's car on the host: matrix, or in the damage, gear or hold), the client puts the car back to its saved state for that sample with the host's on top and runs the later samples again on their inputs (`phys::World::replaySample`: the car alone, - everything it touches held still and moving at its own velocity, the - other players' cars where each sample first met them; no sound or effect), - at most 120 samples. The drawing keeps the car where it was and eases it + everything it touches held still and moving at its own velocity, every + body within 40 m of it (the other players' cars, the police, knocked + traffic cars and props) where each sample first met it; the traffic cars + on their rails where the frame placed them; no sound or effect), at most + 120 samples. The drawing keeps the car where it was and eases it onto the corrected place with a 60 ms half-life (`game::CorrectionBlend`; more than 4 m is a jump, drawn at once). The client's simulation runs up to 3% faster when the host had fewer than one of its inputs in hand over diff --git a/docs/review/multiplayer-desync-cars.md b/docs/review/multiplayer-desync-cars.md index 4451f49..3c6f81c 100644 --- a/docs/review/multiplayer-desync-cars.md +++ b/docs/review/multiplayer-desync-cars.md @@ -40,7 +40,14 @@ behaviour and what OpenMM2 does differently. and how close every machine drew the two cars within 0.6 s of it; **corrections** are a client's own car moved by the host's states. * The 0.3.1 numbers come from 0.3.1 with only the trace added (80b8b8e and - the clock fix below), built separately. + the clock fix below), built separately; the "host authority" numbers from + c367288 (merged with the shared traffic's work, 52aa601), release builds. +* A crash is chaotic: the same scenario run again moves by tens of percent + (rear: 543, 786 and 423 corrections a minute in three runs, the second + while the opponent sweep was loading the machine). The tables show single + runs; where a scenario was run twice both are given. +* A jump's velocity for a machine's own car is its body's frame velocity, + which includes the sample's pushes out of contacts. A first round of "before" numbers was wrong: the trace's clock was each process's own (`net::monotonicMs` counts from the process's start), so @@ -122,8 +129,11 @@ decompile and the asm of `SendPosition` and `PositionUpdate`): inputs; clients predict their own car, reconcile and draw the other cars interpolated from the host's states; resets travel as commands with the inputs; the host decides every car's damage. -6. **Replays meet the other cars where they were** (19e8271), plus the - `OPENMM2_NET_OTHERS` experiment. +6. **Replays meet the other cars where they were** (19e8271), then every + body within 40 m of the car (c367288: the police, knocked traffic cars + and props too), plus the `OPENMM2_NET_OTHERS` experiment. The traffic + cars still on their rails are held where the frame placed them (the + shared traffic's `TrafficClient::poseAt` could put them back too). 7. **Cops and Robbers' limits on the host** (276e508, sync review S6). 8. **Catch-up** (127ac41): a client's backlog (it loaded before the host) is dropped after a second instead of lagging its car by seconds. @@ -139,26 +149,28 @@ showing B's car): | Scenario | view | 0.3.1 | host authority | | --- | --- | --- | --- | -| ram | host ← client | 0.60 / 1.53 | 0.60 / 1.29 | -| ram | client ← host | 0.58 / 1.50 | 0.59 / 1.66 | -| chase | host ← client | 0.21 / 2.85 | 0.43 / 2.18 | -| chase | client ← host | 0.19 / 1.56 | 0.65 / 1.57 | -| ram, 150 ms, 5% | host ← client | 0.95 / 2.46 | 1.04 / 14.9 (a fall through the city, see below) | -| ram, 150 ms, 5% | client ← host | 0.90 / 2.45 | 0.93 / 2.44 | -| ram, 3 players | host ← clients | 0.53-0.56 / 1.41-1.42 | 0.42-0.46 / 1.27-1.33 | -| ram, 3 players | clients ← host | 0.56 / 1.43 | 0.57 / 1.44-1.57 | -| ram, 3 players | client ← client | 0.80-0.85 / 2.08-2.12 | 0.88-0.96 / 2.60-2.82 | -| ram, shared traffic | host ← client | 0.49 / 1.47 | 0.54 / 1.30 | -| ram, shared traffic | client ← host | 0.58 / 1.44 | 0.58 / 1.77 | -| rear | host ← client | 0.65 / 1.89 | 1.74 / 4.26 | -| rear | client ← host | 0.31 / 1.05 | 0.15 / 2.24 | +| ram | host ← client | 0.60 / 1.53 | 0.53 / 1.19 | +| ram | client ← host | 0.58 / 1.50 | 0.61 / 1.76 | +| chase | host ← client | 0.21 / 2.85 | 0.24 / 2.20 | +| chase | client ← host | 0.19 / 1.56 | 0.22 / 1.67 | +| ram, 150 ms, 5% | host ← client | 0.95 / 2.46 | 1.05 / 2.40 | +| ram, 150 ms, 5% | client ← host | 0.90 / 2.45 | 0.99 / 2.53 | +| ram, 3 players | host ← clients | 0.53-0.56 / 1.41-1.42 | 0.50-0.51 / 1.26-1.44 | +| ram, 3 players | clients ← host | 0.56 / 1.43 | 0.56-0.57 / 1.50-1.51 | +| ram, 3 players | client ← client | 0.80-0.85 / 2.08-2.12 | 0.99-1.04 / 2.66-2.98 | +| ram, shared traffic | host ← client | 0.49 / 1.47 | 0.58 / 1.37 | +| ram, shared traffic | client ← host | 0.58 / 1.44 | 0.59 / 1.61 | +| rear (two runs) | host ← client | 0.65 / 1.89 | 1.28-1.30 / 4.47-6.01 | +| rear (two runs) | client ← host | 0.31 / 1.05 | 0.69-1.01 / 2.46-2.53 | The other players' cars are still drawn in the past (the maintainer's choice: interpolated from the host's states), and a client's own car now runs ahead of the host's by its inputs' trip and the host's margin, so the -distance between screens is about what it was. Where one car shunts another -the client's own car now shows where its prediction put it and the host -corrects it (below), which is the one view that grew. +distance between screens is about what it was. A client's view of another +client grew a little (that car's states now come through the host, a trip +older than its own snapshots were). Where one car shunts another the +client's own car shows where its prediction put it and the host corrects +it (below): that view grew most. Collisions between players (host authority: the host's collisions are the ones that happen; the client's are its prediction of them): @@ -166,10 +178,10 @@ ones that happen; the client's are its prediction of them): | Scenario | 0.3.1: one screen only | host authority: host's collisions the client predicted / client predictions the host did not have | | --- | --- | --- | | ram | 1 of 4 | 18 of 18 / 0 of 18 | -| ram, 150 ms, 5% | 2 of 8 | 11 of 11 / 0 of 11 | -| ram, 3 players | 1 of 9, 1 of 4 | 4 of 4 and 2 of 4 / 0 and 2 | -| ram, shared traffic | 0 of 3 | 15 of 15 / 0 of 14 | -| rear | 13 of 21 (host), 6 of 15 (client) | 4 of 4 / 5 of 9 | +| ram, 150 ms, 5% | 2 of 8 | 18 of 18 / 0 of 18 | +| ram, 3 players | 1 of 9, 1 of 4 | 7 of 7, 6 of 7, 5 of 5 (host and client 1, host and client 2, the clients) / 1 of 8, 1 of 7, 0 of 5 | +| ram, shared traffic | 0 of 3 | 6 of 6 / 0 of 6 | +| rear (two runs) | 13 of 21 (host), 6 of 15 (client) | 12 of 12, 4 of 4 / 10 of 22, 4 of 8 | Every collision now happens once, on the host, with both cars giving way by their masses, and every machine shows its outcome: a client draws its own @@ -178,35 +190,42 @@ client did not predict reaches its car as a correction a round trip later; one it predicted that the host did not have (the other car was elsewhere by then: shunting from behind against its past image) is corrected away. -Jumps over 1 m in a frame: none in 0.3.1 and none after except one real -teleport (the harsh run: the client's car fell through the city at the foot -of the hill, about z = 255, and the fall rule reset it on both machines at -the same sample) and one 4.2 m correction drawn at once (3 players). +Jumps over 1 m in a frame: none in 0.3.1; after, none for any other +player's car, and none for a client's own car except in the first rear +run: 81 frames in two spells of under a second, where the client's car, +corrected to the host's place right behind the host's car, sat 2 m inside +that car's past image and was pushed out of it every sample, shaking from +side to side by up to 1.5 m (6 of those were corrections over 4 m, drawn at +once; finding O1). An earlier build's harsh run had one real teleport: the +client's car fell through the city at the foot of the hill (about +z = 255) and the fall rule reset it on both machines at the same sample. Corrections of a client's own car (host authority only): | Scenario | a minute | median | 99th pct. | over 10 cm | over 1 m | samples run again | | --- | --- | --- | --- | --- | --- | --- | | chase (no contact) | 0 | | | 0 | 0 | | -| ram | 262 | 6.9 cm | 0.81 m | 137 | 1 | 12 | -| ram, 150 ms, 5% | 227 | 2.3 cm | 2.25 m | 86 | 9 | 24 | -| ram, 3 players | 193-331 | 2.5-2.9 cm | 1.1-1.3 m | 59-115 | 6-7 | 12 | -| ram, shared traffic | 257 | 3.4 cm | 0.82 m | 97 | 2 | 12 | -| rear | 543 | 6.9 cm | 2.15 m | 256 | 29 | 13 | +| ram | 252 | 5.2 cm | 0.82 m | 121 | 3 | 11 | +| ram, 150 ms, 5% | 254 | 10.4 cm | 1.49 m | 150 | 16 | 24 | +| ram, 3 players | 298-312 | 4.9-8.7 cm | 1.2-1.6 m | 126-167 | 5-12 | 12-13 | +| ram, shared traffic | 182 | 1.2 cm | 0.86 m | 52 | 0 | 13 | +| rear (two runs) | 786, 423 | 20, 7.8 cm | 3.8, 3.2 m | 666, 232 | 187, 32 | 13 | A car that touches nothing is never corrected: the host and the client run the same code on the same inputs from the same state (chase: 75 s without one). Corrections come in bursts around contacts with another player's car (and with props, which each machine still runs itself until the props work -lands); most are millimetres (the state after a correction is the host's +lands); half are under 1-10 cm (the state after a correction is the host's body and main parts, not every last field), and the drawing eases each one away over about 60 ms, so the client's own car showed no jump over 1 m in -any run but one. Running the samples again cost at most 12.8 ms a second -and 2.5 ms in a frame (shunting, 13 samples a correction); it is bounded at -120 samples. +any run but the one above. Running the samples again cost at most 12.8 ms +a second and 2.5 ms in a frame over every run (shunting, 13 samples a +correction); it is bounded at 120 samples. -The host never ran short of a client's inputs in these runs (0 repeated or -coasted samples); the client ran at 1.000 speed throughout. +The host ran short of a client's inputs once in these runs (one sample +repeated, in the rear run on the loaded machine); the client ran at 1.000 +speed except for spells at 0.994 in the rear runs (the host holding more +than three of its inputs). ### Builds that round differently @@ -229,7 +248,8 @@ coasted samples); the client ran at 1.000 speed throughout. the host will have them when it runs this machine's sample (extrapolated from the newest states by the samples not yet acknowledged less half the round trip) instead of in the past; `=ghost` keeps the client's car from -touching them at all: +touching them at all (these runs with the build before c367288 and the +merge, so the default row is that build's): | | rear: corrections over 1 m | rear: other car's jumps over 1 m a minute | ram: corrections over 1 m | ram: other car's jumps over 1 m a minute | | --- | --- | --- | --- | --- | @@ -274,12 +294,13 @@ into each other on screen. The default stays as the maintainer chose. | C3 | minor (parity) | `CopsAndRobbers::tickLimits` | Every machine checked the limits (sync review S6). | fixed (276e508) | | C4 | visible | `HostInputQueue` (new) | A client that loaded before the host started seconds behind its car. | fixed (127ac41) | | C5 | visible | `CarPrediction::acknowledge` (new) | Replays against the other cars' newest places made shunting corrections swing by up to 4 m. | fixed (19e8271) | -| O1 | visible | the prediction | Corrections of up to 2-3 m while shunting another player's car, which the client sees in the past. | open: inherent to drawing the others in the past and predicting one's own car; the experiment above trades it for the other cars jumping | +| O1 | visible | the prediction | Corrections of up to 2-3 m while shunting another player's car, which the client sees in the past. At worst (one rear run of six) a correction puts the client's car where the host has it, right behind the other car, which is inside that car's past image: the car is pushed out every sample and shakes from side to side by up to 1.5 m for under a second. | open: inherent to drawing the others in the past and predicting one's own car; the experiment above trades it for the other cars jumping. Narrower ways out: let a corrected car pass through another player's image until they part, or limit the push a car takes from an image in a sample | | O2 | visible | props | Each machine runs its own props, so a car knocking them is corrected (the props work makes them the host's). | for the props agent | | O3 | minor | rules | Checkpoints, laps, finishes and Cops and Robbers' gold are still decided on each player's own machine and relayed (a client could claim them); only the limits are the host's. | open: the host would run each car's gates (`game::session::playerGateHit` on its simulated cars, the laps and the finish) and Cops and Robbers' `CopsAndRobbers::update` over every car (it has every car and every collision now), and send the results; the clients would show them | | O4 | minor | commands | The host checks a client's resets only against the city's box and four a second, not against the race's checkpoints and the water. | open (needs the rules on the host) | | O5 | minor | `CarStatesMsg` | The own car's state is 270 bytes at 20 Hz to every client (78 KB/s for the host with eight players). | open: it could go only when it changed beyond the tolerance | | O6 | minor | MSVC against GCC | Not testable here; the noise test suggests corrections in crashes, eased away. | open | +| O7 | minor | replays | The shared traffic's cars still on their rails are held where the frame placed them while a client runs its samples again. | open: `TrafficClient::poseAt` could put each where its sample met it | ## Reproducing diff --git a/src/app/RaceScreen.cpp b/src/app/RaceScreen.cpp index 942373e..7aaa246 100644 --- a/src/app/RaceScreen.cpp +++ b/src/app/RaceScreen.cpp @@ -3773,11 +3773,10 @@ class RaceScreen final : public Screen { m_player->sim().onImpactCallback = [this](const phys::CarImpact& impact) { playerImpact(impact); }; } - // vehCarDamage::ApplyImpact for the player's car: AudImpact (the impact - // sounds), the damage effects, and the game's impact callback - // (mmPlayer::ImpactCallback), which counts the hits. // OPENMM2_NET_TRACE: every player's car as this frame draws it, for the - // divergence between machines (netprobe syncreport). + // divergence between machines (netprobe syncreport). The own car's + // velocity is its body's frame velocity, which includes the sample's + // pushes (a contact's push-out shows as speed). void traceNetDrawn(Context& ctx) { if (!multiplayer(ctx) || !ctx.netGame->tracing()) return; @@ -3789,6 +3788,9 @@ class RaceScreen final : public Screen { ctx.netGame->traceDrawn(rc.id, false, *drawn, rc.velocity); } + // vehCarDamage::ApplyImpact for the player's car: AudImpact (the impact + // sounds), the damage effects, and the game's impact callback + // (mmPlayer::ImpactCallback), which counts the hits. void playerImpact(const phys::CarImpact& impact) { // OpenMM2: a network client running its samples again has had them. if (m_netReplaying) From 7766963e50b163f3b0eb44b209636730fcbe3653 Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Fri, 9 Oct 2026 15:26:56 -0700 Subject: [PATCH 049/129] Test that a replay knocks and moves no prop for real A car run back through a client's props as a correction runs it, past the host's pieces, the client's own predicted ones and standing props: every prop keeps its state, place and motion. Co-Authored-By: Claude Opus 5.5 (1M context) --- tests/game/test_prop_sync.cpp | 55 +++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/tests/game/test_prop_sync.cpp b/tests/game/test_prop_sync.cpp index db73134..6df0167 100644 --- a/tests/game/test_prop_sync.cpp +++ b/tests/game/test_prop_sync.cpp @@ -442,6 +442,61 @@ TEST(PropSync, ReplayJustAfterAKnockEndsAsTheRealSamples) { client.world.remove(&car.body); } +// A replay knocks and moves no prop for real: the standing props stand, the +// pieces and the host's mirrors stay where they are, with their motion. +TEST(PropSync, AReplayMovesNoPropForReal) { + Race r; + Car host({0, 1, 3.0f}, {0, 0, -10}); // the host knocks prop 0 + r.host.world.add(&host.body); + Car mine({6, 1, 4.5f}, {0, 0, -10}); // the client's own car knocks prop 1 + r.clientCar = &mine.body; + r.client.world.add(&mine.body); + r.run(0.6); + r.host.world.remove(&host.body); + ASSERT_FALSE(r.client.set.standing(1)); + struct Seen { + BangerSet::State state; + Vec3 at; + int active; + }; + auto seen = [&] { + std::vector out; + for (std::size_t i = 0; i < r.client.set.instances().size(); ++i) { + const auto& inst = r.client.set.instances()[i]; + out.push_back({inst.state, inst.matrix.m3, inst.active}); + } + return out; + }; + const auto before = seen(); + std::vector bodies; + for (std::size_t i = 0; i < r.client.set.instances().size(); ++i) + if (const phys::Body* b = r.client.set.body(i)) + bodies.push_back(b->ics.linearVelocity); + // The car run back through the props, as a correction would. + Car again({0, 1, 4.5f}, {3, 0, -12}); + r.client.world.add(&again.body); + phys::Body* replayed[] = {&again.body}; + r.client.world.beginReplay(); + for (int i = 0; i < 40; ++i) + r.client.world.replaySample(replayed, kDt); + r.client.world.remove(&again.body); + const auto after = seen(); + ASSERT_EQ(before.size(), after.size()); + for (std::size_t i = 0; i < before.size(); ++i) { + EXPECT_EQ(before[i].state, after[i].state) << i; + EXPECT_EQ(before[i].active, after[i].active) << i; + EXPECT_LT(before[i].at.dist(after[i].at), 1e-6f) << i; + } + std::size_t k = 0; + for (std::size_t i = 0; i < r.client.set.instances().size(); ++i) { + if (const phys::Body* b = r.client.set.body(i)) { + EXPECT_LT(b->ics.linearVelocity.dist(bodies[k++]), 1e-6f) << i; + } + } + EXPECT_TRUE(r.client.set.standing(2)); + EXPECT_TRUE(r.client.set.standing(3)); +} + TEST(PropSync, ReplayedCarMeetsAPropAsARealSampleDoes) { TempBangers files; bangers::BangerDataLibrary lib(files.vfs); From f1f3b56e93494063b1f9695f35a7a10bc1ae571c Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Fri, 9 Oct 2026 15:34:55 -0700 Subject: [PATCH 050/129] Let a client's predicted pieces knock other props only while young A client's own simulated piece knocked a parking meter 3 s after its car had hit a street light; the host's piece of the same knock had gone elsewhere by then, and the meter stood up again 2 s later. A piece this machine simulates now knocks the placed props it meets for its first 0.5 s only, while it is still close to the host's (chain knocks the host makes come with its knocks as always). Co-Authored-By: Claude Opus 5.5 (1M context) --- src/game/bangers/BangerSet.cpp | 15 +++++++++++---- src/game/bangers/BangerSet.h | 3 +++ 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/src/game/bangers/BangerSet.cpp b/src/game/bangers/BangerSet.cpp index 06e3928..4709f93 100644 --- a/src/game/bangers/BangerSet.cpp +++ b/src/game/bangers/BangerSet.cpp @@ -1195,10 +1195,17 @@ bool BangerSet::acceptsFrom(std::size_t i, const phys::Instance& other) const { if (m_localToucher && m_localToucher(other)) return true; // The props this machine simulates (its predictions) go on knocking - // the placed props as they would; the host's pieces move only for this - // machine's car (two of them lying together would otherwise keep each - // other moving here while the host's rest). - return !m_instances[i].mirror && isActiveBody(&other); + // the placed props as they would while they are young (later the + // simulation here has drifted from the host's, and such a knock is + // mostly undone); the host's pieces move only for this machine's car + // (two of them lying together would otherwise keep each other moving + // here while the host's rest). + if (m_instances[i].mirror) + return false; + for (const auto& a : m_active) + if (&a->body == &other) + return a->age <= kPredictedChainSeconds; + return false; } void BangerSet::breakPlaced(std::size_t i) { diff --git a/src/game/bangers/BangerSet.h b/src/game/bangers/BangerSet.h index efef342..faa58fd 100644 --- a/src/game/bangers/BangerSet.h +++ b/src/game/bangers/BangerSet.h @@ -231,6 +231,9 @@ class BangerSet final : public InstanceSource { bool replayGhost(std::size_t i) const; bool replayGhostPiece(std::size_t i) const; static constexpr double kGhostSeconds = 1.0; + // A client's own simulated prop knocks others for this long after it + // was set moving (a prediction of a prediction; inferred). + static constexpr float kPredictedChainSeconds = 0.5f; // A network client: only this machine's own car (`localToucher`) and the // props this set simulates itself may touch a prop (phys::Instance:: From b06bba62cf491d41609e1f6a04c929150951edf2 Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Fri, 9 Oct 2026 15:47:32 -0700 Subject: [PATCH 051/129] Document the client's chain knocks Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/bangers.md | 5 +++-- docs/multiplayer.md | 7 ++++--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/docs/bangers.md b/docs/bangers.md index fd4fb20..12e4a6e 100644 --- a/docs/bangers.md +++ b/docs/bangers.md @@ -198,8 +198,9 @@ needs, all of it inert in a single-player race: they were handed out (`generation`). * `takeKnocks` (with `recordKnocks(true)`): the placed props that broke loose (`dgUnhitBangerInstance::Impact`) and what hit them. -* A client (`setReplica`): only its own car and the props it simulates may - touch its props (`phys::Instance::acceptsContact`); `breakPlaced` takes a +* A client (`setReplica`): only its own car may touch its props, and the + props it simulates may knock a placed prop for their first 0.5 s + (`phys::Instance::acceptsContact`); `breakPlaced` takes a prop out of its room as Impact does but without a body; `restoreStanding` undoes a knock its car predicted that the host did not make (Reset for one prop); `showMirror` / `hideMirror` show the host's ring slots in instances diff --git a/docs/multiplayer.md b/docs/multiplayer.md index 17a3a17..04f3afb 100644 --- a/docs/multiplayer.md +++ b/docs/multiplayer.md @@ -922,9 +922,10 @@ them, and dent them, through single player's code. in the past (what the messages needed to arrive over the last 3 s plus a send interval, 50-500 ms, as the other players' cars): a knock when it shows that time, the ring in mirror slots interpolated on their velocities - (`net::SnapshotBuffer`). Only its own car (and the props it sets moving - itself) may touch its props (`phys::Instance::acceptsContact`): the other - cars pass through them, since the host decides what they do. Its car hits a + (`net::SnapshotBuffer`). Only its own car may touch its props + (`phys::Instance::acceptsContact`), and the pieces it simulates itself may + knock a standing prop for their first 0.5 s: the other cars pass through + them, since the host decides what they do. Its car hits a prop at once, as in single player (the impulse, the damage, the prop breaking loose): a prediction. When the host's knock comes (from its copy of the car, a playout delay later) the piece simulated here stands in for From c5c1b4cddb5ffaed8fd8491c9606190799396992 Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Fri, 9 Oct 2026 15:49:47 -0700 Subject: [PATCH 052/129] Let a client simulate the players' cars near its own with it The host now sends each client the other players' cars within 40 m of its car in full, with the input it last applied to them (protocol 9, net::NearCarState, at most three). The client runs those cars along with its own instead of placing them at their interpolated past: each host state puts them back to the host's state at the acknowledged sample and runs them again with its own car on that input, so a car shunted from behind meets the other where the host's does and both give way. The drawing blends what each state moves them by, and the change between the two ways of placing a car. OPENMM2_NET_OTHERS keeps the old placing for comparison. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/app/RaceScreen.cpp | 272 ++++++++++++++++++++++++++++++------ src/game/net/PlayerCars.cpp | 33 +++-- src/game/net/PlayerCars.h | 20 ++- src/net/PlayerCars.h | 30 +++- src/net/Protocol.h | 5 +- src/net/Session.cpp | 7 +- src/net/Session.h | 3 + 7 files changed, 311 insertions(+), 59 deletions(-) diff --git a/src/app/RaceScreen.cpp b/src/app/RaceScreen.cpp index 7aaa246..0dc80c8 100644 --- a/src/app/RaceScreen.cpp +++ b/src/app/RaceScreen.cpp @@ -749,7 +749,8 @@ class RaceScreen final : public Screen { continue; game::VehiclePose pose = rv.sim->pose(); stepBodies[id] = pose.body; - if (const auto it = m_remoteStepBodies.find(id); it != m_remoteStepBodies.end() && !rv.simulated) + if (const auto it = m_remoteStepBodies.find(id); + it != m_remoteStepBodies.end() && !rv.simulated && !rv.predicted) pose.body = it->second; const std::uint32_t resets = rv.sim->sim().resets; m_drawnPhys.record(drawnKey(Drawn::RemoteCar, id), pose, resets); @@ -790,6 +791,15 @@ class RaceScreen final : public Screen { for (const auto& [id, rv] : m_remotes) if (rv.simulated && rv.placed && rv.sim) m_remoteDrawn[id] = drawnPose(game::Drawn::RemoteCar, id, *rv.sim).body; + // A client: the other players' cars with what the host's states moved + // drawn away (the ones it simulates, between their last two samples). + for (auto& [id, rv] : m_remotes) { + if (rv.simulated || !rv.sim) + continue; + rv.blend.update(m_frameDt); + if (const auto base = remoteDrawnBase(id, rv)) + m_remoteDrawn[id] = rv.blend.apply(*base); + } if (!m_player) return; m_drawPose = drawnPose(game::Drawn::Player, 0, *m_player); @@ -3784,8 +3794,12 @@ class RaceScreen final : public Screen { ctx.netGame->traceDrawn(ctx.netGame->localId(), true, m_drawPose.body, m_player->sim().body.ics.frameVelocity); for (const auto& rc : m_remoteCars) - if (const auto drawn = netCarDrawn(rc.id)) - ctx.netGame->traceDrawn(rc.id, false, *drawn, rc.velocity); + if (const auto drawn = netCarDrawn(rc.id)) { + const auto it = m_remotes.find(rc.id); + const bool here = it != m_remotes.end() && it->second.predicted && it->second.sim; + ctx.netGame->traceDrawn(rc.id, false, *drawn, + here ? it->second.sim->sim().body.ics.frameVelocity : rc.velocity); + } } // vehCarDamage::ApplyImpact for the player's car: AudImpact (the impact @@ -4628,13 +4642,21 @@ class RaceScreen final : public Screen { m_netStateTime = static_cast(std::max(0.0, ctx.netGame->frameTime() - lagMs)); // Drawn where the rest of the scene is, a sample behind the frame // (game::StepHistory): alpha x step = the remainder. - m_remoteDrawn.clear(); + m_remoteInterp.clear(); const double stepMs = static_cast(phys::kFixedSampleStep) * 1000.0; const auto drawn = ahead ? ctx.netGame->remoteCarsAhead(stepMs, m_netLead) : ctx.netGame->remoteCars(stepMs); for (const auto& rc : drawn) if (rc.hasState) - m_remoteDrawn[rc.id] = rc.transform; + m_remoteInterp[rc.id] = rc.transform; + // A car simulated here keeps where it was drawn (updateDrawnPoses + // draws it again after the samples). + std::erase_if(m_remoteDrawn, [this](const auto& e) { + const auto it = m_remotes.find(e.first); + return it == m_remotes.end() || !it->second.predicted; + }); + for (const auto& [id, transform] : m_remoteInterp) + m_remoteDrawn.try_emplace(id, transform); // A player who quit the race takes its car out of it. std::erase_if(m_remoteCars, [this](const game::NetRemoteCar& c) { return m_netLeft.contains(c.id); }); std::vector present; @@ -4648,28 +4670,13 @@ class RaceScreen final : public Screen { 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). - Mat34 ics = rc.transform; - ics.m3 = rc.transform.m3 - rc.transform.transformDir(sim.centerOfGravity); - const bool jumped = sim.body.ics.matrix.m3.dist2(ics.m3) > 20.0f * 20.0f; - sim.body.place(ics); - sim.body.ics.linearVelocity = rc.velocity; - sim.body.ics.angularVelocity = rc.angularVelocity; - // Everything meets the car as a moving one (the local car, and - // the shared traffic and police): as a still wall, a car closing - // on it at 1 m/s while both did 30 m/s lost 12.6 m/s, a rear tap - // threw the chaser back and rubbing side by side dragged as a - // barrier would. - sim.body.kinematicMoves = true; - 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, others == "ghost" ? 0x0b : 0x1b); // mmNetObject::Update - if (auto* trailer = rv.sim->trailer()) { - trailer->body.declare(3, 0x1b); - if (jumped) - trailer->reset(); // respawned: hitched again behind it + if (rv.predicted) { + // Simulated here (predictNearCars): it runs on its input. + sim.body.declare(3, 0x1b); // mmNetObject::Update + sim.setWaterLevel(waterLevelAt(sim.modelMatrix().m3)); + } else { + placeNetCar(rv, rc); + sim.body.declare(3, others == "ghost" ? 0x0b : 0x1b); // mmNetObject::Update } updateNetCarDamage(ctx, game::DamageReplica::playerKey(rc.id), *rv.renderer, rv.fx.get(), sim, rv.sim->model().baseName, netCarDrawn(rc.id).value_or(rc.transform), rc.velocity, @@ -4679,6 +4686,85 @@ class RaceScreen final : public Screen { updateOwnNetDamage(ctx); } + // Client: another player's car placed at its interpolated state (`rc`), + // a kinematic body moving at its velocity. + void placeNetCar(RemoteVehicle& rv, const game::NetRemoteCar& rc) { + auto& sim = rv.sim->sim(); + // The snapshot is the model matrix; the body is at the centre of + // mass (vehCarSim::SetWorldMatrix's offset). + Mat34 ics = rc.transform; + ics.m3 = rc.transform.m3 - rc.transform.transformDir(sim.centerOfGravity); + const bool jumped = sim.body.ics.matrix.m3.dist2(ics.m3) > 20.0f * 20.0f; + sim.body.place(ics); + sim.body.ics.linearVelocity = rc.velocity; + sim.body.ics.angularVelocity = rc.angularVelocity; + // Everything meets the car as a moving one (the local car, and + // the shared traffic and police): as a still wall, a car closing + // on it at 1 m/s while both did 30 m/s lost 12.6 m/s, a rear tap + // threw the chaser back and rubbing side by side dragged as a + // barrier would. + sim.body.kinematicMoves = true; + sim.body.kinematicVelocity = rc.velocity; + sim.body.kinematicSpin = rc.angularVelocity; + sim.setInputs(rc.controls.throttle, rc.controls.brake, rc.controls.steering, rc.controls.handbrake); + if (auto* trailer = rv.sim->trailer()) { + trailer->body.declare(3, 0x1b); + if (jumped) + trailer->reset(); // respawned: hitched again behind it + } + } + + // Client: which other players' cars it simulates along with its own (the + // ones the host's newest state sent in full, `near`; none with + // OPENMM2_NET_OTHERS set, the experiments), switching each between that + // and its interpolated states; the ones simulated, as the + // reconciliation's companions. + void predictNearCars(const std::vector& near, + std::vector& companions) { + static const bool others = [] { + const char* v = std::getenv("OPENMM2_NET_OTHERS"); + return v && *v; + }(); + for (auto& [id, rv] : m_remotes) { + if (rv.simulated || !rv.sim) + continue; + const auto it = std::ranges::find(near, id, &net::NearCarState::id); + const auto rc = std::ranges::find(m_remoteCars, id, &game::NetRemoteCar::id); + const bool wanted = !others && it != near.end() && !rv.sim->trailer() && + rc != m_remoteCars.end() && rc->hasState && !m_netLeft.contains(id); + auto& sim = rv.sim->sim(); + if (wanted && !rv.predicted) { + // As the host simulates it (loadRemoteCar's `simulated`), but + // its damage stays the host's (updateNetCarDamage). + sim.body.kinematic = false; + sim.body.kinematicMoves = false; + sim.body.resetCollider(); + sim.options.player = true; + sim.ownRandom = true; + rv.driver.attach(*rv.sim); + rv.predicted = true; + } else if (!wanted && rv.predicted) { + sim.body.kinematic = true; + sim.body.resetCollider(); + sim.options.player = false; + sim.ownRandom = false; + rv.predicted = false; + if (rc != m_remoteCars.end() && rc->hasState) + placeNetCar(rv, *rc); + } + if (rv.predicted) { + rv.input = it->input; + companions.push_back({rv.sim.get(), &rv.driver, &it->state, it->input}); + } + } + } + bool predictedBody(const phys::Body* b) const { + for (const auto& [id, rv] : m_remotes) + if (rv.predicted && rv.sim && &rv.sim->sim().body == b) + return true; + return false; + } + // The cars of players no longer in the race leave it. void dropRemoteCars(const std::vector& present) { for (auto it = m_remotes.begin(); it != m_remotes.end();) { @@ -4893,9 +4979,14 @@ class RaceScreen final : public Screen { } return; } - // A client: where the bodies around its car (the other players' - // cars, the police, knocked traffic cars and props) stood for this - // sample: the samples run again meet them there. + // A client: the other players' cars it simulates take the input the + // host last applied to them. + for (auto& [id, rv] : m_remotes) + if (rv.predicted && rv.sim) + rv.driver.apply(*rv.sim, rv.input); + // Where the bodies around its car (the other players' cars, the + // police, knocked traffic cars and props) stood for this sample: the + // samples run again meet them there. m_bodyHistory.push_back({m_prediction.nextSeq(), bodyPoses()}); while (m_bodyHistory.size() > 240) m_bodyHistory.pop_front(); @@ -4963,12 +5054,12 @@ class RaceScreen final : public Screen { if (!multiplayer(ctx) || ctx.netGame->isHost() || !m_player || !m_world) return; const auto updates = ctx.netGame->takeOwnCarStates(); - const net::OwnCarState* newest = nullptr; + const net::Session::OwnCarUpdate* newest = nullptr; std::uint32_t ack = 0; for (const auto& u : updates) { m_netWaiting.push_back(u.waiting); if (u.hasOwn && u.ack > ack) { - newest = &u.own; + newest = &u; ack = u.ack; } updateNetCarLead(u.ack, u.time); @@ -4996,38 +5087,72 @@ class RaceScreen final : public Screen { m_netLead = m_netLead < 0.0 ? lead : m_netLead + (lead - m_netLead) * 0.1; } const game::VehiclePose before = drawnPose(game::Drawn::Player, 0, *m_player); + // Where the other players' cars are drawn now: the drawing blends + // from there to where the host's states put them. + std::map othersBefore; + std::set predictedBefore; + for (const auto& [id, rv] : m_remotes) { + if (!rv.sim || rv.simulated) + continue; + if (const auto drawn = remoteDrawnBase(id, rv)) + othersBefore[id] = rv.blend.apply(*drawn); + if (rv.predicted) + predictedBefore.insert(id); + } + std::vector companions; + predictNearCars(newest->near, companions); // The bodies the samples run again may move, as they stand now. std::vector now; for (const auto& h : m_bodyHistory) if (h.seq > ack) for (const auto& p : h.poses) if (std::ranges::none_of(now, [&](const BodyPose& q) { return q.body == p.body; }) && - m_world->contains(p.body)) + m_world->contains(p.body) && !predictedBody(p.body)) now.push_back(poseOf(*p.body)); const auto replayStart = std::chrono::steady_clock::now(); m_netReplaying = true; const auto c = m_prediction.acknowledge( - *m_player, m_netDriver, *m_world, ack, *newest, + *m_player, m_netDriver, *m_world, ack, newest->own, [this] { - // What the drawing blends from: the car before its last sample. + // What the drawing blends from: the cars before their last + // sample. const std::uint32_t resets = m_player->sim().resets; m_drawnPhys.record(game::drawnKey(game::Drawn::Player, 0), m_player->pose(), resets); if (m_player->trailer()) m_drawnPhys.record(game::drawnKey(game::Drawn::PlayerTrailer, 0), m_player->trailerPose(), resets); + for (const auto& [id, rv] : m_remotes) + if (rv.predicted && rv.sim) + m_drawnPhys.record(game::drawnKey(game::Drawn::RemoteCar, id), rv.sim->pose(), + rv.sim->sim().resets); }, [this](std::uint32_t seq) { for (const auto& h : m_bodyHistory) if (h.seq == seq) placeBodies(h.poses); - }); + }, + companions); placeBodies(now); m_netReplaying = false; + // The other players' cars: what the host's states moved the ones + // simulated here by, and a switch between simulating one and placing + // it at its states, drawn away (a switch more slowly). + for (auto& [id, rv] : m_remotes) { + const auto from = othersBefore.find(id); + const auto to = remoteDrawnBase(id, rv); + const bool switched = rv.predicted != predictedBefore.contains(id); + if (from == othersBefore.end() || !to || (!rv.predicted && !switched)) + continue; + rv.blend.halfLife = switched ? 0.15f : 0.08f; + rv.blend.snapDistance = 20.0f; + rv.blend.add(from->second, *to); + } const double replayMs = std::chrono::duration(std::chrono::steady_clock::now() - replayStart).count(); m_netReplayMs += replayMs; m_netReplayWorstMs = std::max(m_netReplayWorstMs, replayMs); - if (!c.corrected) + // Run again with the other cars, this one moves only where they meet. + if (!c.corrected && c.moved.mag() <= 0.003f) return; const game::VehiclePose after = drawnPose(game::Drawn::Player, 0, *m_player); m_correction.add(m_correction.apply(before.body), after.body); @@ -5038,9 +5163,10 @@ class RaceScreen final : public Screen { static const bool verbose = std::getenv("OPENMM2_DEBUG_NETCARS") != nullptr; if (verbose) log::info("netcars: correction at sample {} ({} run again): position {:.4f} m, " - "velocity {:.4f} m/s, rotation {:.5f}{}{}{}", + "velocity {:.4f} m/s, rotation {:.5f}{}{}{}{}; moved {:.4f} m", ack, c.replayed, c.positionError, c.velocityError, c.rotationError, - c.damage ? ", damage" : "", c.held ? ", held" : "", c.gear ? ", gear" : ""); + c.damage ? ", damage" : "", c.held ? ", held" : "", c.gear ? ", gear" : "", + c.corrected ? "" : ", only the other cars' states", c.moved.mag()); } // Client: the bodies within reach of its car as they stand, and put @@ -5064,13 +5190,13 @@ class RaceScreen final : public Screen { m_world->bodiesNear(m_player->sim().body.ics.matrix.m3, 40.0f, near); const phys::Trailer* trailer = m_player->trailer(); for (phys::Body* b : near) - if (b != &m_player->sim().body && (!trailer || b != &trailer->body)) + if (b != &m_player->sim().body && (!trailer || b != &trailer->body) && !predictedBody(b)) out.push_back(poseOf(*b)); return out; } void placeBodies(const std::vector& poses) { for (const auto& p : poses) { - if (!m_world->contains(p.body)) + if (!m_world->contains(p.body) || predictedBody(p.body)) continue; phys::Body& b = *p.body; b.ics.matrix = p.ics; @@ -5136,15 +5262,33 @@ class RaceScreen final : public Screen { } } + // Host: the other players' cars sent to a client in full + // (net::NearCarState): those within kNearEnter metres of its car, kept + // until kNearLeave, the nearest net::kMaxNearCars; not one towing a + // trailer (its trailer's state does not travel). + static constexpr float kNearEnter = 40.0f, kNearLeave = 50.0f; + // Host: each client's CarStates: the last input applied to its car and - // the car's state after it, and every other player's car. + // the car's state after it, every other player's car, and the ones near + // it in full. void sendNetCars(Context& ctx, std::uint64_t now) { m_netCarsSentAt = now; std::vector> cars; cars.emplace_back(ctx.netGame->localId(), game::carSnapshot(*m_player, m_netInput)); + struct Full { + std::uint8_t id = 0; + const game::SimVehicle* car = nullptr; + net::CarInputFrame input; + }; + std::vector full; + if (!m_player->trailer()) + full.push_back({ctx.netGame->localId(), m_player.get(), m_netInput}); for (const auto& [id, rv] : m_remotes) - if (rv.simulated && rv.placed && rv.sim) + if (rv.simulated && rv.placed && rv.sim) { cars.emplace_back(id, game::carSnapshot(*rv.sim, rv.input)); + if (!rv.sim->trailer()) + full.push_back({id, rv.sim.get(), rv.input}); + } for (auto& [id, rv] : m_remotes) { if (!rv.simulated || !rv.sim) continue; @@ -5158,6 +5302,30 @@ class RaceScreen final : public Screen { for (const auto& c : cars) if (c.first != id) msg.cars.push_back(c); + if (rv.placed) { + const Vec3 at = rv.sim->sim().body.ics.matrix.m3; + std::vector> near; + for (const auto& f : full) { + if (f.id == id) + continue; + const float d = std::sqrt(f.car->sim().body.ics.matrix.m3.dist2(at)); + if (d < (rv.nearIds.contains(f.id) ? kNearLeave : kNearEnter)) + near.emplace_back(d, &f); + } + std::ranges::sort(near, {}, &std::pair::first); + if (near.size() > net::kMaxNearCars) + near.resize(net::kMaxNearCars); + rv.nearIds.clear(); + for (const auto& [d, f] : near) { + rv.nearIds.insert(f->id); + net::NearCarState n; + n.id = f->id; + n.state = game::ownCarState(*f->car, 0); + n.input = f->input; + n.input.events = 0; + msg.near.push_back(std::move(n)); + } + } m_netStatesBytes += ctx.netGame->sendCarStates(id, msg); ++m_netStatesSent; } @@ -5344,6 +5512,14 @@ class RaceScreen final : public Screen { // Where a network player's car and a shared police car are drawn // (model matrices), when known. + // Client: another player's car as drawn before the blend: simulated + // here, between its last two samples; else at its interpolated state. + std::optional remoteDrawnBase(std::uint8_t id, const RemoteVehicle& rv) const { + if (rv.predicted && rv.sim) + return drawnPose(game::Drawn::RemoteCar, id, *rv.sim).body; + const auto it = m_remoteInterp.find(id); + return it != m_remoteInterp.end() ? std::optional(it->second) : std::nullopt; + } std::optional netCarDrawn(std::uint8_t id) const { const auto it = m_remoteDrawn.find(id); return it != m_remoteDrawn.end() ? std::optional(it->second) : std::nullopt; @@ -6039,6 +6215,15 @@ class RaceScreen final : public Screen { net::CarInputFrame input; // the last one applied std::uint32_t resets = 0; // the commands carried out std::uint32_t lastMoveSeq = 0; // the sample of the last command that moved it + std::set nearIds; // the other cars sent to its player in full + // Client (OpenMM2): a car near this machine's that the host sends in + // full (net::NearCarState) is simulated here along with this + // machine's car instead of placed at its interpolated states + // (reconcileNetCar, predictNearCars), on the input the host last + // applied to it (`input`); each new state, and the change between + // the two ways of placing it, is blended away in the drawing. + bool predicted = false; + game::CorrectionBlend blend; }; std::map m_remotes; // OpenMM2: the network cars' damage (game/net/DamageSync): this player's @@ -6049,6 +6234,7 @@ class RaceScreen final : public Screen { std::uint32_t m_netStateTime = 0; std::vector m_remoteCars; // this frame's sample (updateRemoteCars) std::map m_remoteDrawn; // and where they are drawn + std::map m_remoteInterp; // client: where their interpolated states put them std::map m_remoteStepBodies; // their bodies in the last sample (recordStepPoses) std::vector m_netEvents; // this frame's game events (takeNetEvents) std::set m_netLeft; // players who quit this race diff --git a/src/game/net/PlayerCars.cpp b/src/game/net/PlayerCars.cpp index 4433696..37eff1a 100644 --- a/src/game/net/PlayerCars.cpp +++ b/src/game/net/PlayerCars.cpp @@ -372,7 +372,8 @@ CarPrediction::Correction CarPrediction::acknowledge(SimVehicle& car, NetCarDriv phys::World& world, std::uint32_t ack, const net::OwnCarState& host, const std::function& beforeLast, - const std::function& beforeEach) { + const std::function& beforeEach, + std::span companions) { Correction out; if (ack <= m_acked) return out; // an older or repeated state @@ -401,39 +402,49 @@ CarPrediction::Correction CarPrediction::acknowledge(SimVehicle& car, NetCarDriv out.velocityError > m_options.velocityTolerance || rotation > m_options.rotationTolerance || out.damage || out.held || out.gear; const std::size_t later = m_history.size() - 1 - index; - if (differs && later > m_options.maxReplay) { + const bool replay = differs || !companions.empty(); + if (replay && later > m_options.maxReplay) { ++m_stats.unreplayable; m_history.erase(m_history.begin(), m_history.begin() + static_cast(index)); return out; } - if (differs) { + if (replay) { const Vec3 before = car.sim().modelMatrix().m3; restore(base, car, driver); - applyOwnCarState(car, host); - save(base, car, driver); - phys::Body* bodies[2] = {&car.sim().body, nullptr}; - std::size_t count = 1; + if (differs) { + applyOwnCarState(car, host); + save(base, car, driver); + } + std::vector bodies{&car.sim().body}; if (phys::Trailer* t = car.trailer()) - bodies[count++] = &t->body; + bodies.push_back(&t->body); + for (const Companion& c : companions) { + applyOwnCarState(*c.car, *c.state); + bodies.push_back(&c.car->sim().body); + } for (std::size_t k = index + 1; k < m_history.size(); ++k) { Entry& e = m_history[k]; for (const auto& c : e.commands) NetCarDriver::command(car, c); driver.apply(car, e.input); + for (const Companion& c : companions) + c.driver->apply(*c.car, c.input); if (beforeEach) beforeEach(e.seq); if (k + 1 == m_history.size() && beforeLast) beforeLast(); - world.replaySample(std::span(bodies, count), phys::kFixedSampleStep); + world.replaySample(bodies, phys::kFixedSampleStep); save(e, car, driver); ++out.replayed; } // The resets asked for the next sample stand again. for (const auto& c : m_pending) NetCarDriver::command(car, c); - out.corrected = true; + out.corrected = differs; + out.rebased = !companions.empty(); out.moved = car.sim().modelMatrix().m3 - before; - ++m_stats.corrections; + if (differs) + ++m_stats.corrections; m_stats.replayedSamples += static_cast(out.replayed); } m_history.erase(m_history.begin(), m_history.begin() + static_cast(index)); diff --git a/src/game/net/PlayerCars.h b/src/game/net/PlayerCars.h index 9084334..3899969 100644 --- a/src/game/net/PlayerCars.h +++ b/src/game/net/PlayerCars.h @@ -36,6 +36,7 @@ #include #include #include +#include #include namespace mm2::game { @@ -180,6 +181,17 @@ class CarPrediction { float rotationError = 0.0f; // the largest difference of the matrices' entries bool damage = false, held = false, gear = false; // which of these differed Vec3 moved; // where the car is now against where it was predicted + bool rebased = false; // other cars were put to the host's state with it (companions) + }; + // Another player's car the client simulates along with its own (the + // host sent it in full: net::NearCarState): put to the host's state at + // the acknowledged sample and run with it on the input the host last + // applied to it. + struct Companion { + SimVehicle* car = nullptr; + NetCarDriver* driver = nullptr; + const net::OwnCarState* state = nullptr; + net::CarInputFrame input; }; // The host's state after sample `ack`. Forgets what it acknowledged; when // the state differs from the prediction for that sample, puts the car @@ -187,10 +199,14 @@ class CarPrediction { // phys::World::replaySample), calling `beforeEach` with each sample's // number before it (the other players' cars put back where they stood // when it first ran) and `beforeLast` before the last one (the drawing - // keeps the car's pose before its last sample). + // keeps the car's pose before its last sample). With `companions` the + // samples are run again whether the car's state differed or not, every + // companion with it from the host's state (the car's own state at `ack` + // is the host's only when it differed). Correction acknowledge(SimVehicle& car, NetCarDriver& driver, phys::World& world, std::uint32_t ack, const net::OwnCarState& host, const std::function& beforeLast = {}, - const std::function& beforeEach = {}); + const std::function& beforeEach = {}, + std::span companions = {}); // Statistics since the start. struct Stats { diff --git a/src/net/PlayerCars.h b/src/net/PlayerCars.h index 3868b5a..4a0a748 100644 --- a/src/net/PlayerCars.h +++ b/src/net/PlayerCars.h @@ -14,7 +14,10 @@ // the host applied to that client's car and the car's // state after it (full precision: the client puts its car // back to it and runs its later inputs again), and every -// other player's car (VehicleSnapshot, drawn interpolated). +// other player's car (VehicleSnapshot, drawn interpolated); +// the few near the client's car also in full with the +// input last applied to them (protocol 9), which the +// client simulates along with its own. // // Both travel on the State channel (unreliable, unsequenced). @@ -275,6 +278,17 @@ bool serialize(S& s, OwnCarState& o) { return s.ok(); } +// Another player's car near the receiving client's, in full (OpenMM2, +// protocol 9): the client runs it along with its own car from this state on +// the input the host last applied to it, so that the two meet where they +// meet on the host instead of where the other was drawn a trip earlier. +struct NearCarState { + std::uint8_t id = 0; + OwnCarState state; + CarInputFrame input; // the last applied (its keys, applied once, left out) +}; +inline constexpr std::size_t kMaxNearCars = 3; + struct CarStatesMsg { static constexpr MsgType kType = MsgType::CarStates; std::uint32_t time = 0; // session ms of the host's sample the states belong to @@ -287,6 +301,8 @@ struct CarStatesMsg { OwnCarState own; // Every other player's car. std::vector> cars; + // The ones near the client's car (at most kMaxNearCars), in full. + std::vector near; }; inline constexpr std::int32_t kMaxReportedWaiting = 63; @@ -309,6 +325,18 @@ bool serialize(S& s, CarStatesMsg& m) { s.u8(id); serialize(s, state); } + auto nearCount = static_cast(std::min(m.near.size(), kMaxNearCars)); + s.ranged(nearCount, 0, static_cast(kMaxNearCars)); + if constexpr (S::kReading) + m.near.resize(static_cast(nearCount)); + for (std::int32_t i = 0; i < nearCount; ++i) { + NearCarState& c = m.near[static_cast(i)]; + s.u8(c.id); + serialize(s, c.state); + c.input.events = 0; // keys are applied once: never repeated + serialize(s, c.input, CarInputFrame{}); + c.input.events = 0; + } return s.ok(); } diff --git a/src/net/Protocol.h b/src/net/Protocol.h index 36e1342..b73f879 100644 --- a/src/net/Protocol.h +++ b/src/net/Protocol.h @@ -35,7 +35,10 @@ inline constexpr std::uint16_t kProtocolMagic = 0x4D32; // "M2" // 6: in AmbientState a rail car's acceleration, curvature and speed over the // ground (the clients predict the shared traffic to their cars' time), and // up to 160 cars; no TrafficHit events. -inline constexpr std::uint16_t kProtocolVersion = 6; +// 9: in CarStates the players' cars near the client's in full, with the input +// last applied to them (net/PlayerCars.h NearCarState). (7 and 8 are other +// changes' numbers.) +inline constexpr std::uint16_t kProtocolVersion = 9; inline constexpr std::uint32_t kConnectData = (std::uint32_t{kProtocolMagic} << 16) | kProtocolVersion; // ENet channels. diff --git a/src/net/Session.cpp b/src/net/Session.cpp index e6ad1e5..c7bd197 100644 --- a/src/net/Session.cpp +++ b/src/net/Session.cpp @@ -1114,7 +1114,12 @@ void Session::clientHandle(MsgType type, std::span data) { } if (m_ownCarStates.size() >= kMaxQueuedOwnCarStates) m_ownCarStates.erase(m_ownCarStates.begin()); - m_ownCarStates.push_back({m.time, m.ack, m.waiting, m.hasOwn, m.own, timeMs()}); + // Untrusted: this machine's own car or a car given twice is refused. + std::vector near; + for (const auto& c : m.near) + if (c.id != m_localId && std::ranges::count(m.near, c.id, &NearCarState::id) == 1) + near.push_back(c); + m_ownCarStates.push_back({m.time, m.ack, m.waiting, m.hasOwn, m.own, timeMs(), std::move(near)}); return; } case MsgType::GameEvent: { diff --git a/src/net/Session.h b/src/net/Session.h index c881188..78275f6 100644 --- a/src/net/Session.h +++ b/src/net/Session.h @@ -274,6 +274,9 @@ class Session { bool hasOwn = false; OwnCarState own; double arrival = 0.0; // session ms + // The other players' cars near this one, in full (none is this + // machine's own, none twice). + std::vector near; }; std::vector takeOwnCarStates() { return std::exchange(m_ownCarStates, {}); } static constexpr std::size_t kMaxQueuedOwnCarStates = 16; From 51c9f8978d11ab8c0b857545ab3fbc6fd537b2d9 Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Fri, 9 Oct 2026 15:53:03 -0700 Subject: [PATCH 053/129] Run a car's checkpoints through a tracker the host can share mmWaypoints' rules for one car (Update, ClearWaypoint, the bookkeeping of DisplayHUDMessage, SetCurrentGoals, GetClosestWaypoint, CycleCurrentWaypoint, ResetAllTags) move out of Session into game::session::WaypointTracker, unchanged: detect() is the gate test of Update, apply() what a hit does, and the steps it reports (cleared, the finish opening, a lap, the final checkpoint, done) come back in mmWaypoints::Update's order. Session shows them as before (the sounds, the split and lap times, the events), so the single-player and network races behave the same; a network race's host can now run the same rules for every player's car. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/game/session/Session.cpp | 245 ++++++--------------------------- src/game/session/Session.h | 32 +---- src/game/session/Waypoints.cpp | 227 ++++++++++++++++++++++++++++++ src/game/session/Waypoints.h | 88 ++++++++++++ 4 files changed, 364 insertions(+), 228 deletions(-) create mode 100644 src/game/session/Waypoints.cpp create mode 100644 src/game/session/Waypoints.h diff --git a/src/game/session/Session.cpp b/src/game/session/Session.cpp index 4b95a85..14254b6 100644 --- a/src/game/session/Session.cpp +++ b/src/game/session/Session.cpp @@ -209,7 +209,7 @@ bool Session::opponentActive(std::size_t index) const { return index < m_oppEnabled.size() && m_oppEnabled[index] != 0; } -Session::WaypointRule Session::rule() const { +WaypointRule Session::rule() const { switch (mode()) { case GameMode::Blitz: return WaypointRule::Blitz; case GameMode::Circuit: return WaypointRule::Circuit; @@ -275,8 +275,7 @@ void Session::beginEvent(int index) { m_lessonEvent = index; const LessonEvent* lesson = currentLesson(); m_checkpoints = lesson ? lesson->checkpoints : m_setup.checkpoints; - m_wp.singleVisible = lesson && lesson->singleCheckpoint; - resetWaypoints(); + m_wp.reset(m_checkpoints, rule(), m_setup.laps, lesson && lesson->singleCheckpoint); // A network Cops and Robbers game waits for its start (copsAndRobbersGo). const bool noCountdown = @@ -309,32 +308,6 @@ void Session::beginEvent(int index) { push(EventType::LessonEventStarted, index); } -void Session::resetWaypoints() { - // mmWaypoints::Reset. - const int n = static_cast(m_checkpoints.size()); - m_wp.cleared.assign(m_checkpoints.size(), 0); - m_wp.visible.assign(m_checkpoints.size(), m_wp.singleVisible ? 0 : 1); - m_wp.current = std::min(1, std::max(0, n - 1)); - m_wp.count = 1; - m_wp.lastCleared = 0; - m_wp.lap = 0; - m_wp.finished = false; - m_wp.stopped = false; - const WaypointRule r = rule(); - if (n == 0 || r == WaypointRule::None) - return; - if (r != WaypointRule::Circuit) { - m_wp.visible[0] = 0; - if (r == WaypointRule::CheckpointRace && n >= 3) - m_wp.visible[static_cast(n - 1)] = 0; // the finish opens later - m_wp.cleared[0] = 1; - } - if (m_wp.singleVisible) { - std::fill(m_wp.visible.begin(), m_wp.visible.end(), 0); - m_wp.visible[static_cast(m_wp.current)] = 1; - } -} - void Session::start() { m_started = true; speech(SpeechCue::PreRace); // mmGame::Reset @@ -535,86 +508,30 @@ void Session::go() { // --- Checkpoints (mmWaypoints::Update) --------------------------------------------- -void Session::setTarget(int index) { - // mmWaypoints::SetCurrentGoals. - const int n = static_cast(m_checkpoints.size()); - m_wp.current = index < 0 ? 0 : std::min(index, n - 1); -} - -void Session::closestTarget(const Vec3& pos) { - // mmWaypoints::GetClosestWaypoint: the nearest shown, uncleared one. - const int n = static_cast(m_checkpoints.size()); - int best = m_wp.current, found = 0; - float bestD = 1e9f; - for (int i = 1; i < n; ++i) { - const auto k = static_cast(i); - if (m_wp.cleared[k] || !m_wp.visible[k]) - continue; - ++found; - const float d = dist2(m_checkpoints[k].position, pos); - if (d < bestD && best != 0) { - bestD = d; - best = i; - } - } - setTarget(found ? best : m_wp.current); -} - void Session::cycleTarget(bool forward) { // mmSingleRace / mmMultiRace::UpdateGameInput: "Next Checkpoint" and // "Prev. Checkpoint" only for waypoint type 2 (Blitz and the crash // course ignore them). if (rule() == WaypointRule::CheckpointRace) - cycleCurrent(forward); -} - -void Session::cycleCurrent(bool forward) { - // mmWaypoints::CycleCurrentWaypoint (GetNextWaypoint / GetLastWaypoint). - // OpenMM2 also stops for fewer than three waypoints, where the original - // could index past the list. - const int n = static_cast(m_checkpoints.size()); - if (n < 3 || m_wp.finished) - return; - if (m_wp.count == n - 1) { - setTarget(n - 1); - return; - } - const int step = forward ? 1 : -1; - int i = m_wp.current; - for (int guard = 0;; ++guard) { - i = (i + step) % n; - if (i == 0 || i == n - 1) - i = forward ? 1 : n - 2; - if (!m_wp.cleared[static_cast(i)]) - break; - if (i == m_wp.current || guard > n) - return; - } - setTarget(i); + m_wp.cycleCurrent(forward); } -void Session::displayCleared(int index) { - // mmWaypoints::DisplayHUDMessage. - const auto k = static_cast(index); - m_wp.lastCleared = index; - m_wp.cleared[k] = 1; - m_wp.visible[k] = 0; - ++m_wp.count; +void Session::displayCleared(int index, int count) { + // mmWaypoints::DisplayHUDMessage (the waypoint was marked cleared). push(EventType::CheckpointCleared, index); - // mmWaypoints::DisplayHUDMessage's sound: "Waypoint" in the crash - // course; for any-order and in-order waypoints, except for the one that - // leaves only the finish; "Lastwaypoint" when a circuit lap is - // completed. + // Its sound: "Waypoint" in the crash course; for any-order and in-order + // waypoints, except for the one that leaves only the finish; + // "Lastwaypoint" when a circuit lap is completed. const int n = static_cast(m_checkpoints.size()); switch (rule()) { case WaypointRule::Circuit: - sound((m_wp.count - 1) % std::max(1, n) == 0 ? GameSound::LastWaypoint : GameSound::Waypoint); + sound((count - 1) % std::max(1, n) == 0 ? GameSound::LastWaypoint : GameSound::Waypoint); break; case WaypointRule::Blitz: sound(GameSound::Waypoint); break; case WaypointRule::CheckpointRace: case WaypointRule::AnyOrderEnd: case WaypointRule::InOrder: - if (mode() == GameMode::CrashCourse || m_wp.count != n - 1) + if (mode() == GameMode::CrashCourse || count != n - 1) sound(GameSound::Waypoint); break; case WaypointRule::None: break; @@ -627,121 +544,43 @@ void Session::displayCleared(int index) { } } -void Session::updateWaypoints(const PlayerState& player) { - const int n = static_cast(m_checkpoints.size()); - const WaypointRule r = rule(); - if (n < 2 || r == WaypointRule::None || m_wp.stopped) - return; - const Mat34& car = player.transform; - auto hit = [&](int i) { - const Checkpoint& cp = m_checkpoints[static_cast(i)]; - if (r == WaypointRule::AnyOrderEnd && cp.hitByRadius) - return radiusHit(cp, car.m3); - return playerGateHit(cp, car, player.inertiaBox); - }; - // mmWaypoints::ClearWaypoint: the first uncleared waypoint hit. - auto firstHit = [&]() { - for (int i = 0; i < n; ++i) - if (!m_wp.cleared[static_cast(i)] && hit(i)) - return i; - return -1; - }; - - switch (r) { - case WaypointRule::Blitz: { - const int idx = firstHit(); - if (idx >= 0 && !m_wp.finished) { - displayCleared(idx); - if (std::any_of(m_wp.cleared.begin(), m_wp.cleared.end(), [](char c) { return c == 0; })) { - closestTarget(car.m3); - if (m_wp.singleVisible) - m_wp.visible[static_cast(m_wp.current)] = 1; - } else { - m_wp.finished = true; // the last checkpoint ends a Blitz - } - } - break; - } - case WaypointRule::CheckpointRace: - case WaypointRule::AnyOrderEnd: { - const int idx = firstHit(); - if (idx < 0 || m_wp.finished) - break; - if (idx == n - 1 && m_wp.count == n - 1) { - m_wp.finished = true; - } else if (idx > 0 && idx < n - 1) { - displayCleared(idx); - if (r == WaypointRule::CheckpointRace) { - if (m_wp.count == n - 1) { - m_wp.visible[static_cast(n - 1)] = 1; - push(EventType::FinishActivated); - } - closestTarget(car.m3); - } else if (idx == m_wp.current) { - cycleCurrent(true); - } - if (m_wp.singleVisible) - m_wp.visible[static_cast(m_wp.current)] = 1; - if (m_wp.count == n - 1) { - m_wp.visible[static_cast(m_wp.current)] = 1; - push(EventType::FinalCheckpoint); - } - } - break; +void Session::lapCompleted(int lap) { + // mmWaypoints::Update when waypoint 0 completes a circuit lap. + const float lapTime = m_raceTime - m_lapStart; + m_lastLap = lapTime; + m_bestLap = m_bestLap > 0.0f ? std::min(m_bestLap, lapTime) : lapTime; + m_lapTimes.push_back(lapTime); + m_lapStart = m_raceTime; + push(EventType::LapCompleted, lap, lapTime); + if (lap != m_setup.laps) { + // mmHUD::PostLapTime: "Final lap!" or "Lap time" for 1 s with the + // time under it. + const bool final = lap == m_setup.laps - 1; + setMessage(final ? 62 : 63, final ? "Final lap!" : "Lap time", 1.0f, false); + setMessage2(formatTime(lapTime)); + if (final) + push(EventType::FinalLap); } - case WaypointRule::InOrder: - if (!m_wp.finished && hit(m_wp.current)) { - const int passed = m_wp.current; - m_wp.visible[static_cast(passed)] = 0; - ++m_wp.current; - displayCleared(passed); - if (m_wp.count == n) - m_wp.finished = true; - else if (m_wp.singleVisible) - m_wp.visible[static_cast(m_wp.current)] = 1; - if (m_wp.current >= n) - m_wp.current = n - 1; - } - break; - case WaypointRule::Circuit: - if (!m_wp.finished && hit(m_wp.current)) { - const int passed = m_wp.current; - m_wp.visible[static_cast(passed)] = 0; - displayCleared(passed); - if (passed == 0) { - ++m_wp.lap; - const float lapTime = m_raceTime - m_lapStart; - m_lastLap = lapTime; - m_bestLap = m_bestLap > 0.0f ? std::min(m_bestLap, lapTime) : lapTime; - m_lapTimes.push_back(lapTime); - m_lapStart = m_raceTime; - push(EventType::LapCompleted, m_wp.lap, lapTime); - if (m_wp.lap == m_setup.laps) { - m_wp.finished = true; - } else { - // mmHUD::PostLapTime: "Final lap!" or "Lap time" for 1 s - // with the time under it. - const bool final = m_wp.lap == m_setup.laps - 1; - setMessage(final ? 62 : 63, final ? "Final lap!" : "Lap time", 1.0f, false); - setMessage2(formatTime(lapTime)); - if (final) - push(EventType::FinalLap); - // mmWaypoints::ResetAllTags: every gate shows again. - std::fill(m_wp.cleared.begin(), m_wp.cleared.end(), 0); - std::fill(m_wp.visible.begin(), m_wp.visible.end(), m_wp.singleVisible ? 0 : 1); - } - } else if (passed == n - 1 && m_wp.lap == m_setup.laps - 1) { - push(EventType::FinalCheckpoint); - } - m_wp.current = passed + 1 == n ? 0 : passed + 1; - if (m_wp.singleVisible && !m_wp.finished) - m_wp.visible[static_cast(m_wp.current)] = 1; +} + +void Session::showWaypointSteps(const std::vector& steps) { + for (const WaypointStep& s : steps) { + switch (s.kind) { + case WaypointStep::Kind::Cleared: displayCleared(s.index, s.count); break; + case WaypointStep::Kind::FinishActivated: push(EventType::FinishActivated); break; + case WaypointStep::Kind::LapCompleted: lapCompleted(s.index); break; + case WaypointStep::Kind::FinalCheckpoint: push(EventType::FinalCheckpoint); break; + case WaypointStep::Kind::Finished: break; // the modes' UpdateGame reads it } - break; - case WaypointRule::None: break; } } +void Session::updateWaypoints(const PlayerState& player) { + std::vector steps; + m_wp.update(m_checkpoints, player.transform, player.inertiaBox, steps); + showWaypointSteps(steps); +} + // --- Opponents --------------------------------------------------------------------- void Session::updateOpponents(std::span opponents) { diff --git a/src/game/session/Session.h b/src/game/session/Session.h index 2367bcd..eb90dfd 100644 --- a/src/game/session/Session.h +++ b/src/game/session/Session.h @@ -25,6 +25,7 @@ #include "game/session/Gate.h" #include "game/session/RaceSetup.h" #include "game/session/Types.h" +#include "game/session/Waypoints.h" #include "vfs/Vfs.h" #include @@ -259,25 +260,6 @@ class Session { Mat34 respawnTransform() const { return m_respawn; } private: - // How the player's checkpoints work (mmWaypoints types). - enum class WaypointRule : std::uint8_t { - None, - Circuit, // 1: in order, waypoint 0 completes a lap - CheckpointRace,// 2: any order, then the finish - Blitz, // 3: any order, done when all are cleared - AnyOrderEnd, // 4: crash course: any order, the last one must come last - InOrder, // 5: crash course: strictly in order - }; - struct Waypoints { - std::vector cleared, visible; - int current = 1; // target - int count = 1; // waypoints passed, including the start - int lastCleared = 0; // respawn point - int lap = 0; - bool finished = false; - bool stopped = false; // no more hits (race over) - bool singleVisible = false; - }; struct Racer { int count = 1; // waypoints passed (the start counts) std::uint32_t mask = 1; // checkpoint race: waypoints passed @@ -302,7 +284,6 @@ class Session { void resetRace(); void beginEvent(int index); - void resetWaypoints(); void updateCountdown(float dt); void enableLessonOpponents(); void go(); @@ -311,10 +292,11 @@ class Session { std::span police); void tickMessage(float dt); void updateWaypoints(const PlayerState& player); - void displayCleared(int index); - void closestTarget(const Vec3& pos); - void cycleCurrent(bool forward); - void setTarget(int index); + // What the waypoints' rules did, on the HUD (mmWaypoints::Update's + // DisplayHUDMessage, mmHUD::ShowSplitTime and PostLapTime, the sounds). + void showWaypointSteps(const std::vector& steps); + void displayCleared(int index, int count); + void lapCompleted(int lap); void updateOpponents(std::span opponents); void updateRank(const PlayerState& player, std::span opponents); bool updateHazards(float dt, const PlayerState& player); @@ -348,7 +330,7 @@ class Session { SessionOptions m_options; std::vector m_checkpoints; - Waypoints m_wp; + WaypointTracker m_wp; std::vector m_opponents; std::vector m_oppEnabled; int m_finishers = 0; diff --git a/src/game/session/Waypoints.cpp b/src/game/session/Waypoints.cpp new file mode 100644 index 0000000..becb1a3 --- /dev/null +++ b/src/game/session/Waypoints.cpp @@ -0,0 +1,227 @@ +#include "game/session/Waypoints.h" + +#include "game/session/Gate.h" + +#include + +namespace mm2::game::session { + +void WaypointTracker::reset(std::span checkpoints, WaypointRule rule, int laps, + bool single) { + // mmWaypoints::Reset. + m_rule = rule; + m_laps = laps; + singleVisible = single; + const int n = static_cast(checkpoints.size()); + cleared.assign(checkpoints.size(), 0); + visible.assign(checkpoints.size(), singleVisible ? 0 : 1); + current = std::min(1, std::max(0, n - 1)); + count = 1; + lastCleared = 0; + lap = 0; + finished = false; + stopped = false; + if (n == 0 || rule == WaypointRule::None) + return; + if (rule != WaypointRule::Circuit) { + visible[0] = 0; + if (rule == WaypointRule::CheckpointRace && n >= 3) + visible[static_cast(n - 1)] = 0; // the finish opens later + cleared[0] = 1; + } + if (singleVisible) { + std::fill(visible.begin(), visible.end(), 0); + visible[static_cast(current)] = 1; + } +} + +void WaypointTracker::setTarget(int index) { + // mmWaypoints::SetCurrentGoals. + const int n = static_cast(cleared.size()); + current = index < 0 ? 0 : std::min(index, n - 1); +} + +void WaypointTracker::closestTarget(std::span checkpoints, const Vec3& pos) { + // mmWaypoints::GetClosestWaypoint: the nearest shown, uncleared one. + const int n = static_cast(std::min(checkpoints.size(), cleared.size())); + int best = current, found = 0; + float bestD = 1e9f; + for (int i = 1; i < n; ++i) { + const auto k = static_cast(i); + if (cleared[k] || !visible[k]) + continue; + ++found; + const float d = checkpoints[k].position.dist2(pos); + if (d < bestD && best != 0) { + bestD = d; + best = i; + } + } + setTarget(found ? best : current); +} + +void WaypointTracker::cycleCurrent(bool forward) { + // mmWaypoints::CycleCurrentWaypoint (GetNextWaypoint / GetLastWaypoint). + // OpenMM2 also stops for fewer than three waypoints, where the original + // could index past the list. + const int n = static_cast(cleared.size()); + if (n < 3 || finished) + return; + if (count == n - 1) { + setTarget(n - 1); + return; + } + const int step = forward ? 1 : -1; + int i = current; + for (int guard = 0;; ++guard) { + i = (i + step) % n; + if (i == 0 || i == n - 1) + i = forward ? 1 : n - 2; + if (!cleared[static_cast(i)]) + break; + if (i == current || guard > n) + return; + } + setTarget(i); +} + +void WaypointTracker::markCleared(int index, std::vector& steps) { + // mmWaypoints::DisplayHUDMessage's bookkeeping (the HUD is the caller's). + const auto k = static_cast(index); + lastCleared = index; + cleared[k] = 1; + visible[k] = 0; + ++count; + steps.push_back({WaypointStep::Kind::Cleared, index, count}); +} + +int WaypointTracker::detect(std::span checkpoints, const Mat34& car, + const Vec3& inertiaBox) const { + const int n = static_cast(std::min(checkpoints.size(), cleared.size())); + if (n < 2 || m_rule == WaypointRule::None || stopped || finished) + return -1; + auto hit = [&](int i) { + const Checkpoint& cp = checkpoints[static_cast(i)]; + if (m_rule == WaypointRule::AnyOrderEnd && cp.hitByRadius) + return radiusHit(cp, car.m3); + return playerGateHit(cp, car, inertiaBox); + }; + switch (m_rule) { + case WaypointRule::Blitz: + case WaypointRule::CheckpointRace: + case WaypointRule::AnyOrderEnd: + // mmWaypoints::ClearWaypoint: the first uncleared waypoint hit. + for (int i = 0; i < n; ++i) + if (!cleared[static_cast(i)] && hit(i)) + return i; + return -1; + case WaypointRule::InOrder: + case WaypointRule::Circuit: return current >= 0 && current < n && hit(current) ? current : -1; + case WaypointRule::None: break; + } + return -1; +} + +bool WaypointTracker::apply(std::span checkpoints, int index, const Vec3& position, + std::vector& steps) { + const int n = static_cast(std::min(checkpoints.size(), cleared.size())); + if (n < 2 || m_rule == WaypointRule::None || stopped || finished || index < 0 || index >= n) + return false; + const auto k = static_cast(index); + switch (m_rule) { + case WaypointRule::Blitz: + if (cleared[k]) + return false; + markCleared(index, steps); + if (std::any_of(cleared.begin(), cleared.end(), [](char c) { return c == 0; })) { + closestTarget(checkpoints, position); + if (singleVisible) + visible[static_cast(current)] = 1; + } else { + finished = true; // the last checkpoint ends a Blitz + steps.push_back({WaypointStep::Kind::Finished}); + } + return true; + case WaypointRule::CheckpointRace: + case WaypointRule::AnyOrderEnd: + if (cleared[k]) + return false; + if (index == n - 1 && count == n - 1) { + finished = true; + steps.push_back({WaypointStep::Kind::Finished}); + return true; + } + if (index <= 0 || index >= n - 1) + return false; + markCleared(index, steps); + if (m_rule == WaypointRule::CheckpointRace) { + if (count == n - 1) { + visible[static_cast(n - 1)] = 1; + steps.push_back({WaypointStep::Kind::FinishActivated}); + } + closestTarget(checkpoints, position); + } else if (index == current) { + cycleCurrent(true); + } + if (singleVisible) + visible[static_cast(current)] = 1; + if (count == n - 1) { + visible[static_cast(current)] = 1; + steps.push_back({WaypointStep::Kind::FinalCheckpoint}); + } + return true; + case WaypointRule::InOrder: { + if (index != current) + return false; + const int passed = current; + visible[static_cast(passed)] = 0; + ++current; + markCleared(passed, steps); + if (count == n) { + finished = true; + steps.push_back({WaypointStep::Kind::Finished}); + } else if (singleVisible) { + visible[static_cast(current)] = 1; + } + if (current >= n) + current = n - 1; + return true; + } + case WaypointRule::Circuit: { + if (index != current) + return false; + const int passed = current; + visible[static_cast(passed)] = 0; + markCleared(passed, steps); + if (passed == 0) { + ++lap; + steps.push_back({WaypointStep::Kind::LapCompleted, lap}); + if (lap == m_laps) { + finished = true; + steps.push_back({WaypointStep::Kind::Finished}); + } else { + // mmWaypoints::ResetAllTags: every gate shows again. + std::fill(cleared.begin(), cleared.end(), 0); + std::fill(visible.begin(), visible.end(), singleVisible ? 0 : 1); + } + } else if (passed == n - 1 && lap == m_laps - 1) { + steps.push_back({WaypointStep::Kind::FinalCheckpoint}); + } + current = passed + 1 == n ? 0 : passed + 1; + if (singleVisible && !finished) + visible[static_cast(current)] = 1; + return true; + } + case WaypointRule::None: break; + } + return false; +} + +void WaypointTracker::update(std::span checkpoints, const Mat34& car, const Vec3& inertiaBox, + std::vector& steps) { + const int index = detect(checkpoints, car, inertiaBox); + if (index >= 0) + apply(checkpoints, index, car.m3, steps); +} + +} // namespace mm2::game::session diff --git a/src/game/session/Waypoints.h b/src/game/session/Waypoints.h new file mode 100644 index 0000000..c4430b9 --- /dev/null +++ b/src/game/session/Waypoints.h @@ -0,0 +1,88 @@ +#pragma once + +// mmWaypoints' rules for one car: which checkpoints it has cleared, its +// target, its laps and when it is done (mmWaypoints::Update, ClearWaypoint, +// the bookkeeping of DisplayHUDMessage, SetCurrentGoals, GetClosestWaypoint, +// CycleCurrentWaypoint, ResetAllTags). Session runs one for the player and +// shows what it reports (sounds, split times, lap times); a network race's +// host runs one for every player's car (game::session::RaceReferee), so both +// apply the same rules. + +#include "game/session/Types.h" + +#include +#include +#include + +namespace mm2::game::session { + +// How a car's checkpoints work (mmWaypoints types). +enum class WaypointRule : std::uint8_t { + None, + Circuit, // 1: in order, waypoint 0 completes a lap + CheckpointRace, // 2: any order, then the finish + Blitz, // 3: any order, done when all are cleared + AnyOrderEnd, // 4: crash course: any order, the last one must come last + InOrder, // 5: crash course: strictly in order +}; + +// What a hit did, in the order mmWaypoints::Update does it. +struct WaypointStep { + enum class Kind : std::uint8_t { + Cleared, // `index` cleared (DisplayHUDMessage); `count` the waypoints passed after it + FinishActivated, // a checkpoint race's finish opens + LapCompleted, // `index` = the lap just completed (1-based) + FinalCheckpoint, // the last checkpoint before the finish + Finished, // the waypoints are done + }; + Kind kind{}; + int index = -1; + int count = 0; +}; + +class WaypointTracker { +public: + // mmWaypoints::Reset for these checkpoints ([0] the start), `laps` a + // circuit's laps; `singleVisible` shows one marker at a time (a crash + // course event's single checkpoint). + void reset(std::span checkpoints, WaypointRule rule, int laps, bool singleVisible); + + // The waypoint the car's gate test hits now (mmWaypoints::Update: the + // first uncleared one in the any-order rules, the target in the in-order + // ones), or -1 (none, or the waypoints are done or stopped). + int detect(std::span checkpoints, const Mat34& car, const Vec3& inertiaBox) const; + // What that hit does (the rest of mmWaypoints::Update), for a car at + // `position` (the any-order rules aim at the nearest one left). Returns + // false when the rule takes nothing from it now (a race's finish before + // the last checkpoint, a waypoint already cleared, not the target). + bool apply(std::span checkpoints, int index, const Vec3& position, + std::vector& steps); + // detect, then apply. + void update(std::span checkpoints, const Mat34& car, const Vec3& inertiaBox, + std::vector& steps); + + // mmWaypoints::SetCurrentGoals, GetClosestWaypoint and + // CycleCurrentWaypoint (GetNextWaypoint / GetLastWaypoint). + void setTarget(int index); + void closestTarget(std::span checkpoints, const Vec3& position); + void cycleCurrent(bool forward); + + WaypointRule rule() const { return m_rule; } + + std::vector cleared, visible; + int current = 1; // target + int count = 1; // waypoints passed, including the start + int lastCleared = 0; // respawn point + int lap = 0; + bool finished = false; + bool stopped = false; // no more hits (race over) + bool singleVisible = false; + +private: + void markCleared(int index, std::vector& steps); + + WaypointRule m_rule = WaypointRule::None; + int m_laps = 0; +}; + +} // namespace mm2::game::session From 6731eeafb0a3367ce526e11629b578554698f841 Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Fri, 9 Oct 2026 16:08:26 -0700 Subject: [PATCH 054/129] Decide a network race's rules on the host and correct the clients The host simulates every player's car, so it can run the rules MM2's machines each ran for their own car. game::session::RaceReferee runs mmWaypoints on every car after each physics sample (the same WaypointTracker), times each car from the sample its own input released it to the one it crossed the line in, and does the host's part of mmMultiRace / mmMultiCircuit / mmMultiBlitz: SortResults, the finish timeout from the first finish (SetTimeoutOn; 0x1fe makes everyone still racing not finish), the end once every player still in the race is counted (0x211), a Blitz car out of time and the host's own time running out, and mmGameMulti::UpdateScore's place and icon numbers for every player's machine. Session gains a net-rules mode: it still predicts the player's checkpoints, stamped with the car's sample, and applyNetProgress takes the host's list of the car's hits: the predicted ones it has are confirmed, the ones it has not counted kNetHitMargin samples later are taken back silently, and the ones only the host has are shown then. The finish (the host's time and the client's own 0x1f7 line), the standings, the timeout and the end are the host's. The icon number's own-car term now measures both cars to the player's own target, as UpdateScore does. CopsAndRobbers gains updateHost (mmMultiCR's per-car rules for every car, the host's answers to them) and, for the other machines, a predicted pickup that the host's decision or state confirms or undoes. net/RulesState.h is the host's message (decisions numbered, the state as it stands) and game::NetRules carries it; neither is wired in yet. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/game/net/NetRules.cpp | 377 ++++++++++++++++++++++++++++ src/game/net/NetRules.h | 141 +++++++++++ src/game/session/CopsAndRobbers.cpp | 224 +++++++++++++++++ src/game/session/CopsAndRobbers.h | 63 ++++- src/game/session/RaceReferee.cpp | 222 ++++++++++++++++ src/game/session/RaceReferee.h | 129 ++++++++++ src/game/session/Session.cpp | 233 ++++++++++++++++- src/game/session/Session.h | 58 +++++ src/net/RulesState.h | 211 ++++++++++++++++ 9 files changed, 1647 insertions(+), 11 deletions(-) create mode 100644 src/game/net/NetRules.cpp create mode 100644 src/game/net/NetRules.h create mode 100644 src/game/session/RaceReferee.cpp create mode 100644 src/game/session/RaceReferee.h create mode 100644 src/net/RulesState.h diff --git a/src/game/net/NetRules.cpp b/src/game/net/NetRules.cpp new file mode 100644 index 0000000..751fde4 --- /dev/null +++ b/src/game/net/NetRules.cpp @@ -0,0 +1,377 @@ +#include "game/net/NetRules.h" + +#include "core/Log.h" + +#include +#include + +namespace mm2::game { +namespace { + +using net::RuleDecision; +using net::RuleDecisionMsg; +using CrMessage = session::CopsAndRobbers::Message; + +std::uint32_t toMs(float seconds) { + if (!(seconds < session::RaceReferee::kDnf)) + return net::kRuleDnfMs; + return std::min(net::kRuleDnfMs - 1, static_cast(std::lround(std::max(0.0f, seconds) * 1000.0f))); +} + +float toSeconds(std::uint32_t ms) { + return ms >= net::kRuleDnfMs ? session::Session::kNetDnf : static_cast(ms) * 0.001f; +} + +std::uint8_t toPlayer(int car) { + return car >= 0 && car < net::kInvalidPlayerId ? static_cast(car) : net::kInvalidPlayerId; +} + +RuleDecisionMsg fromCops(const CrMessage& m) { + RuleDecisionMsg d; + d.player = toPlayer(m.car); + switch (m.type) { + case CrMessage::Type::PickupRequest: + case CrMessage::Type::GoldTaken: d.type = RuleDecision::GoldTaken; break; + case CrMessage::Type::GoldDropped: + d.type = RuleDecision::GoldDropped; + d.position = m.position; + d.flag = m.knocked; + break; + case CrMessage::Type::GoldDelivered: d.type = RuleDecision::GoldDelivered; break; + case CrMessage::Type::NewSet: + d.type = RuleDecision::NewSet; + d.gold = m.set.gold; + d.bank = m.set.bank; + d.hideout = m.set.hideout; + break; + case CrMessage::Type::Limit: + d.type = RuleDecision::Limit; + d.flag = m.pointLimit; + d.value = std::clamp(m.value, 0, net::kMaxRuleScore); + break; + } + return d; +} + +CrMessage toCops(const RuleDecisionMsg& d) { + CrMessage m; + m.car = d.player == net::kInvalidPlayerId ? -1 : d.player; + switch (d.type) { + case RuleDecision::GoldTaken: m.type = CrMessage::Type::GoldTaken; break; + case RuleDecision::GoldDropped: + m.type = CrMessage::Type::GoldDropped; + m.position = d.position; + m.knocked = d.flag; + break; + case RuleDecision::GoldDelivered: m.type = CrMessage::Type::GoldDelivered; break; + case RuleDecision::NewSet: + m.type = CrMessage::Type::NewSet; + m.set = {d.bank, d.gold, d.hideout}; + break; + default: + m.type = CrMessage::Type::Limit; + m.pointLimit = d.flag; + m.value = d.value; + break; + } + return m; +} + +} // namespace + +bool hostAcceptsGameEvent(std::uint8_t from, std::uint16_t type) { + if (from == net::kHostPlayerId) + return true; + // MM2's peers sent these about their own car (SendFinishReq, the waypoint + // count, mmMultiCR's 0x25e, 0x259, 600, 0x261, SendLimitReached); under + // the host's authority it decides them from its own simulation. + using T = net::GameEventType; + switch (static_cast(type)) { + case T::CheckpointReached: + case T::LapCompleted: + case T::RaceFinished: + case T::GoldPickedUp: + case T::GoldDropped: + case T::GoldDelivered: return false; + default: break; + } + constexpr auto kCustom = static_cast(T::Custom); + // Cops and Robbers' pickup request, places and limit (0x8001-0x8003) + // and the rules message. + return !(type >= kCustom + 1 && type <= kCustom + 3) && type != net::kRulesEvent; +} + +void NetRules::begin(const Setup& setup) { + *this = NetRules{}; + m_setup = setup; + if (!m_setup.host) + return; + if (session::Session* s = m_setup.session) { + session::RaceReferee::Config c; + c.mode = s->mode(); + c.checkpoints = s->checkpoints(); + c.laps = s->laps(); + c.timeLimit = s->mode() == GameMode::Blitz ? s->setup().timeLimit : 0.0f; + c.host = m_setup.self; + c.sampleSeconds = m_setup.sampleSeconds; + m_referee = std::make_unique(std::move(c)); + for (const std::uint8_t p : m_setup.players) + m_referee->addPlayer(p); + } + for (const std::uint8_t p : m_setup.players) + if (p != m_setup.self) + m_clients[p] = {}; +} + +std::string NetRules::nameOf(std::uint8_t id) const { + const auto it = m_names.find(id); + return it != m_names.end() ? it->second : std::format("Player {}", id); +} + +void NetRules::hostSample(std::uint8_t id, std::uint32_t seq, const Mat34& car, const Vec3& inertiaBox, bool held) { + if (!m_setup.host || m_left.contains(id)) + return; + m_carSamples[id] = seq; + if (m_referee) + m_referee->sample(id, seq, car, inertiaBox, held); +} + +void NetRules::playerLeft(std::uint8_t id) { + if (!m_left.insert(id).second) + return; + m_clients.erase(id); + m_icons.erase(id); + if (m_referee) + m_referee->removePlayer(id); +} + +void NetRules::hostDecided(const std::vector& decisions) { + if (!m_setup.host) + return; + for (const auto& m : decisions) + decide(fromCops(m)); +} + +void NetRules::decide(RuleDecisionMsg d) { + d.seq = static_cast(m_log.size()) + 1; + m_log.push_back(d); + apply(d); +} + +void NetRules::apply(const RuleDecisionMsg& d) { + ++m_stats.decisions; + session::Session* s = m_setup.session; + switch (d.type) { + case RuleDecision::Finished: + m_finished.insert(d.player); + if (s) { + if (d.player == m_setup.self) + s->netFinished(toSeconds(d.ms)); + else + s->remoteFinished(nameOf(d.player), toSeconds(d.ms)); + } + return; + case RuleDecision::TimedOut: + m_timedOut = true; + if (s) + s->netTimedOut(); + return; + case RuleDecision::AllCounted: + m_allCounted = true; + if (s) + s->netAllCounted(); + return; + default: + // Cops and Robbers: the host's rules applied them already. + if (!m_setup.host && m_setup.cops) + m_setup.cops->applyHost(toCops(d), m_setup.self); + return; + } +} + +net::RulesMsg NetRules::message(std::uint8_t player, Client& c) const { + net::RulesMsg m; + m.race = m_setup.race; + m.seq = ++c.seq; + std::size_t k = c.lastDecision; + for (; k < m_log.size() && m.decisions.size() < net::kMaxRuleDecisions; ++k) + m.decisions.push_back(m_log[k]); + c.lastDecision = static_cast(k); + if (const auto it = m_carSamples.find(player); it != m_carSamples.end()) + m.evaluated = it->second; + if (const session::CopsAndRobbers* cops = m_setup.cops) { + m.cops = true; + const auto st = cops->state(); + m.carrier = toPlayer(st.carrier); + m.goldActive = st.goldActive; + m.goldPosition = st.goldPosition; + m.bank = st.set.bank; + m.setGold = st.set.gold; + m.hideout = st.set.hideout; + for (const auto& [id, sc] : st.scores) + if (m.scores.size() < net::kMaxPlayers && id >= 0 && id < net::kInvalidPlayerId) + m.scores.emplace_back(static_cast(id), std::clamp(sc, 0, net::kMaxRuleScore)); + m.over = st.over; + return m; + } + if (!m_referee) + return m; + if (const auto* p = m_referee->player(player)) { + m.evaluated = p->evaluated; + const std::size_t first = p->hits.size() > kHitWindow ? p->hits.size() - kHitWindow : 0; + m.firstHit = static_cast(first); + m.hits.assign(p->hits.begin() + static_cast(first), p->hits.end()); + c.hits = p->hits.size(); + m.place = static_cast(std::clamp(p->place, 1, static_cast(net::kMaxPlayers))); + m.racers = static_cast(std::clamp(p->racers, 1, static_cast(net::kMaxPlayers))); + } + for (const auto& [id, q] : m_referee->players()) + if (id != player && q.inRace && m.icons.size() < net::kMaxPlayers) + m.icons.emplace_back(id, static_cast(std::clamp( + m_referee->iconPlace(player, id), 0, static_cast(net::kMaxPlayers)))); + for (const auto& r : m_referee->results()) + if (m.results.size() < net::kMaxPlayers) + m.results.emplace_back(r.player, toMs(r.seconds)); + m.timedOut = m_referee->timedOut(); + m.allCounted = m_referee->allCounted(); + return m; +} + +void NetRules::hostUpdate(float dt, std::uint64_t nowMs, const Vec3& ownPosition, const Send& send) { + if (!m_setup.host || !active()) + return; + if (m_referee) { + m_referee->update(dt); + for (const auto& rd : m_referee->takeDecisions()) { + RuleDecisionMsg d; + d.player = rd.player; + switch (rd.kind) { + case session::RaceReferee::Decision::Kind::Finished: + d.type = RuleDecision::Finished; + d.ms = toMs(rd.seconds); + break; + case session::RaceReferee::Decision::Kind::TimedOut: + d.type = RuleDecision::TimedOut; + d.player = net::kInvalidPlayerId; + break; + case session::RaceReferee::Decision::Kind::AllCounted: + d.type = RuleDecision::AllCounted; + d.player = net::kInvalidPlayerId; + break; + } + decide(d); + } + // The host's own player takes the referee's word as a client does, + // with no delay. + if (const auto* p = m_referee->player(m_setup.self); p && m_setup.session) { + m_evaluated = p->evaluated; + m_setup.session->applyNetProgress({p->evaluated, 0, p->hits}, ownPosition); + m_setup.session->setNetStanding(p->place, p->racers); + } + m_icons.clear(); + for (const auto& [id, q] : m_referee->players()) + if (id != m_setup.self && q.inRace) + m_icons[id] = m_referee->iconPlace(m_setup.self, id); + } + if (!send) + return; + for (auto& [id, c] : m_clients) { + const bool news = c.lastDecision < m_log.size(); + const auto* p = m_referee ? m_referee->player(id) : nullptr; + const bool hit = p && p->hits.size() != c.hits; + if (!news && !hit && c.seq != 0 && nowMs - c.sentAt < kStateIntervalMs) + continue; + const net::RulesMsg m = message(id, c); + auto payload = net::encodePayload(m); + if (payload.size() > net::kMaxEventPayload) { + log::warn("netrules: the message to player {} is {} bytes", id, payload.size()); + continue; + } + c.sentAt = nowMs; + ++m_stats.sent; + m_stats.bytes += payload.size(); + send(id, std::move(payload)); + } +} + +void NetRules::receive(const std::vector& events, const Vec3& carPosition) { + if (m_setup.host || !active()) + return; + for (const auto& ev : events) { + if (static_cast(ev.type) != net::kRulesEvent) + continue; + if (ev.from != net::kHostPlayerId) { + ++m_stats.refused; + continue; + } + net::RulesMsg m; + if (!net::decodePayload(ev.payload, m)) { + ++m_stats.malformed; + continue; + } + if (m.race != m_setup.race || m.seq <= m_lastMessage) { + ++m_stats.stale; + continue; + } + m_lastMessage = m.seq; + ++m_stats.received; + m_evaluated = std::max(m_evaluated, m.evaluated); + for (const auto& d : m.decisions) { + if (d.seq <= m_lastDecision) + continue; + if (d.seq > m_lastDecision + 1) { + ++m_stats.gaps; + log::info("netrules: decisions {} to {} missed (the state stands in)", m_lastDecision + 1, + d.seq - 1); + } + m_lastDecision = d.seq; + m_log.push_back(d); + apply(d); + } + if (m.cops != (m_setup.cops != nullptr)) + continue; + if (session::CopsAndRobbers* cops = m_setup.cops) { + session::CopsAndRobbers::State st; + st.carrier = m.carrier == net::kInvalidPlayerId ? -1 : m.carrier; + st.goldActive = m.goldActive; + st.goldPosition = m.goldPosition; + st.set = {m.bank, m.setGold, m.hideout}; + for (const auto& [id, sc] : m.scores) + st.scores.emplace_back(id, sc); + st.over = m.over; + const bool confirmed = + m_pickupSample && m.evaluated >= *m_pickupSample + session::Session::kNetHitMargin; + cops->adopt(st, m_setup.self, confirmed); + if (!cops->pickupPending()) + m_pickupSample.reset(); + } else { + applyRaceState(m, carPosition); + } + } +} + +void NetRules::applyRaceState(const net::RulesMsg& m, const Vec3& carPosition) { + session::Session* s = m_setup.session; + if (!s) + return; + s->applyNetProgress({m.evaluated, m.firstHit, m.hits}, carPosition); + s->setNetStanding(m.place, m.racers); + m_icons.clear(); + for (const auto& [id, place] : m.icons) + m_icons[id] = place; + // A finish whose decision this machine missed: the results stand in. + for (const auto& [id, ms] : m.results) + if (!m_finished.contains(id)) + apply({0, RuleDecision::Finished, id, ms}); + if (m.timedOut && !m_timedOut) + apply({0, RuleDecision::TimedOut}); + if (m.allCounted && !m_allCounted) + apply({0, RuleDecision::AllCounted}); +} + +int NetRules::iconPlace(std::uint8_t id) const { + const auto it = m_icons.find(id); + return it == m_icons.end() ? 0 : it->second; +} + +} // namespace mm2::game diff --git a/src/game/net/NetRules.h b/src/game/net/NetRules.h new file mode 100644 index 0000000..558e9d0 --- /dev/null +++ b/src/game/net/NetRules.h @@ -0,0 +1,141 @@ +#pragma once + +// The rules of a network race decided by the host (OpenMM2: the host is the +// authority, docs/multiplayer.md "Rules"; the wire is net/RulesState.h). +// +// host runs the race's referee (session::RaceReferee: every player's +// checkpoints, laps, finish and time from its simulated cars, +// the finish timeout, the end, the standings) or takes Cops and +// Robbers' decisions (session::CopsAndRobbers::updateHost), shows +// its own player's as a client would, and sends every other +// player, a few times a second and at once when something +// happened, what it decided since its last message and the state +// as it stands. +// client takes the host's messages only: the decisions are shown once +// each (in order, by their numbers), the state corrects what the +// client predicted (the checkpoints its car hit, a gold pickup) +// and stands in for anything it missed. +// +// Nothing here touches the network: the race screen hands the messages to +// NetGame and feeds the received game events in. + +#include "game/net/NetGame.h" +#include "game/session/CopsAndRobbers.h" +#include "game/session/RaceReferee.h" +#include "game/session/Session.h" +#include "net/RulesState.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace mm2::game { + +// The host's event filter (net::Session::setEventFilter): a player's own +// word on a rule (a checkpoint, a lap, a finish, the gold, Cops and +// Robbers' places and limits, the host's rules message) is refused; only +// the host decides them. +bool hostAcceptsGameEvent(std::uint8_t from, std::uint16_t type); + +class NetRules { +public: + // How often a player hears from the host when nothing happened (the + // standings, the samples its rules have seen). + static constexpr std::uint64_t kStateIntervalMs = 250; + // The waypoints a message lists at most (the newest; a client keeps the + // earlier ones as it has them). + static constexpr std::size_t kHitWindow = 64; + + struct Setup { + bool host = false; + std::uint8_t self = 0; + std::uint32_t race = 0; // NetGame::raceNumber() + session::Session* session = nullptr; // a race's rules (Blitz, circuit, checkpoint race) + session::CopsAndRobbers* cops = nullptr; // Cops and Robbers' + std::vector players; // the players in the race at its start + float sampleSeconds = 1.0f / 60.0f; + }; + void begin(const Setup& setup); + bool active() const { return m_setup.session != nullptr || m_setup.cops != nullptr; } + bool host() const { return m_setup.host; } + // A player's name for the lines (" finished in"); kept after it leaves. + void setName(std::uint8_t id, std::string name) { m_names[id] = std::move(name); } + + // --- Host --------------------------------------------------------------------------- + // After each physics sample of a player's car (the host's own too): `seq` + // the input's number, `held` whether it held the car. + void hostSample(std::uint8_t id, std::uint32_t seq, const Mat34& car, const Vec3& inertiaBox, bool held); + // A player left the session or quit the race. + void playerLeft(std::uint8_t id); + // Cops and Robbers: what the host's rules decided this frame. + void hostDecided(const std::vector& decisions); + // Once a frame, after the rules: the referee's frame, its word on the + // host's own player (`ownPosition` its car), and each player's message + // when it is due (`send(player, payload)` as a game event of type + // net::kRulesEvent to that player). + using Send = std::function payload)>; + void hostUpdate(float dt, std::uint64_t nowMs, const Vec3& ownPosition, const Send& send); + + // --- Client ------------------------------------------------------------------------- + // The host's messages among the frame's game events (`carPosition`: this + // machine's car, where a corrected target is measured from). + void receive(const std::vector& events, const Vec3& carPosition); + // This machine predicted its car took the gold at its sample `seq`. + void predictedPickup(std::uint32_t seq) { m_pickupSample = seq; } + + // --- Both --------------------------------------------------------------------------- + // A car's icon number on this machine as the host ranks it (0: none). + int iconPlace(std::uint8_t id) const; + // Whether the host has decided that player's finish (or its did-not-finish). + bool finished(std::uint8_t id) const { return m_finished.contains(id); } + const session::RaceReferee* referee() const { return m_referee.get(); } + // The last sample of this machine's car the host's rules have seen. + std::uint32_t evaluated() const { return m_evaluated; } + + struct Stats { + std::uint64_t sent = 0, bytes = 0; // host: messages and their payload bytes + std::uint64_t received = 0; // client: messages taken + std::uint64_t refused = 0; // client: rules messages not from the host + std::uint64_t malformed = 0, stale = 0; // client: undecodable; another race's or older + std::uint64_t decisions = 0, gaps = 0; // decisions applied; numbers missing (the state stood in) + }; + const Stats& stats() const { return m_stats; } + // Every decision so far (host), or every one applied (client), in order. + const std::vector& decisions() const { return m_log; } + +private: + struct Client { + std::uint32_t seq = 0; // messages sent + std::uint32_t lastDecision = 0; // the newest decision sent + std::size_t hits = 0; // its car's hits when last sent + std::uint64_t sentAt = 0; + }; + void decide(net::RuleDecisionMsg d); + void apply(const net::RuleDecisionMsg& d); + void applyRaceState(const net::RulesMsg& m, const Vec3& carPosition); + net::RulesMsg message(std::uint8_t player, Client& c) const; + std::string nameOf(std::uint8_t id) const; + + Setup m_setup; + std::unique_ptr m_referee; + std::map m_carSamples; // host: each car's last sample + std::map m_clients; + std::set m_left; + std::vector m_log; + std::uint32_t m_lastDecision = 0; // client: the newest applied + std::uint32_t m_lastMessage = 0; // client: the newest message + std::uint32_t m_evaluated = 0; + std::optional m_pickupSample; + std::set m_finished; + std::map m_icons; + bool m_timedOut = false, m_allCounted = false; + std::map m_names; + Stats m_stats; +}; + +} // namespace mm2::game diff --git a/src/game/session/CopsAndRobbers.cpp b/src/game/session/CopsAndRobbers.cpp index b0a31e2..4a46f0f 100644 --- a/src/game/session/CopsAndRobbers.cpp +++ b/src/game/session/CopsAndRobbers.cpp @@ -318,10 +318,234 @@ std::vector CopsAndRobbers::receive(const Message& m, i m_goldActive = true; m_events.push_back({EventType::NewSet}); break; + case Message::Type::Limit: + limitReached(m.pointLimit ? EventType::PointLimit : EventType::TimeUp, m.car, m.value); + break; + } + return out; +} + +// --- Under the host's authority (OpenMM2) ------------------------------------------ + +bool CopsAndRobbers::locked(int id) const { + for (const auto& [cid, t] : m_lockout) + if (cid == id && t > 0.0f) + return true; + return false; +} + +void CopsAndRobbers::lockOut(int id, float seconds) { + for (auto& [cid, t] : m_lockout) + if (cid == id) { + t = std::max(t, seconds); + return; + } + m_lockout.emplace_back(id, seconds); +} + +void CopsAndRobbers::tickLockouts(float dt) { + for (auto& [cid, t] : m_lockout) + t -= dt; +} + +bool CopsAndRobbers::canTake(const Car& c) const { + // mmMultiCR::UpdateGold: free gold within 5 m in the car's room, the car + // not sitting out a wreck (state 6) or a lost gold (state 7). + return m_carrier < 0 && m_goldActive && !locked(c.id) && !c.wrecked && + c.position.dist2(m_goldPos) < kGoldRadius * kGoldRadius && + (!m_settings.sameRoom || m_settings.sameRoom(m_goldPos, c.position)); +} + +std::vector CopsAndRobbers::updateHost(float dt, const std::vector& cars, + const std::vector& impacts) { + std::vector out; + if (m_over) + return out; + tickLockouts(dt); + // Each car's ImpactCallback (the physics step before this frame), + // UpdateGame state 4's wreck and the water handlers, as its own machine + // runs them: a damaging hit of 250 or more from another player's car + // knocks the carrier's gold loose where it is and locks it out for 2 s; + // a wrecked car sits out 5 s, dropping the gold where it is; the water + // or a fall sends the gold back to its place. + bool dropped = false; + for (const auto& c : cars) { + if (m_carrier == c.id) { + for (const auto& im : impacts) { + const int other = im.a == c.id ? im.b : (im.b == c.id ? im.a : -1); + if (other < 0 || other == c.id || im.impulse < kStealImpulse) + continue; + lockOut(c.id, kDropLockout); + drop(c.id, c.position, false, true); + out.push_back({Message::Type::GoldDropped, c.id, m_goldPos, {}, true}); + dropped = true; + break; + } + } + if (c.wrecked && !locked(c.id)) { + lockOut(c.id, kWreckPenalty); + if (m_carrier == c.id) { + drop(c.id, c.position, false); + out.push_back({Message::Type::GoldDropped, c.id, m_goldPos}); + dropped = true; + } + } + if (c.inWater && m_carrier == c.id) { + drop(c.id, c.position, true); + out.push_back({Message::Type::GoldDropped, c.id, m_goldPos}); + dropped = true; + } + } + // UpdateLimit and UpdateTimeWarning (the host's alone), before the + // frame's pickups and deliveries. + tickLimits(dt); + if (m_over) { + for (auto it = m_events.rbegin(); it != m_events.rend(); ++it) + if (it->type == EventType::TimeUp || it->type == EventType::PointLimit) { + Message m{Message::Type::Limit, it->car}; + m.pointLimit = it->type == EventType::PointLimit; + m.value = it->value; + out.push_back(m); + break; + } + return out; + } + // UpdateGold, then UpdateBank / UpdateHideout, for each car: the gold + // rides 2 m above its carrier; a car at free gold takes it (the host + // grants it while nobody carries it, and only with another player in + // the game); a carrier within 12 m of its base, whose room it can + // reach, delivers it and the host draws the next places. Nobody takes + // gold in the frame it was dropped or delivered (the others learn of + // it by message in MM2). + for (const auto& c : cars) { + if (m_carrier == c.id) { + m_goldPos = c.position + Vec3{0.0f, 2.0f, 0.0f}; + } else if (!dropped && cars.size() > 1 && canTake(c)) { + take(c.id); + out.push_back({Message::Type::GoldTaken, c.id, m_goldPos}); + } + const Vec3 base = deliveryTarget(teamOf(c.id)); + if (m_carrier == c.id && c.position.dist2(base) < kBaseRadius * kBaseRadius && + (!m_settings.baseReachable || m_settings.baseReachable(base, c.position))) { + deliver(c.id); + out.push_back({Message::Type::GoldDelivered, c.id, m_goldPos}); + newSet(); + out.push_back({Message::Type::NewSet, -1, m_goldPos, m_set}); + dropped = true; + } } return out; } +bool CopsAndRobbers::updatePredicted(float dt, const Car& me, int players) { + if (m_over) + return false; + tickLockouts(dt); + if (me.wrecked && !locked(me.id)) + lockOut(me.id, kWreckPenalty); + tickLimits(dt); + if (m_over || m_pending || players < 2 || !canTake(me)) + return false; + // UpdateGold on a machine that is not the host: MM2 asks the host + // (0x25e) and shows the gold taken when the host's 0x25a arrives; + // OpenMM2 shows it at once and the host's decision confirms it. + take(me.id); + m_pending = true; + return true; +} + +void CopsAndRobbers::applyHost(const Message& m, int self) { + // A pickup this machine predicted, which the host's word shows was not + // its car's. + auto undo = [&] { + if (!m_pending) + return; + m_pending = false; + if (m_carrier == self) { + m_carrier = -1; + m_goldActive = true; + m_goldPos = m_set.gold; + } + for (auto& [cid, sc] : m_scores) + if (cid == self) + sc = std::max(0, sc - kPickupPoints); + m_events.push_back({EventType::PickupUndone, self}); + }; + switch (m.type) { + case Message::Type::PickupRequest: break; // a host's word never asks + case Message::Type::GoldTaken: + if (m.car == self && m_pending && m_carrier == self) { + m_pending = false; // confirmed: shown already + return; + } + undo(); + take(m.car); + return; + case Message::Type::GoldDropped: + if (m.car != self) + undo(); + else + m_pending = false; + m_goldPos = m.position; + m_carrier = -1; + m_goldActive = true; + if (m.car == self && m.knocked) + lockOut(self, kDropLockout); + m_events.push_back({EventType::GoldDropped, m.car, m.knocked ? 1 : 0}); + return; + case Message::Type::GoldDelivered: + if (m.car != self) + undo(); + m_pending = false; + deliver(m.car); + return; + case Message::Type::NewSet: + undo(); + receive(m, 0, false); + return; + case Message::Type::Limit: + undo(); + receive(m, 0, false); + return; + } +} + +CopsAndRobbers::State CopsAndRobbers::state() const { + State s; + s.carrier = m_carrier; + s.goldActive = m_goldActive; + s.goldPosition = m_goldPos; + s.set = m_set; + for (const auto& [id, sc] : m_scores) + s.scores.emplace_back(id, sc); + s.over = m_over; + return s; +} + +void CopsAndRobbers::adopt(const State& s, int self, bool confirmed) { + if (m_pending) { + if (s.carrier == self) { + m_pending = false; + } else if (confirmed) { + // The host has run this car well past the pickup and has not + // granted it. + m_pending = false; + m_events.push_back({EventType::PickupUndone, self}); + } else { + return; // still on its way: the prediction stands + } + } + m_carrier = s.carrier; + m_goldActive = s.goldActive; + if (m_carrier < 0) + m_goldPos = s.goldPosition; + m_set = s.set; + for (const auto& [id, sc] : s.scores) + for (auto& [cid, mine] : m_scores) + if (cid == id) + mine = sc; +} + void CopsAndRobbers::score(int id, int points) { // mmMultiCR::Score. for (auto& [cid, s] : m_scores) diff --git a/src/game/session/CopsAndRobbers.h b/src/game/session/CopsAndRobbers.h index 9fb9ae7..7c3f3de 100644 --- a/src/game/session/CopsAndRobbers.h +++ b/src/game/session/CopsAndRobbers.h @@ -90,8 +90,19 @@ class CopsAndRobbers { // impact lasts), of a damaging impact. float impulse = 0.0f; }; - enum class EventType : std::uint8_t { GoldTaken, GoldDropped, GoldDelivered, NewSet, TimeWarning, TimeUp, - PointLimit }; + enum class EventType : std::uint8_t { + GoldTaken, + GoldDropped, + GoldDelivered, + NewSet, + TimeWarning, + TimeUp, + PointLimit, + // OpenMM2 (host authority): the pickup this machine predicted for + // its car was not the host's (another car took the gold, or the + // host never granted it): the gold leaves the car, without a line. + PickupUndone, + }; struct Event { EventType type{}; int car = -1; // carrier / deliverer @@ -116,11 +127,15 @@ class CopsAndRobbers { GoldDropped, // 0x259: the carrier lost it at `position` GoldDelivered, // 600: the carrier delivered it NewSet, // 0x261 ChangeSet: the host's new places + Limit, // SendLimitReached: the game is over (updateHost) }; Type type{}; int car = -1; Vec3 position; CrSet set; + bool knocked = false; // GoldDropped: by a hit (ImpactCallback's "You dropped the gold!") + bool pointLimit = false; // Limit: a point limit (`car` and `value` the winner's), else the time + int value = 0; }; // The local machine's frame for its car `self` (in `cars`, with the // others' places): `impacts` are its car's; `host` grants pickups at @@ -136,6 +151,46 @@ class CopsAndRobbers { // A player left (mmMultiCR::SystemMessage 0x2d): the host drops a // leaver's gold where it is (2 m above the car), not back to its spawn. std::vector playerLeft(int id, bool host); + // --- Under the host's authority (OpenMM2, docs/multiplayer.md "Rules") --------- + // The host simulates every car, so it runs mmMultiCR's rules for each as + // that car's own machine did (ImpactCallback, UpdateGame's wreck, + // HitWaterHandler, UpdateGold, UpdateBank / UpdateHideout) with its own + // answers to them (a pickup granted while nobody carries the gold, + // GetNewSet after a delivery), and tells everyone. `cars` are every + // player's car in the game, `impacts` the damaging hits between them. + // Returns the decisions, in order. + std::vector updateHost(float dt, const std::vector& cars, const std::vector& impacts); + // A machine that is not the host: the clock and its warnings, and its + // own car `me` taking free gold within reach (UpdateGold's test, the + // car not locked out by a wreck or a lost gold): a prediction, shown at + // once (GoldTaken), which the host's decision confirms or undoes + // (applyHost, adopt). `players` counts the players in the game. Returns + // true when it predicted a pickup. + bool updatePredicted(float dt, const Car& me, int players); + // The host's decision (a Message from updateHost) on a machine that is + // not the host: as receive(), except that a predicted pickup it confirms + // shows nothing more, and one it gives to another car is undone. + void applyHost(const Message& m, int self); + // The host's state, which a machine that is not the host adopts as it + // stands (silently: the decisions showed what happened). `confirmed`: + // the host has seen this machine's car long enough after a predicted + // pickup that the pickup is undone unless the state has it. + struct State { + int carrier = -1; + bool goldActive = true; + Vec3 goldPosition; + CrSet set; + std::vector> scores; + bool over = false; + }; + State state() const; + void adopt(const State& state, int self, bool confirmed); + // A pickup this machine predicted that the host has not confirmed yet. + bool pickupPending() const { return m_pending; } + // Lock this machine's own car out of pickups (its car was wrecked, or + // the host's word knocked the gold out of it). + void lockOut(int id, float seconds); + // Whether the gold can be taken (mmWaypointObject active). bool goldActive() const { return m_goldActive; } @@ -166,6 +221,9 @@ class CopsAndRobbers { void tickLimits(float dt); void score(int id, int points); void checkLimits(); + bool locked(int id) const; + void tickLockouts(float dt); + bool canTake(const Car& c) const; CrSettings m_settings; CrLocations m_locations; @@ -181,6 +239,7 @@ class CopsAndRobbers { float m_lastWarning = 0.0f; bool m_scored = false; bool m_over = false; + bool m_pending = false; // a predicted pickup (updatePredicted) not confirmed yet std::vector m_events; }; diff --git a/src/game/session/RaceReferee.cpp b/src/game/session/RaceReferee.cpp new file mode 100644 index 0000000..2db0d22 --- /dev/null +++ b/src/game/session/RaceReferee.cpp @@ -0,0 +1,222 @@ +#include "game/session/RaceReferee.h" + +#include + +namespace mm2::game::session { +namespace { + +WaypointRule ruleFor(GameMode mode) { + switch (mode) { + case GameMode::Blitz: return WaypointRule::Blitz; + case GameMode::Circuit: return WaypointRule::Circuit; + case GameMode::Checkpoint: return WaypointRule::CheckpointRace; + default: return WaypointRule::None; + } +} + +} // namespace + +RaceReferee::RaceReferee(Config config) : m_config(std::move(config)) {} + +void RaceReferee::addPlayer(std::uint8_t id) { + auto [it, added] = m_players.try_emplace(id); + if (added) + it->second.wp.reset(m_config.checkpoints, ruleFor(m_config.mode), m_config.laps, false); +} + +void RaceReferee::removePlayer(std::uint8_t id) { + if (const auto it = m_players.find(id); it != m_players.end()) + it->second.inRace = false; +} + +const RaceReferee::Player* RaceReferee::player(std::uint8_t id) const { + const auto it = m_players.find(id); + return it == m_players.end() ? nullptr : &it->second; +} + +std::vector RaceReferee::takeDecisions() { + std::vector out; + out.swap(m_decisions); + return out; +} + +void RaceReferee::sample(std::uint8_t id, std::uint32_t seq, const Mat34& car, const Vec3& inertiaBox, + bool held) { + addPlayer(id); + Player& p = m_players.at(id); + if (!p.inRace || (p.evaluated != 0 && seq <= p.evaluated)) + return; + p.placed = true; + p.position = car.m3; + p.evaluated = seq; + // The car races from the first sample its own input lets it go (its + // player's Go at the shared start; a late loader's own Go). + if (!p.released) { + if (held) + return; + p.released = true; + p.releaseSeq = seq; + } + if (p.finish) + return; + // mmWaypoints::Update, as the car's own machine runs it (that machine + // once a frame, the host after every sample: the same at 60 frames a + // second). + const int index = p.wp.detect(m_config.checkpoints, car, inertiaBox); + if (index >= 0) { + std::vector steps; + if (p.wp.apply(m_config.checkpoints, index, car.m3, steps)) + p.hits.push_back(static_cast(index)); + } + // mmMultiRace / mmMultiCircuit / mmMultiBlitz::UpdateGame state 3. A + // held car (the wreck penalty's state 7) is not checked until it goes + // again. + if (held) + return; + const float seconds = static_cast(seq - p.releaseSeq + 1) * m_config.sampleSeconds; + if (p.wp.finished) { + decideFinish(id, p, seconds); + return; + } + if (m_config.mode == GameMode::Blitz && m_config.timeLimit > 0.0f && seconds >= m_config.timeLimit) { + // Its clock ran out: "Time's up!" and a did-not-finish + // (SendFinishReq(86400)); the host's own clock running out tells + // everyone (0x1fe). + p.wp.stopped = true; + decideFinish(id, p, kDnf); + if (id == m_config.host) + timeOutAll(); + } +} + +void RaceReferee::decideFinish(std::uint8_t id, Player& p, float seconds) { + if (p.finish) + return; + p.finish = seconds; + // mmGameMulti::SortResults: a player is listed once, by time (a later + // equal time goes after; did-not-finish last). + if (std::ranges::none_of(m_results, [id](const Result& r) { return r.player == id; })) { + const auto at = std::ranges::find_if(m_results, [seconds](const Result& r) { return r.seconds > seconds; }); + m_results.insert(at, Result{id, seconds}); + } + m_decisions.push_back({Decision::Kind::Finished, id, seconds}); + // SetTimeoutOn: the first finish arms the finish timeout. + if (waitsForAll() && m_results.size() < 2 && !m_timedOut && !m_allCounted) { + m_timeoutOn = true; + m_timeout = timeout(); + } +} + +void RaceReferee::timeOutAll() { + // 0x1fe: every machine still racing stops ("Race over") and does not + // finish (SendFinishReq(86400)), which the host counts. + if (m_timedOut) + return; + m_timedOut = true; + m_timeoutOn = false; + m_decisions.push_back({Decision::Kind::TimedOut}); + for (auto& [id, p] : m_players) { + if (!p.inRace || p.finish) + continue; + p.wp.stopped = true; + decideFinish(id, p, kDnf); + } +} + +Vec3 RaceReferee::targetOf(const Player& p) const { + const int n = static_cast(m_config.checkpoints.size()); + return m_config.checkpoints[static_cast(std::clamp(p.wp.current, 0, n - 1))].position; +} + +void RaceReferee::update(float dt) { + // The finish timeout (mmMultiRace / mmMultiCircuit::UpdateGame on the + // host): when it has run out, 0x1fe. + if (m_timeoutOn && !m_timedOut) { + m_timeout -= dt; + if (m_timeout <= 0.0f) + timeOutAll(); + } + // 0x211 once every player still in the race is counted (OpenMM2 counts + // the players still in the race, where MM2 compared the count with the + // session's players: a player who finished and left does not end the + // race of one still driving). + if (waitsForAll() && !m_allCounted && !m_results.empty() && + std::ranges::all_of(m_players, [](const auto& e) { return !e.second.inRace || e.second.finish; })) { + m_allCounted = true; + m_timeoutOn = false; + m_decisions.push_back({Decision::Kind::AllCounted}); + } + // mmGameMulti::UpdateScore on each player's machine: its place among the + // racers while its waypoints are not done. + const int n = static_cast(m_config.checkpoints.size()); + if (n < 2) + return; + for (auto& [id, p] : m_players) { + if (!p.inRace || p.wp.finished) + continue; + const Vec3 target = targetOf(p); + const float mine = p.position.dist2(target); + int place = 1, racers = 1; + for (const auto& [qid, q] : m_players) { + if (qid == id || !q.inRace) + continue; + const bool done = q.finish.has_value(); + if (!q.placed) { + // A slot without a car counts only once finished. + if (done) { + ++racers; + ++place; + } + continue; + } + ++racers; + if (p.wp.count < q.wp.count || done) + ++place; + else if (p.wp.count == q.wp.count && q.position.dist2(target) < mine) + ++place; + } + p.place = place; + p.racers = racers; + } +} + +int RaceReferee::iconPlace(std::uint8_t viewer, std::uint8_t car) const { + // mmGameMulti::UpdateScore's second half on `viewer`'s machine: a car's + // IconIndex is 1 + the other cars ahead of it (more waypoints passed, + // finished, or level and nearer its next waypoint) + the viewer's own + // car when that is ahead (more waypoints, its waypoints done, or level + // and nearer the viewer's own target). + const int n = static_cast(m_config.checkpoints.size()); + const Player* p = player(car); + if (n < 2 || !p || !p->inRace || !p->placed || p->finish || viewer == car) + return 0; + const Vec3 wp = m_config.checkpoints[static_cast(p->wp.count % n)].position; + const float own = p->position.dist2(wp); + int place = 1; + for (const auto& [qid, q] : m_players) { + if (qid == car || qid == viewer || !q.inRace) + continue; + const bool done = q.finish.has_value(); + if (!q.placed) { + if (done) + ++place; + continue; + } + if (q.wp.count > p->wp.count || done) + ++place; + else if (q.wp.count == p->wp.count && q.position.dist2(wp) < own) + ++place; + } + if (const Player* v = player(viewer); v && v->placed) { + if (p->wp.count < v->wp.count || v->wp.finished) { + ++place; + } else if (p->wp.count == v->wp.count) { + const Vec3 target = targetOf(*v); + if (v->position.dist2(target) < p->position.dist2(target)) + ++place; + } + } + return place; +} + +} // namespace mm2::game::session diff --git a/src/game/session/RaceReferee.h b/src/game/session/RaceReferee.h new file mode 100644 index 0000000..8769d78 --- /dev/null +++ b/src/game/session/RaceReferee.h @@ -0,0 +1,129 @@ +#pragma once + +// The host's referee of a network race (OpenMM2: the host is the authority +// for the rules, docs/multiplayer.md "Rules"). MM2's machines each ran +// mmWaypoints on their own car and told the others (mmGameMulti:: +// SendPosition's waypoint count, SendFinishReq); the host acknowledged the +// finishes, ran the finish timeout and ended the race (mmMultiRace / +// mmMultiCircuit / mmMultiBlitz::UpdateGame and GameMessage 0x206, 0x1fe, +// 0x211). OpenMM2's host simulates every player's car, so it runs those +// waypoints itself, with the same rules (WaypointTracker), on every car +// after each physics sample, and decides: +// +// * each car's checkpoints, laps and finish, and its finish time: the +// samples the host simulated it from the one its own input released it +// (its player's Go at the shared start) to the one it crossed the line in, +// 1/60 s each; +// * a Blitz car out of time (its own clock), and the host's own time running +// out ending everyone's race (0x1fe); +// * the finish timeout from the first finish (60 s in a checkpoint race, +// 120 s in a circuit; SetTimeoutOn) and every player still racing when it +// runs out not finishing (0x1fe), and the end once every player still in +// the race is counted (0x211); +// * the standings: each player's place and each car's icon number as every +// player's own machine would rank them (mmGameMulti::UpdateScore). + +#include "game/RaceConfig.h" +#include "game/session/Types.h" +#include "game/session/Waypoints.h" + +#include +#include +#include +#include + +namespace mm2::game::session { + +class RaceReferee { +public: + // A time that means "did not finish" (mmGameMulti's 86400). + static constexpr float kDnf = 86400.0f; + + struct Config { + GameMode mode = GameMode::Checkpoint; // Blitz, Circuit or Checkpoint + std::vector checkpoints; + int laps = 1; + float timeLimit = 0.0f; // Blitz: seconds on each car's clock (0: none) + std::uint8_t host = 0; // the host's player id (its time running out ends a Blitz) + float sampleSeconds = 1.0f / 60.0f; + }; + + // What the referee decided, in order. + struct Decision { + enum class Kind : std::uint8_t { + Finished, // `player` finished in `seconds` (kDnf: did not finish) + TimedOut, // 0x1fe: the race is over for everyone still racing + AllCounted, // 0x211: every player still in the race is counted + }; + Kind kind{}; + std::uint8_t player = 0; + float seconds = 0.0f; + }; + + struct Player { + WaypointTracker wp; + std::vector hits; // the waypoints its car hit, in order + bool inRace = true; // in the session and not quit: waited for + bool placed = false; // its car is in the world (the host simulates it) + bool released = false; + std::uint32_t releaseSeq = 0; // its first sample not held + std::uint32_t evaluated = 0; // the last sample the rules saw + Vec3 position; + std::optional finish; // decided: seconds, or kDnf + // mmGameMulti::UpdateScore on its machine: its place among the + // racers (frozen once its waypoints are done) and how many race. + int place = 1, racers = 1; + }; + + explicit RaceReferee(Config config); + + const Config& config() const { return m_config; } + + // A player in the race (waited for before it has a car). + void addPlayer(std::uint8_t id); + // A player left the session or quit the race: no longer waited for (a + // finish it had stays in the results). + void removePlayer(std::uint8_t id); + // After each physics sample of a player's car: `seq` the number of the + // input the sample applied, `held` whether that input held the car + // (vehCar::SetDrivable(0, 1): the grid, a wreck penalty, the finish). + void sample(std::uint8_t id, std::uint32_t seq, const Mat34& car, const Vec3& inertiaBox, bool held); + // Once a frame (`dt` seconds of the host's clock): the finish timeout, + // the end, the standings. + void update(float dt); + + std::vector takeDecisions(); + const Player* player(std::uint8_t id) const; + const std::map& players() const { return m_players; } + // mmGameMulti::SortResults' table: by time, a later equal time after. + struct Result { + std::uint8_t player = 0; + float seconds = 0.0f; + }; + const std::vector& results() const { return m_results; } + bool timedOut() const { return m_timedOut; } + bool allCounted() const { return m_allCounted; } + // A car's icon number on `viewer`'s machine (mmGameMulti::UpdateScore's + // IconIndex): 0 no icon (finished, or no car), else its place. + int iconPlace(std::uint8_t viewer, std::uint8_t car) const; + +private: + void decideFinish(std::uint8_t id, Player& p, float seconds); + void timeOutAll(); + bool waitsForAll() const { + return m_config.mode == GameMode::Checkpoint || m_config.mode == GameMode::Circuit; + } + float timeout() const { return m_config.mode == GameMode::Circuit ? 120.0f : 60.0f; } + Vec3 targetOf(const Player& p) const; + + Config m_config; + std::map m_players; + std::vector m_results; + std::vector m_decisions; + bool m_timeoutOn = false; + float m_timeout = 0.0f; + bool m_timedOut = false; + bool m_allCounted = false; +}; + +} // namespace mm2::game::session diff --git a/src/game/session/Session.cpp b/src/game/session/Session.cpp index 14254b6..09cc267 100644 --- a/src/game/session/Session.cpp +++ b/src/game/session/Session.cpp @@ -4,6 +4,7 @@ #include "core/StringUtil.h" #include +#include #include #include #include @@ -68,17 +69,21 @@ ModeText modeText(GameMode m, bool multi) { // The multiplayer races' finish lines (mmMultiRace / Circuit / Blitz:: // GameMessage, UpdateGame): another player "finished in" (the host's line on -// 0x206, a client's on 0x1f7) and the finish timeout's "Race over". +// 0x206, a client's on 0x1f7), the finish timeout's "Race over" (the host's +// own at the timeout, a client's on 0x1fe; a Blitz client's on the host's +// 0x1fe), and the player's own "finished in" (the host's at its finish, a +// client's when the host's 0x1f7 about it arrives). struct NetText { std::uint32_t otherFinished = 0, raceOver = 0; float timeout = 0.0f; // the timeout armed at the first finish (0: none) + std::uint32_t ownFinished = 0; }; NetText netText(GameMode m, bool host) { switch (m) { - case GameMode::Checkpoint: return {host ? 152u : 150u, host ? 143u : 153u, 60.0f}; - case GameMode::Circuit: return {host ? 110u : 107u, host ? 100u : 111u, 120.0f}; - case GameMode::Blitz: return {host ? 99u : 96u, 0, 0.0f}; + case GameMode::Checkpoint: return {host ? 152u : 150u, host ? 143u : 153u, 60.0f, host ? 148u : 149u}; + case GameMode::Circuit: return {host ? 110u : 107u, host ? 100u : 111u, 120.0f, host ? 105u : 106u}; + case GameMode::Blitz: return {host ? 99u : 96u, host ? 0u : 97u, 0.0f, host ? 93u : 95u}; default: return {}; } } @@ -252,6 +257,9 @@ void Session::resetRace() { m_netResults.clear(); // mmGameMulti::Reset: the results table m_netTimeoutOn = m_netTimedOut = false; m_netRacerCount = 1; + m_netHits.clear(); + m_netEvaluated = 0; + m_netFinishKnown = m_netAllCounted = false; // DisableRacers: the player takes no damage before "Go!". m_playerDamage = mode() == GameMode::Cruise || mode() == GameMode::CopsAndRobbers; m_resultFinished = m_resultWon = false; @@ -577,10 +585,199 @@ void Session::showWaypointSteps(const std::vector& steps) { void Session::updateWaypoints(const PlayerState& player) { std::vector steps; - m_wp.update(m_checkpoints, player.transform, player.inertiaBox, steps); + const int index = m_wp.detect(m_checkpoints, player.transform, player.inertiaBox); + if (index < 0 || !m_wp.apply(m_checkpoints, index, player.transform.m3, steps)) + return; + // A network race: the host's word on it follows (applyNetProgress). + if (netRules()) + m_netHits.push_back({static_cast(index), m_netSample}); showWaypointSteps(steps); } +// --- Network races under the host's authority (SessionOptions::netRules) ------------ + +std::vector Session::netHits() const { + std::vector out; + for (const auto& h : m_netHits) + out.push_back(h.index); + return out; +} + +namespace { + +// A hit's identity in a car's sequence of hits: the waypoint and how many +// times the car had hit it before (a circuit's laps). +std::vector> hitKeys(const std::vector& hits) { + std::vector> keys; + std::array seen{}; + for (const std::uint8_t h : hits) + keys.emplace_back(h, seen[h]++); + return keys; +} + +} // namespace + +void Session::applyNetProgress(const NetProgress& progress, const Vec3& carPosition) { + if (!netRules() || progress.evaluated < m_netEvaluated) + return; + m_netEvaluated = progress.evaluated; + // The host's sequence: this machine's hits before `first`, then the + // host's. + std::vector host; + for (std::size_t i = 0; i < m_netHits.size() && i < progress.first; ++i) + host.push_back(m_netHits[i].index); + host.insert(host.end(), progress.hits.begin(), progress.hits.end()); + const std::vector mine = netHits(); + const auto hostKeys = hitKeys(host); + const auto myKeys = hitKeys(mine); + // The hits this machine predicted that the host has not counted are + // still on their way while the host has not run kNetHitMargin of the + // car's samples past them, and are taken back after that. + std::vector next = host; + std::vector nextSeq(host.size(), progress.evaluated); + for (std::size_t i = 0; i < m_netHits.size(); ++i) { + if (const auto hk = std::ranges::find(hostKeys, myKeys[i]); hk != hostKeys.end()) { + nextSeq[static_cast(hk - hostKeys.begin())] = m_netHits[i].seq; + continue; + } + if (m_netHits[i].seq + kNetHitMargin > progress.evaluated) { + next.push_back(m_netHits[i].index); + nextSeq.push_back(m_netHits[i].seq); + } + } + if (next == mine) { + for (std::size_t i = 0; i < m_netHits.size(); ++i) + m_netHits[i].seq = nextSeq[i]; + return; + } + // The waypoints as the host's word and the hits still on their way make + // them: every hit applied again from mmWaypoints::Reset (the same + // rules), the HUD showing the ones this machine had not shown. + WaypointTracker wp; + const LessonEvent* lesson = currentLesson(); + wp.reset(m_checkpoints, rule(), m_setup.laps, lesson && lesson->singleCheckpoint); + std::vector hits; + std::vector shown; + std::array seen{}; + for (std::size_t i = 0; i < next.size(); ++i) { + std::vector steps; + if (!wp.apply(m_checkpoints, next[i], carPosition, steps)) + continue; + const std::pair key{next[i], seen[next[i]]++}; + hits.push_back({next[i], nextSeq[i]}); + if (std::ranges::find(myKeys, key) == myKeys.end()) + shown.insert(shown.end(), steps.begin(), steps.end()); + } + // A lap taken back leaves the lap times. + if (wp.lap < m_wp.lap) { + m_lapTimes.resize(static_cast(std::max(0, wp.lap))); + m_lapStart = 0.0f; + for (const float t : m_lapTimes) + m_lapStart += t; + m_lastLap = m_lapTimes.empty() ? 0.0f : m_lapTimes.back(); + m_bestLap = m_lapTimes.empty() ? 0.0f : *std::ranges::min_element(m_lapTimes); + } + // The player's own target stays while it is still one (the Next / Prev. + // Checkpoint keys choose it); the any-order rules aim at the nearest + // otherwise. + const int n = static_cast(m_checkpoints.size()); + const int target = m_wp.current; + if (wp.rule() == WaypointRule::CheckpointRace || wp.rule() == WaypointRule::Blitz) { + const auto k = static_cast(std::clamp(target, 0, std::max(0, n - 1))); + if (k < wp.cleared.size() && !wp.cleared[k] && wp.visible[k]) + wp.setTarget(target); + else if (!wp.finished) + wp.closestTarget(m_checkpoints, carPosition); + } + log::debug("session: the host's word on the waypoints after sample {}: {} hits ({} predicted)", + progress.evaluated, hits.size(), m_netHits.size()); + m_wp = std::move(wp); + m_netHits = std::move(hits); + showWaypointSteps(shown); +} + +void Session::setNetStanding(int place, int racers) { + if (!netRules()) + return; + m_rank = std::max(1, place); + m_netRacerCount = std::max(1, racers); +} + +void Session::netFinished(float seconds) { + if (!netRules() || m_netFinishKnown || m_phase == Phase::Done) + return; + m_netFinishKnown = true; + const NetText nt = netText(mode(), m_options.netHost); + if (seconds >= kNetDnf) { + // Did not finish: its clock ran out (a Blitz), or the host timed the + // race out (its "Race over" came with netTimedOut). + addNetResult(m_options.playerName, kNetDnf, true); + if (m_phase == Phase::Racing) { + stopTimerWarning(); + m_wp.stopped = true; + if (mode() == GameMode::Blitz) { + const ModeText mt = modeText(mode(), true); + m_timeUp = true; + push(EventType::TimeUp); + setMessage(mt.timeUp, "Time's up!", 5.0f, false); + deactivateFinish(); + endRace(false, false, kPostRace); + } else { + setMessage(nt.raceOver, "Race over", 5.0f, false); + endRace(false, false, 3.0f, PlayerHold::Undrivable); + } + } + return; + } + // mmMultiRace / Circuit / Blitz: the host's own finish (UpdateGame state + // 3) or a client's on the host's 0x1f7 about it: "" / "finished in + // M:SS:HH" with the host's time, the results, and the wait before them + // (a Blitz until its clock would have run out, the others 3 s). + stopTimerWarning(); + m_raceTime = seconds; + setMessage(m_options.playerName, 5.0f, false); + setMessage2(std::format("{} {}", str(nt.ownFinished, "finished in"), formatTime(seconds))); + addNetResult(m_options.playerName, seconds, true); + int row = 0; + for (const auto& r : m_netResults) { + ++row; + if (r.self) + break; + } + m_resultPosition = row; + if (m_phase == Phase::PostRace) { + // Its own clock had run out first (a Blitz): the host's finish stands. + m_resultFinished = m_resultWon = true; + m_resultTime = seconds; + m_timeUp = false; + return; + } + push(EventType::PlayerFinished, row, seconds); + const float wait = mode() == GameMode::Blitz ? std::max(0.0f, m_clock) : 3.0f; + endRace(true, true, wait); +} + +void Session::netTimedOut() { + // 0x1fe: a machine still racing stops, braked, with the mode's line for + // 5 s (a Blitz's with the net alert); MM2 then sends its did-not-finish, + // which the host has decided already. + if (!netRules() || m_phase != Phase::Racing) + return; + m_netTimedOut = true; + stopTimerWarning(); + m_wp.stopped = true; + const NetText nt = netText(mode(), m_options.netHost); + if (mode() == GameMode::Blitz) { + sound(GameSound::NetAlert); + if (nt.raceOver) + setMessage(nt.raceOver, "Time's up!", 5.0f, false); + endRace(false, false, 5.0f, PlayerHold::Undrivable); + } else { + setMessage(nt.raceOver, "Race over", 5.0f, false); + endRace(false, false, 3.0f, PlayerHold::Undrivable); + } +} + // --- Opponents --------------------------------------------------------------------- void Session::updateOpponents(std::span opponents) { @@ -915,7 +1112,8 @@ void Session::addNetResult(std::string name, float time, bool self) { // SetTimeoutOn: the first finish (fewer than 2 counted) arms the // finish timeout of a race (60 s) or circuit (120 s). const NetText nt = netText(mode(), m_options.netHost); - if (netWaitsForAll() && nt.timeout > 0.0f && m_netResults.size() < 2 && m_phase != Phase::Done) { + if (!netRules() && netWaitsForAll() && nt.timeout > 0.0f && m_netResults.size() < 2 && + m_phase != Phase::Done) { m_netTimeoutOn = true; m_netTimeout = nt.timeout; } @@ -936,6 +1134,14 @@ void Session::remoteFinished(const std::string& name, float seconds) { } void Session::updateNetRace(float dt, const PlayerState& player) { + if (netRules()) { + // The host ranks everyone (setNetStanding, NetRacer::hostPlace) and + // runs the finish timeout (netTimedOut). + m_netPlaces.assign(m_netRacers.size(), 0); + for (std::size_t i = 0; i < m_netRacers.size(); ++i) + m_netPlaces[i] = m_netRacers[i].present && !m_netRacers[i].finished ? m_netRacers[i].hostPlace : 0; + return; + } // mmGameMulti::UpdateScore: the place among the other players, while // the player's waypoints are not done ("Place: n/N"). const int n = static_cast(m_checkpoints.size()); @@ -981,8 +1187,14 @@ void Session::updateNetRace(float dt, const PlayerState& player) { else if (o.present && o.waypoints == r.waypoints && dist2(o.position, wp) < own) ++place; } + // The player's own car: ahead with more waypoints or its + // waypoints done; level, when it is nearer its own target than + // that car is (mmGameMulti::UpdateScore measures both to the + // player's target there, not to the car's next waypoint). + const Vec3 target = + m_checkpoints[static_cast(std::clamp(m_wp.current, 0, n - 1))].position; if (m_wp.count > r.waypoints || m_wp.finished || - (m_wp.count == r.waypoints && dist2(player.transform.m3, wp) < own)) + (m_wp.count == r.waypoints && dist2(player.transform.m3, target) < dist2(r.position, target))) ++place; m_netPlaces[i] = place; } @@ -1044,7 +1256,8 @@ void Session::updateRace(float dt, const PlayerState& player) { if (mode() == GameMode::Blitz && m_hasClock && m_clock < kTimerWarning) timerWarning(dt); wreckPenalty(); - if (m_wp.finished) { + // Under the host's authority the finish is the host's (netFinished). + if (m_wp.finished && !netRules()) { stopTimerWarning(); playerFinished(); // The player's name, and "finished in M:SS:HH" under it. @@ -1581,8 +1794,10 @@ void Session::updateRules(float dt, const PlayerState& player, std::span racers) { m_netRacers = std::move(racers); } // Another player's finish (or kNetDnf) arrived (mmMulti*::GameMessage @@ -256,6 +267,42 @@ class Session { // Waypoints passed, the start included (sent to the other players). int waypointsPassed() const { return m_wp.count; } + // --- Network races under the host's authority (SessionOptions::netRules) --- + // A predicted checkpoint the host has not counted by this many of the + // car's samples after the one it was hit in is taken back. + static constexpr std::uint32_t kNetHitMargin = 30; + // This machine's newest physics sample (its car's input number): the + // checkpoints the session predicts are stamped with it. + void setNetSample(std::uint32_t seq) { m_netSample = seq; } + // The host's word on the player's waypoints: the waypoints its car hit, + // in order, as the host's simulation of it saw them up to its sample + // `evaluated` (`first`: the number of the first of `hits`, the earlier + // ones as this machine has them). The predicted hits the host has + // counted are confirmed; the ones it has not counted within + // kNetHitMargin samples are taken back (silently: the marker shows + // again); the ones it counted that were not predicted are shown now. + struct NetProgress { + std::uint32_t evaluated = 0; + std::uint32_t first = 0; + std::vector hits; + }; + void applyNetProgress(const NetProgress& progress, const Vec3& carPosition); + // The waypoints this machine's car has hit, in order (for tests and + // the trace). + std::vector netHits() const; + // The host's standings for the player (mmGameMulti::UpdateScore on this + // machine: "Place: n/N"). + void setNetStanding(int place, int racers); + // The host's finish for the player (mmGameMulti::SendFinishAck to it: + // its line and its results), or kNetDnf. + void netFinished(float seconds); + // 0x1fe from the host: the race is over for everyone still racing + // ("Race over"; a Blitz with the net alert). + void netTimedOut(); + // 0x211 from the host: every player is counted, the results follow. + void netAllCounted() { m_netAllCounted = true; } + bool netFinishKnown() const { return m_netFinishKnown; } + // Where to put the player after Respawn. Mat34 respawnTransform() const { return m_respawn; } @@ -403,6 +450,17 @@ class Session { int m_resultPosition = 0; float m_resultTime = 0.0f; float m_resultDamage = 0.0f; + // SessionOptions::netRules. + struct NetHit { + std::uint8_t index = 0; + std::uint32_t seq = 0; // this machine's sample it was hit in (or the host's word came) + }; + std::vector m_netHits; // every hit m_wp took, in order + std::uint32_t m_netSample = 0; + std::uint32_t m_netEvaluated = 0; + bool m_netFinishKnown = false; + bool m_netAllCounted = false; + bool netRules() const { return m_options.netRules && multiplayer(); } // Crash course. int m_lessonEvent = 0; diff --git a/src/net/RulesState.h b/src/net/RulesState.h new file mode 100644 index 0000000..d7d6ce2 --- /dev/null +++ b/src/net/RulesState.h @@ -0,0 +1,211 @@ +#pragma once + +// The host's rules in a network race (OpenMM2's own protocol, version 8; see +// docs/multiplayer.md "Rules"). The host simulates every player's car, so it +// decides the rules from its cars: in a race each car's checkpoints, laps, +// finish and time, the finish timeout, the end and the standings; in Cops +// and Robbers the gold's pickups, drops and deliveries, the scores, the new +// places and the limits. MM2's machines each decided their own car's and +// told the others (mmGameMulti::SendPosition's waypoint count, +// SendFinishReq; mmMultiCR's 0x25e, 0x259, 600); the host acknowledged +// finishes and pickups and drew the places (SendFinishAck 0x1f7, +// SendGoldAck 0x25a, SendChangeSet 0x261) and ran the timeouts and limits +// (0x1fe, 0x211, SendLimitReached). +// +// One message, a reliable game event from the host to one player, carries +// what the host decided since its last message to that player (numbered, +// so each decision is shown once) and the whole state as it stands (so a +// player that missed a decision, or joined the race late, still has it). +// The host refuses every rule event a player sends (NetGame's event +// filter), and a player takes this one from the host only. + +#include "net/Protocol.h" + +#include +#include +#include +#include + +namespace mm2::net { + +inline constexpr std::uint16_t kRulesEvent = static_cast(GameEventType::Custom) + 0x40; + +inline constexpr std::size_t kMaxRuleDecisions = 8; // per message (more wait for the next) +inline constexpr std::size_t kMaxRuleHits = 256; // waypoints hit, per message (the newest) +inline constexpr std::uint32_t kRuleDnfMs = 86400000; // MM2's 86400 s: did not finish +inline constexpr float kMaxRulePosition = 16384.0f; +inline constexpr std::int32_t kMaxRuleScore = 1 << 20; + +enum class RuleDecision : std::uint8_t { + Finished, // `player` finished in `ms` (kRuleDnfMs: did not) (0x1f7) + TimedOut, // the race is over for everyone still racing (0x1fe) + AllCounted, // every player is counted: the results (0x211) + GoldTaken, // `player` has the gold (0x25a) + GoldDropped, // `player` lost it at `position` (`flag`: knocked loose by a hit) (0x259) + GoldDelivered, // `player` delivered it (600) + NewSet, // the bank, gold and hideout at new places (0x261) + Limit, // the game is over (`flag`: a point limit, `player` and `value` the winner's; else the + // time) (SendLimitReached) + Last = Limit, +}; + +struct RuleDecisionMsg { + std::uint32_t seq = 0; // the host's number for it, from 1, the same for every player + RuleDecision type{}; + std::uint8_t player = kInvalidPlayerId; + std::uint32_t ms = 0; + Vec3 position; + bool flag = false; + std::int32_t value = 0; + Vec3 gold, bank, hideout; + + bool operator==(const RuleDecisionMsg&) const = default; +}; + +struct RulesMsg { + std::uint32_t race = 0; // the race's number (a message for another race is dropped) + std::uint32_t seq = 0; // this player's messages in the race, from 1 + std::uint32_t evaluated = 0; // the last sample of this player's car the rules have seen + std::vector decisions; + bool cops = false; // Cops and Robbers' state follows, else a race's + + // A race: this player's car's waypoints hit, in order, from hit number + // `firstHit`; its place and the racers (mmGameMulti::UpdateScore on its + // machine); the other cars' icon numbers as its machine ranks them (0: + // none); the results so far by time (mmGameMulti::SortResults). + std::uint32_t firstHit = 0; + std::vector hits; + std::uint8_t place = 1, racers = 1; + std::vector> icons; + std::vector> results; + bool timedOut = false, allCounted = false; + + // Cops and Robbers: who carries the gold (kInvalidPlayerId: nobody), + // whether it can be taken, where it is, the places, every player's + // score, the game over. + std::uint8_t carrier = kInvalidPlayerId; + bool goldActive = true; + Vec3 goldPosition, bank, setGold, hideout; + std::vector> scores; + bool over = false; + + bool operator==(const RulesMsg&) const = default; +}; + +namespace detail { + +template +bool rulePosition(S& s, Vec3& v) { + s.vec3(v); + if (std::fabs(v.x) > kMaxRulePosition || std::fabs(v.y) > kMaxRulePosition || std::fabs(v.z) > kMaxRulePosition) + return s.fail(); + return s.ok(); +} + +template +bool rulePlayer(S& s, std::uint8_t& id, bool optional) { + s.u8(id); + if (id == kInvalidPlayerId && !optional) + return s.fail(); + return s.ok(); +} + +template +bool ruleList(S& s, std::vector& list, std::size_t max, F each) { + auto count = static_cast(list.size()); + s.varU32(count); + if (count > max) + return s.fail(); + if constexpr (S::kReading) + list.resize(count); + for (auto& item : list) + if (!each(item)) + return s.fail(); + return s.ok(); +} + +} // namespace detail + +template +bool serialize(S& s, RuleDecisionMsg& d) { + s.u32(d.seq); + s.enumeration(d.type, RuleDecision::Last); + switch (d.type) { + case RuleDecision::Finished: + detail::rulePlayer(s, d.player, false); + s.u32(d.ms); + if (d.ms > kRuleDnfMs) + return s.fail(); + break; + case RuleDecision::TimedOut: + case RuleDecision::AllCounted: break; + case RuleDecision::GoldTaken: + case RuleDecision::GoldDelivered: detail::rulePlayer(s, d.player, false); break; + case RuleDecision::GoldDropped: + detail::rulePlayer(s, d.player, false); + detail::rulePosition(s, d.position); + s.boolean(d.flag); + break; + case RuleDecision::NewSet: + detail::rulePosition(s, d.gold); + detail::rulePosition(s, d.bank); + detail::rulePosition(s, d.hideout); + break; + case RuleDecision::Limit: + s.boolean(d.flag); + detail::rulePlayer(s, d.player, true); + s.ranged(d.value, 0, kMaxRuleScore); + break; + } + return s.ok(); +} + +template +bool serialize(S& s, RulesMsg& m) { + s.u32(m.race); + s.u32(m.seq); + s.u32(m.evaluated); + detail::ruleList(s, m.decisions, kMaxRuleDecisions, [&](RuleDecisionMsg& d) { return serialize(s, d); }); + s.boolean(m.cops); + if (!s.ok()) + return false; + if (!m.cops) { + s.varU32(m.firstHit); + detail::ruleList(s, m.hits, kMaxRuleHits, [&](std::uint8_t& h) { + s.u8(h); + return s.ok(); + }); + s.u8(m.place); + s.u8(m.racers); + detail::ruleList(s, m.icons, kMaxPlayers, [&](std::pair& e) { + detail::rulePlayer(s, e.first, false); + s.u8(e.second); + return s.ok() && e.second <= kMaxPlayers; + }); + detail::ruleList(s, m.results, kMaxPlayers, [&](std::pair& e) { + detail::rulePlayer(s, e.first, false); + s.u32(e.second); + return s.ok() && e.second <= kRuleDnfMs; + }); + s.boolean(m.timedOut); + s.boolean(m.allCounted); + if (m.place < 1 || m.racers < 1 || m.place > kMaxPlayers || m.racers > kMaxPlayers) + return s.fail(); + return s.ok(); + } + detail::rulePlayer(s, m.carrier, true); + s.boolean(m.goldActive); + detail::rulePosition(s, m.goldPosition); + detail::rulePosition(s, m.bank); + detail::rulePosition(s, m.setGold); + detail::rulePosition(s, m.hideout); + detail::ruleList(s, m.scores, kMaxPlayers, [&](std::pair& e) { + detail::rulePlayer(s, e.first, false); + s.ranged(e.second, 0, kMaxRuleScore); + return s.ok(); + }); + s.boolean(m.over); + return s.ok(); +} + +} // namespace mm2::net From 9ab7293075f366d51d3d973cfbc8b4a126dbd35c Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Fri, 9 Oct 2026 16:21:19 -0700 Subject: [PATCH 055/129] Let the host decide every network race's rules (protocol 8) The race screen wires game::NetRules in. On the host, after every physics sample, the referee sees every player's car (its own, and each client's car as the host simulates it, numbered as that client numbers its inputs); after the frame's rules it applies its word on its own player and sends every other player a reliable rules message (net:: kRulesEvent) at once when something happened and four times a second otherwise. In Cops and Robbers the host runs mmMultiCR's rules for every car (CopsAndRobbers::updateHost: the hits between players' cars it simulates, their wrecks, the resets their water handlers asked for), repairs a deliverer's car itself (600), and gives the cars it simulates the gold's mass, throttle cap and regeneration its own rules say. A client takes the host's messages only: it predicts its checkpoints and a gold pickup, and the host's word confirms or corrects them; its finish, standings, the finish timeout and the end are the host's. It no longer sends a checkpoint, a finish or a gold event, and the host refuses those and Cops and Robbers' old messages from any player (NetGame's event filter). The game event tests use other events. OPENMM2_DEBUG_AUTOPILOT=[,] (game/net/Autopilot) drives the player's car along the race's opponent line with MM2's racer AI through the car's inputs, and OPENMM2_NET_TRACE gains the rules lines (game/net/RulesTrace.h), for automated runs. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/app/RaceScreen.cpp | 411 +++++++++++++++++----------- src/game/net/Autopilot.cpp | 68 +++++ src/game/net/Autopilot.h | 56 ++++ src/game/net/NetGame.cpp | 6 + src/game/net/NetRules.cpp | 26 +- src/game/net/NetRules.h | 10 + src/game/net/RulesTrace.cpp | 57 ++++ src/game/net/RulesTrace.h | 46 ++++ src/game/session/CopsAndRobbers.cpp | 6 +- src/game/session/CopsAndRobbers.h | 7 +- src/game/session/RaceReferee.cpp | 7 +- src/game/session/RaceReferee.h | 3 +- src/game/session/Session.cpp | 12 +- src/game/session/Session.h | 1 + src/game/session/Types.h | 5 + src/game/session/Waypoints.cpp | 4 +- src/net/Protocol.h | 2 +- src/net/RulesState.h | 3 +- tests/game/test_netgame.cpp | 18 +- tests/game/test_netgame_lobby.cpp | 6 +- 20 files changed, 573 insertions(+), 181 deletions(-) create mode 100644 src/game/net/Autopilot.cpp create mode 100644 src/game/net/Autopilot.h create mode 100644 src/game/net/RulesTrace.cpp create mode 100644 src/game/net/RulesTrace.h diff --git a/src/app/RaceScreen.cpp b/src/app/RaceScreen.cpp index 7aaa246..4b270d7 100644 --- a/src/app/RaceScreen.cpp +++ b/src/app/RaceScreen.cpp @@ -41,11 +41,14 @@ #include "game/fx/SkidMarks.h" #include "game/fx/VehicleEffects.h" #include "game/fx/Weather.h" +#include "game/net/Autopilot.h" #include "game/net/DamageSync.h" #include "game/net/NetGame.h" +#include "game/net/NetRules.h" #include "game/net/NetTrafficCars.h" #include "game/net/PlayerCars.h" #include "game/net/RaceStart.h" +#include "game/net/RulesTrace.h" #include "game/net/TrafficProxies.h" #include "game/net/TrafficSync.h" #include "game/net/TrafficTrace.h" @@ -82,36 +85,6 @@ namespace mm2::app { namespace { -// Cops and Robbers' own messages (mmMultiCR's 0x25e pickup request and 0x261 -// ChangeSet) as OpenMM2 game events. -constexpr auto kCrPickupRequest = static_cast(static_cast(net::GameEventType::Custom) + 1); -constexpr auto kCrNewSet = static_cast(static_cast(net::GameEventType::Custom) + 2); -// mmMultiCR::SendLimitReached: the host's word that the time ran out or a -// point limit was reached (OpenMM2: with the winner and its points). -constexpr auto kCrLimit = static_cast(static_cast(net::GameEventType::Custom) + 3); -struct CrLimitEvent { - std::uint8_t pointLimit = 0; // 0: the time ran out - std::int32_t car = -1; - std::int32_t value = 0; -}; -template -bool serialize(S& s, CrLimitEvent& e) { - s.u8(e.pointLimit); - s.ranged(e.car, -1, static_cast(net::kMaxPlayers) - 1); - s.ranged(e.value, 0, 1 << 20); - return s.ok(); -} -struct CrSetEvent { - Vec3 gold, bank, hideout; -}; -template -bool serialize(S& s, CrSetEvent& e) { - s.vec3(e.gold); - s.vec3(e.bank); - s.vec3(e.hideout); - 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). @@ -448,8 +421,10 @@ class RaceScreen final : public Screen { updateAudio(ctx, static_cast(dt)); updateEffects(static_cast(dt)); sendNetDamage(ctx); // OpenMM2: what this machine's cars painted and broke + receiveNetRules(ctx); // OpenMM2: a client takes the host's word on the rules updateSession(ctx, static_cast(dt)); updateCopsAndRobbers(ctx, static_cast(dt)); + sendNetRules(ctx, static_cast(dt)); // OpenMM2: the host decides them // Development aid: OPENMM2_DEBUG_FOCUS=ped|car frames the nearest // pedestrian or traffic car (for screenshots); net: a // network player's car (this machine's own, or another's as drawn), @@ -1148,6 +1123,7 @@ class RaceScreen final : public Screen { if (m_player) m_player->sim().damage.enabled = m_session->playerDamageEnabled(); setupCopsAndRobbers(ctx); + beginNetRules(ctx); // mmPlayer::SetPreRaceCam (every single-player mode but cruise). if (m_result.config.mode != game::GameMode::Cruise && !multiplayer(ctx)) m_cams.startPreRace(); @@ -1410,6 +1386,8 @@ class RaceScreen final : public Screen { opts.scoringBias = info->scoringBias; opts.playerName = ctx.settings.playerName; opts.netHost = multiplayer(ctx) && ctx.netGame->isHost(); + // OpenMM2: the host decides the rules (game/net/NetRules). + opts.netRules = multiplayer(ctx); // mmMultiRoam / mmMultiCR: RespawnXYZ draws the start from a random // stream seeded with the player's id (MM2: its DirectPlay id), so // every player starts elsewhere. @@ -2068,11 +2046,20 @@ class RaceScreen final : public Screen { m_session->setPreRaceCamera(m_cams.preRace()); // The multiplayer countdown (2.5 s) starts with the host's start // message; OpenMM2's follows the shared start time (updateNetStart). - if (multiplayer(ctx)) + if (multiplayer(ctx)) { m_session->setNetStart(m_netToGo); - else + m_session->setNetSample(ctx.netGame->isHost() ? m_netOwnSample : m_prediction.nextSeq() - 1); + } else { m_session->setStartSignal(true); + } m_session->update(dt, m_playerState, opps, cops); + if (multiplayer(ctx) && + (m_session->position() != m_tracedPlace || m_session->racerCount() != m_tracedRacers)) { + m_tracedPlace = m_session->position(); + m_tracedRacers = m_session->racerCount(); + game::traceRuleStanding(ctx.netGame->traceFile(), ctx.netGame->frameTime(), m_tracedPlace, + m_tracedRacers); + } // DisableRacers / EnableRacers: the player's vehCarDamage switch. m_player->sim().damage.enabled = m_session->playerDamageEnabled(); // mmSingleStunt::UpdateEvade turns the map on during its first line. @@ -2246,13 +2233,15 @@ class RaceScreen final : public Screen { // line for the checkpoint (mmCCSpeech::PlayCheckPoint, 0.01 s). if (m_announcerOk && m_result.config.mode == game::GameMode::CrashCourse) m_announcer.playCrashCourseCheckPoint(e.index, 0.01f); - // mmGameMulti::SendPosition carries the waypoint count - // (mmPlayer +0x2254) for the others' standings. + // OpenMM2: the host counts every car's waypoints itself + // (MM2's SendPosition carried each machine's own count). if (multiplayer(ctx)) - ctx.netGame->sendCheckpoint(m_session->waypointsPassed(), - static_cast(m_session->raceTime() * 1000.0f)); - } else if (e.type == EventType::NetFinished && multiplayer(ctx)) { - ctx.netGame->sendFinish(static_cast(e.value * 1000.0f), 0); + game::traceRuleShown(ctx.netGame->traceFile(), ctx.netGame->frameTime(), + ctx.netGame->localId(), e.index, m_session->waypointsPassed(), + e.value > 0.0f ? 1 : 0); + } else if (e.type == EventType::CheckpointTakenBack && multiplayer(ctx)) { + game::traceRuleTakenBack(ctx.netGame->traceFile(), ctx.netGame->frameTime(), + ctx.netGame->localId(), e.index); } else if (e.type == EventType::FinalCheckpoint || e.type == EventType::FinalLap) { // mmWaypoints::Update: the last stretch switches the music to // the cop chase segment; the final checkpoint is announced @@ -2576,16 +2565,6 @@ class RaceScreen final : public Screen { m_announcer.beginCopsAndRobbers(); } - // mmMultiCR::FondleCarMass: the gold's mass on the carrier - // (phInertialCS::Init with the mass changed) and its throttle cap. - // (OpenMM2: the car takes it with its inputs, game::NetCarDriver, on - // the host too.) - void fondleMass(float kg) { - const long gold = std::lround(static_cast(m_netGold) + kg); - m_netGold = static_cast(std::clamp(gold, 0L, static_cast(net::kMaxExtraMass))); - 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(); @@ -2601,46 +2580,13 @@ class RaceScreen final : public Screen { return r; } - void sendCr(Context& ctx, const std::vector& messages) { - using Type = game::session::CopsAndRobbers::Message::Type; - for (const auto& m : messages) { - switch (m.type) { - case Type::PickupRequest: - ctx.netGame->sendEvent(kCrPickupRequest, - net::encodePayload(net::GoldEvent{m.position, static_cast(m.car)}), - net::kHostPlayerId); - break; - case Type::GoldTaken: ctx.netGame->sendGold(net::GameEventType::GoldPickedUp, m.position, m.car); break; - case Type::GoldDropped: ctx.netGame->sendGold(net::GameEventType::GoldDropped, m.position, m.car); break; - case Type::GoldDelivered: - ctx.netGame->sendGold(net::GameEventType::GoldDelivered, m.position, m.car); - break; - case Type::NewSet: - ctx.netGame->sendEvent(kCrNewSet, net::encodePayload(CrSetEvent{m.set.gold, m.set.bank, m.set.hideout})); - break; - } - } - } - void updateCopsAndRobbers(Context& ctx, float dt) { if (!m_cr || !m_player || !m_session) return; using game::session::CopsAndRobbers; - using Type = CopsAndRobbers::Message::Type; const bool host = ctx.netGame->isHost(); // mmPlayer::UpdateRegen while regeneration is on: the car's input // carries it (net::kInputRegen; game::NetCarDriver). - // mmMultiCR::UpdateGame for the local car, with the others' places. - std::vector cars; - // mmMultiCR::HitWaterHandler / DropThruCityHandler drop the gold back - // at its place when the water handler fires (5 s in the water, or a - // 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 : m_remoteCars) - if (rc.hasState) - cars.push_back({rc.id, m_cr->teamOf(rc.id), rc.transform.m3, (rc.flags & net::kVehicleWrecked) != 0, - false}); // 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, @@ -2651,9 +2597,8 @@ class RaceScreen final : public Screen { const double raceClock = ctx.netGame->raceStartKnown() ? std::max(0.0, sinceStart / 1000.0) : 0.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. + std::vector left; { std::set now; for (const auto& p : ctx.netGame->players()) @@ -2661,58 +2606,72 @@ class RaceScreen final : public Screen { now.insert(p.id); for (const auto id : m_crPlayers) if (!now.contains(id)) - sendCr(ctx, m_cr->playerLeft(id, host)); + left.push_back(id); m_crPlayers = std::move(now); } - // The others' messages (mmMultiCR::GameMessage). - for (const auto& ev : m_netEvents) { - CopsAndRobbers::Message m; - const auto type = static_cast(ev.type); - if (type == kCrPickupRequest) { - m.type = Type::PickupRequest; - m.car = ev.from; - } else if (type == kCrNewSet) { - const auto set = ev.as(); - if (!set) - continue; - m.type = Type::NewSet; - m.set = {set->bank, set->gold, set->hideout}; - } else if (type == kCrLimit) { - // GameMessage: the host's limit (taken from the host only). - const auto limit = ev.as(); - if (limit && ev.from == net::kHostPlayerId && !host) - m_cr->limitReached(limit->pointLimit ? CopsAndRobbers::EventType::PointLimit - : CopsAndRobbers::EventType::TimeUp, - limit->car, limit->value); - continue; - } else if (const auto g = ev.as(); g && (ev.type == net::GameEventType::GoldPickedUp || - ev.type == net::GameEventType::GoldDropped || - ev.type == net::GameEventType::GoldDelivered)) { - m.type = ev.type == net::GameEventType::GoldPickedUp ? Type::GoldTaken - : ev.type == net::GameEventType::GoldDropped ? Type::GoldDropped - : Type::GoldDelivered; - m.car = g->team; - m.position = g->position; - } else { - continue; + const Vec3 own = m_player->sim().body.ics.matrix.m3; + if (host) { + // OpenMM2: the host simulates every player's car, so it runs + // mmMultiCR's rules for each as that car's own machine did + // (CopsAndRobbers::updateHost) and tells everyone + // (game/net/NetRules). mmMultiCR::HitWaterHandler / + // DropThruCityHandler drop the gold back at its place when the + // water handler fires (5 s in the water, or a fall out of the + // city): this car's, or a reset another player's car took. + std::vector cars; + cars.push_back( + {m_crSelf, m_crMyTeam, own, m_playerState.wrecked, std::exchange(m_crWaterHandled, false)}); + for (const auto& [id, rv] : m_remotes) + if (rv.simulated && rv.placed && rv.sim && !m_netLeft.contains(id)) + cars.push_back({id, m_cr->teamOf(id), rv.sim->sim().body.ics.matrix.m3, + rv.sim->sim().damage.wrecked(), m_crResets.contains(id)}); + m_crResets.clear(); + auto decisions = m_cr->updateHost(ruleDt, cars, m_crImpacts); + for (const auto id : left) { + const auto dropped = m_cr->playerLeft(id, true); + decisions.insert(decisions.end(), dropped.begin(), dropped.end()); } - sendCr(ctx, m_cr->receive(m, ev.from, host)); + for (const auto& d : decisions) + if (d.type == CopsAndRobbers::Message::Type::GoldDelivered && d.car != m_crSelf && d.car >= 0) + repairRemoteCar(static_cast(d.car)); + m_netRules.hostDecided(decisions); + } else { + // The host's word came with the frame's events + // (receiveNetRules); this machine predicts only its own car + // taking free gold, which the host confirms or undoes. + std::vector cars; + cars.push_back({m_crSelf, m_crMyTeam, own, m_playerState.wrecked, false}); + for (const auto& rc : m_remoteCars) + if (rc.hasState) + cars.push_back({rc.id, m_cr->teamOf(rc.id), rc.transform.m3, + (rc.flags & net::kVehicleWrecked) != 0, false}); + int players = 0; + for (const auto& p : ctx.netGame->players()) + players += m_netLeft.contains(p.id) ? 0 : 1; + if (m_cr->updatePredicted(ruleDt, cars.front(), cars, players)) + m_netRules.predictedPickup(m_prediction.nextSeq() - 1); + m_crWaterHandled = false; } + m_crImpacts.clear(); // What happened, on this machine's HUD and car. auto name = [&](int id) { const auto* p = ctx.netGame->player(static_cast(id)); return p ? p->name : std::string(); }; const auto& s = ctx.game->strings; + std::FILE* trace = ctx.netGame->traceFile(); for (const auto& e : m_cr->takeEvents()) { using E = CopsAndRobbers::EventType; const bool me = e.car == m_crSelf; + if (e.type != E::TimeWarning) + game::traceCopsEvent(trace, ctx.netGame->frameTime(), static_cast(e.type), e.car, + e.value); switch (e.type) { case E::GoldTaken: if (me) { - // StealGold: the mass, no regeneration, "You have the Gold!". - fondleMass(m_cr->carrierExtraMassKg()); - m_regen = false; + // StealGold: the mass, no regeneration (below), "You + // have the Gold!" (OpenMM2: a client shows it when it + // predicts the pickup, MM2 when the host's 0x25a came). m_session->showMessage(s.get(host ? 115 : 134, "You have the Gold!"), 5.0f, false); } else { m_session->showMessage(std::format("{} {}", name(e.car), s.get(135, "has the Gold!")), 5.0f, false); @@ -2720,8 +2679,6 @@ class RaceScreen final : public Screen { break; case E::GoldDropped: if (me) { - fondleMass(-m_cr->carrierExtraMassKg()); - m_regen = true; // Only ImpactCallback says so; the wreck and the water // drop it without a line. if (e.value == 1) @@ -2735,10 +2692,11 @@ class RaceScreen final : public Screen { break; case E::GoldDelivered: if (me) { - // UpdateBank / UpdateHideout: regeneration, a repaired car. - fondleMass(-m_cr->carrierExtraMassKg()); - m_regen = true; - netCommand({0, net::CarCommandKind::ClearDamage, {}, 0.0f}); + // UpdateBank / UpdateHideout: regeneration, a repaired + // car (OpenMM2: the host repairs the car it simulates, + // and a client's follows its states). + if (host) + netCommand({0, net::CarCommandKind::ClearDamage, {}, 0.0f}); clearVehicleDamage(); m_session->showMessage(s.get(117, "Gold delivered!"), 5.0f, false); } else { @@ -2759,17 +2717,37 @@ class RaceScreen final : public Screen { } case E::TimeUp: case E::PointLimit: - // SendLimitReached: the host tells the others. - if (host) - ctx.netGame->sendEvent(kCrLimit, net::encodePayload(CrLimitEvent{ - static_cast(e.type == E::PointLimit), - e.car, e.value})); - // UpdateLimit: the message for 3 s, then 3 s to the results + // SendLimitReached: the host tells the others (its rules + // message). UpdateLimit: the message for 3 s, then 3 s to the results // (state 9). m_session->showMessage(s.get(e.type == E::TimeUp ? 118 : 119, ""), 3.0f, false); m_crEnd = 3.0f; break; case E::NewSet: break; + case E::PickupUndone: + log::info("race: the host did not give this car the gold it reached"); + break; + } + } + // mmMultiCR::FondleCarMass and mmPlayer::EnableRegen: the gold's mass + // and throttle cap on the car that carries it, the regeneration on + // every other (this machine's car takes them with its inputs; the + // host's own rules set them on the cars it simulates). + { + const bool carrying = m_cr->goldCarrier() == m_crSelf; + const float kg = carrying ? m_cr->carrierExtraMassKg() : 0.0f; + m_netGold = static_cast( + std::clamp(std::lround(kg), 0L, static_cast(net::kMaxExtraMass))); + m_throttleCap = carrying && kg > 0.0f ? m_cr->carrierThrottleCap() : 1.0f; + m_regen = !carrying; + } + if (trace) { + std::vector> scores; + for (const auto& p : ctx.netGame->players()) + scores.emplace_back(p.id, m_cr->playerScore(p.id)); + if (scores != m_tracedScores) { + game::traceCopsScores(trace, ctx.netGame->frameTime(), scores); + m_tracedScores = std::move(scores); } } if (m_crEnd >= 0.0f) { @@ -3030,46 +3008,137 @@ class RaceScreen final : public Screen { ctx.nextScreen = makeFrontendScreen(ctx, result); } - // The multiplayer races' exchange (mmGameMulti, mmMultiRace / Circuit / - // Blitz::GameMessage): the other players' finishes and their waypoint - // counts (which MM2 sends in every position packet) for the standings. + // The multiplayer races' standings (mmGameMulti::UpdateScore's icons): + // OpenMM2's host decides the finishes and ranks every car + // (game/net/NetRules); the other players' icons show its numbers. void updateNetRace(Context& ctx) { const auto mode = m_result.config.mode; if (!multiplayer(ctx) || !m_session || !(mode == game::GameMode::Blitz || mode == game::GameMode::Circuit || mode == game::GameMode::Checkpoint)) return; - for (const auto& ev : m_netEvents) { - if (ev.type == net::GameEventType::CheckpointReached) { - if (const auto e = ev.as()) - m_netWaypoints[ev.from] = e->index; - } else if (ev.type == net::GameEventType::RaceFinished) { - const auto e = ev.as(); - const auto* p = ctx.netGame->player(ev.from); - if (!e || !p || m_netFinished.contains(ev.from)) - continue; - m_netFinished.insert(ev.from); - const float seconds = static_cast(e->raceTime) * 0.001f; - m_session->remoteFinished(p->name, std::min(seconds, game::session::Session::kNetDnf)); - } - } std::vector racers; for (const auto& p : ctx.netGame->players()) { if (p.id == ctx.netGame->localId() || m_netLeft.contains(p.id)) continue; game::session::Session::NetRacer r; r.name = p.name; - if (const auto it = m_netWaypoints.find(p.id); it != m_netWaypoints.end()) - r.waypoints = it->second; const auto it = m_remotes.find(p.id); r.present = it != m_remotes.end() && it->second.sim; if (r.present) r.position = it->second.sim->sim().body.ics.matrix.m3; - r.finished = m_netFinished.contains(p.id); + r.finished = m_netRules.finished(p.id); + r.hostPlace = m_netRules.iconPlace(p.id); racers.push_back(std::move(r)); } m_session->setNetRacers(std::move(racers)); } + // --- OpenMM2: the rules the host decides (game/net/NetRules) --------------------------- + + void beginNetRules(Context& ctx) { + if (!multiplayer(ctx) || !m_session) + return; + const auto mode = m_result.config.mode; + game::NetRules::Setup s; + s.host = ctx.netGame->isHost(); + s.self = ctx.netGame->localId(); + s.race = m_netRace; + const bool race = mode == game::GameMode::Blitz || mode == game::GameMode::Circuit || + mode == game::GameMode::Checkpoint; + if (race) + s.session = m_session.get(); + else + s.cops = m_cr.get(); + for (const auto& p : ctx.netGame->players()) + s.players.push_back(p.id); + s.sampleSeconds = phys::kFixedSampleStep; + m_netRules.begin(s); + // Development aid: OPENMM2_DEBUG_AUTOPILOT=[,] drives this + // machine's car along the race's opponent line through its inputs + // (game/net/Autopilot.h), for automated runs. + if (const char* ap = std::getenv("OPENMM2_DEBUG_AUTOPILOT"); ap && m_player && m_world && s.session) { + const auto parts = str::split(ap, ','); + const int line = static_cast(str::parseInt(parts.empty() ? "" : parts[0]).value_or(0)); + const float speed = + parts.size() > 1 ? static_cast(str::parseDouble(parts[1]).value_or(0.0)) : 0.0f; + std::string error; + m_autopilot = game::NetAutopilot::create(*m_city, ctx.game->vfs, m_session->setup(), + m_player->sim(), line, speed, m_world.get(), &error); + if (m_autopilot) + log::info("race: the autopilot drives opponent line {}", line); + else + log::warn("race: no autopilot: {}", error); + } + } + + // Client: the host's word on the rules among the frame's events. + void receiveNetRules(Context& ctx) { + if (!multiplayer(ctx) || !m_netRules.active()) + return; + for (const auto& p : ctx.netGame->players()) + m_netRules.setName(p.id, p.name); + m_netRules.setTrace(ctx.netGame->traceFile(), ctx.netGame->frameTime()); + if (!ctx.netGame->isHost() && m_player) + m_netRules.receive(m_netEvents, m_player->sim().body.ics.matrix.m3); + } + + // Host: its referee's frame, its word on its own player, each player's message. + void sendNetRules(Context& ctx, float dt) { + if (!multiplayer(ctx) || !m_netRules.active() || !ctx.netGame->isHost() || !m_player) + return; + for (const auto id : m_netRules.players()) + if (m_netLeft.contains(id) || !ctx.netGame->player(id)) + m_netRules.playerLeft(id); + m_netRules.hostUpdate(dt, net::monotonicMs(), m_player->sim().body.ics.matrix.m3, + [&ctx](std::uint8_t player, std::vector payload) { + ctx.netGame->sendEvent(net::kRulesEvent, std::move(payload), player); + }); + } + + // Host: after every physics sample, every player's car for the referee. + void netRulesSample() { + if (!m_player || !m_traceNet) + return; + ++m_netOwnSample; + m_netRules.hostSample(m_traceNet->localId(), m_netOwnSample, m_player->sim().body.ics.matrix, + m_player->sim().params.inertiaBox, (m_netInput.flags & net::kInputHeld) != 0); + for (const auto& [id, rv] : m_remotes) + if (rv.simulated && rv.placed && rv.sim) + m_netRules.hostSample(id, rv.inputs.lastApplied(), rv.sim->sim().body.ics.matrix, + rv.sim->sim().params.inertiaBox, + (rv.input.flags & net::kInputHeld) != 0); + } + + // Host: the gold's mass, its throttle cap and the regeneration on a + // player's car it simulates are its own rules' (who carries the gold), + // whatever that player's input says (mmMultiCR::FondleCarMass, + // mmPlayer::EnableRegen). + void hostGoldInput(std::uint8_t id, net::CarInputFrame& frame) const { + if (!m_cr) + return; + const bool carrying = m_cr->goldCarrier() == id; + const float kg = carrying ? m_cr->carrierExtraMassKg() : 0.0f; + const long mass = std::clamp(std::lround(kg), 0L, static_cast(net::kMaxExtraMass)); + frame.extraMass = static_cast(mass); + const long cap = std::lround(m_cr->carrierThrottleCap() * 255.0f); + frame.throttleCap = + carrying && kg > 0.0f ? static_cast(std::clamp(cap, 0L, 255L)) : std::uint8_t{255}; + frame.flags = carrying ? static_cast(frame.flags & ~net::kInputRegen) + : static_cast(frame.flags | net::kInputRegen); + } + + // Host: 600 on every machine (mmMultiCR::GameMessage): the deliverer's + // car repaired (vehCar::ClearDamage), here on the car the host simulates. + void repairRemoteCar(std::uint8_t id) { + const auto it = m_remotes.find(id); + if (it == m_remotes.end() || !it->second.sim) + return; + const net::CarCommand repair{it->second.inputs.lastApplied(), net::CarCommandKind::ClearDamage, {}, + 0.0f}; + game::NetCarDriver::command(*it->second.sim, repair); + hostCarReset(id, it->second); + } + void buildPopup(Context& ctx) { // mmPopup(game, 0.2, 0.1, 0.6, 0.8): the popup card covers x 0.2-0.8 // and y 0.1-0.9 of the screen. PUMain's buttons sit at 0.125, 0.25, @@ -4841,6 +4910,18 @@ class RaceScreen final : public Screen { rv.fx->impact(impact, rv.sim->sim()); if (impact.damaging) m_netDamage.player(id).impact(m_netStateTime, game::damageImpactOf(impact, rv.sim->sim())); + // mmMultiCR::ImpactCallback for this car: a damaging hit from another + // player's car. + if (m_cr && impact.damaging && impact.otherBody) { + std::optional other; + if (m_player && impact.otherBody == &m_player->sim().body && m_traceNet) + other = m_traceNet->localId(); + for (const auto& [oid, orv] : m_remotes) + if (orv.sim && &orv.sim->sim().body == impact.otherBody) + other = oid; + if (other && *other != id) + m_crImpacts.push_back({id, *other, impact.total}); + } traceNetImpact(id, impact); } @@ -4878,6 +4959,9 @@ class RaceScreen final : public Screen { for (const auto& c : next->commands) { if (!netCommandAllowed(rv, c)) continue; + // mmMultiCR::HitWaterHandler / DropThruCityHandler on that machine. + if (c.kind == net::CarCommandKind::Reset || c.kind == net::CarCommandKind::RespawnAt) + m_crResets.insert(id); game::NetCarDriver::command(*rv.sim, c); if (c.kind == net::CarCommandKind::ResetTo || c.kind == net::CarCommandKind::RespawnAt) rv.lastMoveSeq = c.seq; @@ -4887,6 +4971,7 @@ class RaceScreen final : public Screen { // Nobody goes before the start, whatever its input says. if (m_netHeld) next->frame.flags |= net::kInputHeld; + hostGoldInput(id, next->frame); if (rv.driver.apply(*rv.sim, next->frame)) hostCarReset(id, rv); rv.input = next->frame; @@ -4907,8 +4992,12 @@ class RaceScreen final : public Screen { m_prediction.beginSample(*m_player, m_netDriver, in); } void afterNetSample() { - if (!m_player || !m_traceNet || m_traceNet->isHost()) + if (!m_player || !m_traceNet) return; + if (m_traceNet->isHost()) { + netRulesSample(); // OpenMM2: the host's referee sees every car after every sample + return; + } // Development aid: OPENMM2_DEBUG_NETCARS_NOISE= nudges a // client's car's momentum by about that fraction each sample, as a // machine built by another compiler might round differently. @@ -5437,6 +5526,15 @@ class RaceScreen final : public Screen { pedals.steering = m_gameInput.filterAxis(clampf(f(2), -1.0f, 1.0f), dt); pedals.handbrake = f(3); } + if (m_autopilot && m_player) { + const bool go = (!multiplayer(ctx) || ctx.netGame->raceStarted()) && + !(m_session && m_session->playerHeld()); + const auto c = m_autopilot->drive(dt, m_player->sim(), !go); + pedals.accelerator = c.throttle; + pedals.brake = c.brake; + pedals.steering = c.steering; + pedals.handbrake = 0.0f; + } // ... and records them, and the car is driven with the recorded // values (bytes). pedals = controls::replayQuantize(pedals); @@ -6052,8 +6150,6 @@ class RaceScreen final : public Screen { std::map m_remoteStepBodies; // their bodies in the last sample (recordStepPoses) 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) bool m_netPlayersKnown = false; std::uint32_t m_netRace = 0; // NetGame::raceNumber() of this race @@ -6091,6 +6187,13 @@ class RaceScreen final : public Screen { std::optional m_netStart; // from the end of the loading (updateNetStart) bool m_netHeld = true; // its car hold std::optional m_netToGo; // its countdown (Session::setNetStart) + // OpenMM2: the rules the host decides (game/net/NetRules, docs/multiplayer.md "Rules"). + game::NetRules m_netRules; + std::uint32_t m_netOwnSample = 0; // host: its own car's samples + std::set m_crResets; // host: cars the water handlers reset this frame + std::unique_ptr m_autopilot; // OPENMM2_DEBUG_AUTOPILOT + int m_tracedPlace = 0, m_tracedRacers = 0; // OPENMM2_NET_TRACE's last RE line + std::vector> m_tracedScores; // and CS line bool multiplayer(Context& ctx) const { return m_result.config.multiplayer && ctx.netGame; } std::unique_ptr m_aiRenderer; // A shared-traffic client's received cars as the bodies' traffic diff --git a/src/game/net/Autopilot.cpp b/src/game/net/Autopilot.cpp new file mode 100644 index 0000000..546d2fa --- /dev/null +++ b/src/game/net/Autopilot.cpp @@ -0,0 +1,68 @@ +#include "game/net/Autopilot.h" + +#include "ai/Opponent.h" +#include "ai/World.h" +#include "city/Race.h" +#include "core/StringUtil.h" + +#include + +namespace mm2::game { + +NetAutopilot::~NetAutopilot() = default; + +std::unique_ptr NetAutopilot::create(const city::CityData& city, const vfs::Vfs& vfs, + const session::RaceSetup& setup, phys::CarSim& car, + int line, float speedLimit, const phys::GroundQuery* ground, + std::string* error) { + auto fail = [&](std::string text) -> std::unique_ptr { + if (error) + *error = std::move(text); + return nullptr; + }; + if (!setup.race || !setup.aiMap || setup.aiMap->opponents.empty()) + return fail("the race has no opponent lines"); + const auto& lines = setup.aiMap->opponents; + const auto& o = lines[static_cast(std::max(0, line)) % lines.size()]; + const std::string& any = !setup.race->aiMap.empty() ? setup.race->aiMap : setup.race->waypoints; + const std::string file = any.substr(0, any.rfind('/') + 1) + str::lower(o.pathFile); + const auto bytes = vfs.readAll(file); + if (!bytes) + return fail("cannot read " + file); + const std::string_view text(reinterpret_cast(bytes->data()), bytes->size()); + auto path = city::parseOpponentPath(text); + if (!path || path->empty()) + return fail("cannot parse " + file); + ai::Settings settings; + settings.trafficDensity = 0.0f; + settings.pedestrianDensity = 0.0f; + settings.maxPeds = 0; + std::unique_ptr pilot(new NetAutopilot); + pilot->m_ai = ai::World::create(city, vfs, settings, &*setup.aiMap, error); + if (!pilot->m_ai) + return nullptr; + const int laps = std::max(1, setup.laps); + pilot->m_driver = ai::Opponent::create(pilot->m_ai->map(), car, *path, o.params, laps, 1, 0, error, ground, + setup.config.vehicle); + if (!pilot->m_driver) + return nullptr; + if (speedLimit > 0.0f) + pilot->m_driver->setSpeedLimit(speedLimit); + pilot->m_driver->setResetCar([](const Mat34&) {}); // the car is the player's: no AI resets + pilot->m_driver->setHeld(true); + const auto saved = car.saveState(); + pilot->m_driver->reset(); + car.restoreState(saved); + return pilot; +} + +NetAutopilot::Controls NetAutopilot::drive(float dt, phys::CarSim& car, bool held) { + const auto saved = car.saveState(); + m_driver->setHeld(held); + m_driver->update(dt, {}); + const Controls c{car.engine.throttle, car.brakes, car.steering}; + car.restoreState(saved); + return c; +} + +} // namespace mm2::game diff --git a/src/game/net/Autopilot.h b/src/game/net/Autopilot.h new file mode 100644 index 0000000..6775ac3 --- /dev/null +++ b/src/game/net/Autopilot.h @@ -0,0 +1,56 @@ +#pragma once + +// Development aid for automated network races (OPENMM2_DEBUG_AUTOPILOT, +// docs/multiplayer.md "Automation"): the player's car driven by MM2's racer +// AI (ai::Opponent, aiRouteRacer) along one of the race's opponent lines, +// through the car's inputs only. The AI's frame runs on the car and the car +// is put back as it was (the AI also nudges its car's momentum and gear, +// which a network car must not take outside its inputs); what it asked of +// the throttle, brakes and wheel is what the player's controls send. A +// network race has no AI map of its own (mmGameMulti's race modes skip +// aiMap::Init), so the autopilot loads one, with no traffic. + +#include "city/CityData.h" +#include "game/session/RaceSetup.h" +#include "phys/vehicle/CarSim.h" +#include "vfs/Vfs.h" + +#include +#include + +namespace mm2::ai { +class Opponent; +class World; +} // namespace mm2::ai + +namespace mm2::phys { +class GroundQuery; +} + +namespace mm2::game { + +class NetAutopilot { +public: + // The race's opponent `line` (0-based, wrapped to the lines it has) at + // up to `speedLimit` m/s (0: the AI's own). + static std::unique_ptr create(const city::CityData& city, const vfs::Vfs& vfs, + const session::RaceSetup& setup, phys::CarSim& car, int line, + float speedLimit, const phys::GroundQuery* ground, + std::string* error = nullptr); + ~NetAutopilot(); + NetAutopilot(const NetAutopilot&) = delete; + NetAutopilot& operator=(const NetAutopilot&) = delete; + + struct Controls { + float throttle = 0.0f, brake = 0.0f, steering = 0.0f; + }; + // The AI's frame for the car (held: on the grid). + Controls drive(float dt, phys::CarSim& car, bool held); + +private: + NetAutopilot() = default; + std::unique_ptr m_ai; + std::unique_ptr m_driver; +}; + +} // namespace mm2::game diff --git a/src/game/net/NetGame.cpp b/src/game/net/NetGame.cpp index 4c82c5e..24cf6d8 100644 --- a/src/game/net/NetGame.cpp +++ b/src/game/net/NetGame.cpp @@ -1,5 +1,7 @@ #include "game/net/NetGame.h" +#include "game/net/NetRules.h" + #include "core/Log.h" #include "game/Catalog.h" #include "core/StringUtil.h" @@ -230,6 +232,10 @@ bool NetGame::host(const RaceConfig& config, const NetHostOptions& hostOptions, impl.session.reset(); return false; } + // OpenMM2: the host decides the rules; a player's own word on them is + // refused (game/net/NetRules). + impl.session->setEventFilter( + [](std::uint8_t from, net::GameEventMsg& m) { return hostAcceptsGameEvent(from, m.type); }); 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 diff --git a/src/game/net/NetRules.cpp b/src/game/net/NetRules.cpp index 751fde4..5156275 100644 --- a/src/game/net/NetRules.cpp +++ b/src/game/net/NetRules.cpp @@ -1,6 +1,7 @@ #include "game/net/NetRules.h" #include "core/Log.h" +#include "game/net/RulesTrace.h" #include #include @@ -15,7 +16,8 @@ using CrMessage = session::CopsAndRobbers::Message; std::uint32_t toMs(float seconds) { if (!(seconds < session::RaceReferee::kDnf)) return net::kRuleDnfMs; - return std::min(net::kRuleDnfMs - 1, static_cast(std::lround(std::max(0.0f, seconds) * 1000.0f))); + const auto ms = static_cast(std::lround(std::max(0.0f, seconds) * 1000.0f)); + return std::min(net::kRuleDnfMs - 1, ms); } float toSeconds(std::uint32_t ms) { @@ -128,7 +130,8 @@ std::string NetRules::nameOf(std::uint8_t id) const { return it != m_names.end() ? it->second : std::format("Player {}", id); } -void NetRules::hostSample(std::uint8_t id, std::uint32_t seq, const Mat34& car, const Vec3& inertiaBox, bool held) { +void NetRules::hostSample(std::uint8_t id, std::uint32_t seq, const Mat34& car, const Vec3& inertiaBox, + bool held) { if (!m_setup.host || m_left.contains(id)) return; m_carSamples[id] = seq; @@ -163,7 +166,8 @@ void NetRules::apply(const RuleDecisionMsg& d) { session::Session* s = m_setup.session; switch (d.type) { case RuleDecision::Finished: - m_finished.insert(d.player); + if (m_finished.insert(d.player).second) + traceRuleFinish(m_trace, m_frameTime, d.player, d.ms); if (s) { if (d.player == m_setup.self) s->netFinished(toSeconds(d.ms)); @@ -225,10 +229,12 @@ net::RulesMsg NetRules::message(std::uint8_t player, Client& c) const { m.place = static_cast(std::clamp(p->place, 1, static_cast(net::kMaxPlayers))); m.racers = static_cast(std::clamp(p->racers, 1, static_cast(net::kMaxPlayers))); } - for (const auto& [id, q] : m_referee->players()) - if (id != player && q.inRace && m.icons.size() < net::kMaxPlayers) - m.icons.emplace_back(id, static_cast(std::clamp( - m_referee->iconPlace(player, id), 0, static_cast(net::kMaxPlayers)))); + for (const auto& [id, q] : m_referee->players()) { + if (id == player || !q.inRace || m.icons.size() >= net::kMaxPlayers) + continue; + const int icon = std::clamp(m_referee->iconPlace(player, id), 0, static_cast(net::kMaxPlayers)); + m.icons.emplace_back(id, static_cast(icon)); + } for (const auto& r : m_referee->results()) if (m.results.size() < net::kMaxPlayers) m.results.emplace_back(r.player, toMs(r.seconds)); @@ -261,6 +267,12 @@ void NetRules::hostUpdate(float dt, std::uint64_t nowMs, const Vec3& ownPosition } decide(d); } + for (const auto& [id, p] : m_referee->players()) { + std::size_t& traced = m_tracedHits[id]; + for (; traced < p.hits.size(); ++traced) + traceRuleHostHit(m_trace, m_frameTime, id, p.hitSamples[traced], p.hits[traced], + static_cast(traced) + 2, p.wp.lap); + } // The host's own player takes the referee's word as a client does, // with no delay. if (const auto* p = m_referee->player(m_setup.self); p && m_setup.session) { diff --git a/src/game/net/NetRules.h b/src/game/net/NetRules.h index 558e9d0..9c7afdb 100644 --- a/src/game/net/NetRules.h +++ b/src/game/net/NetRules.h @@ -26,6 +26,7 @@ #include "net/RulesState.h" #include +#include #include #include #include @@ -63,8 +64,14 @@ class NetRules { void begin(const Setup& setup); bool active() const { return m_setup.session != nullptr || m_setup.cops != nullptr; } bool host() const { return m_setup.host; } + const std::vector& players() const { return m_setup.players; } // A player's name for the lines (" finished in"); kept after it leaves. void setName(std::uint8_t id, std::string name) { m_names[id] = std::move(name); } + // OPENMM2_NET_TRACE's rules lines (game/net/RulesTrace.h), this frame. + void setTrace(std::FILE* f, double frameTime) { + m_trace = f; + m_frameTime = frameTime; + } // --- Host --------------------------------------------------------------------------- // After each physics sample of a player's car (the host's own too): `seq` @@ -135,6 +142,9 @@ class NetRules { std::map m_icons; bool m_timedOut = false, m_allCounted = false; std::map m_names; + std::FILE* m_trace = nullptr; + double m_frameTime = 0.0; + std::map m_tracedHits; Stats m_stats; }; diff --git a/src/game/net/RulesTrace.cpp b/src/game/net/RulesTrace.cpp new file mode 100644 index 0000000..738c45f --- /dev/null +++ b/src/game/net/RulesTrace.cpp @@ -0,0 +1,57 @@ +#include "game/net/RulesTrace.h" + +#include + +namespace mm2::game { +namespace { + +// The machine's monotonic clock (every process on it shares it). +double machineMs() { + const auto now = std::chrono::steady_clock::now().time_since_epoch(); + return std::chrono::duration(now).count(); +} + +} // namespace + +void traceRuleShown(std::FILE* f, double frame, int player, int index, int count, int kind) { + if (f) + std::fprintf(f, "RS %.3f %.3f %d %d %d %d\n", machineMs(), frame, player, index, count, kind); +} + +void traceRuleTakenBack(std::FILE* f, double frame, int player, int index) { + if (f) + std::fprintf(f, "RX %.3f %.3f %d %d\n", machineMs(), frame, player, index); +} + +void traceRuleHostHit(std::FILE* f, double frame, int player, std::uint32_t sample, int index, int count, + int lap) { + if (f) + std::fprintf(f, "RH %.3f %.3f %d %u %d %d %d\n", machineMs(), frame, player, sample, index, count, + lap); +} + +void traceRuleFinish(std::FILE* f, double frame, int player, std::uint32_t ms) { + if (f) + std::fprintf(f, "RF %.3f %.3f %d %u\n", machineMs(), frame, player, ms); +} + +void traceRuleStanding(std::FILE* f, double frame, int place, int racers) { + if (f) + std::fprintf(f, "RE %.3f %.3f %d %d\n", machineMs(), frame, place, racers); +} + +void traceCopsEvent(std::FILE* f, double frame, int type, int car, int value) { + if (f) + std::fprintf(f, "CG %.3f %.3f %d %d %d\n", machineMs(), frame, type, car, value); +} + +void traceCopsScores(std::FILE* f, double frame, const std::vector>& scores) { + if (!f) + return; + std::fprintf(f, "CS %.3f %.3f %zu", machineMs(), frame, scores.size()); + for (const auto& [id, score] : scores) + std::fprintf(f, " %d %d", id, score); + std::fprintf(f, "\n"); +} + +} // namespace mm2::game diff --git a/src/game/net/RulesTrace.h b/src/game/net/RulesTrace.h new file mode 100644 index 0000000..644d440 --- /dev/null +++ b/src/game/net/RulesTrace.h @@ -0,0 +1,46 @@ +#pragma once + +// Development aid for the rules of a network race: with OPENMM2_NET_TRACE= +// set, each machine writes what its rules showed and decided, one +// line each, next to the players' cars' lines (docs/multiplayer.md, +// "Diagnosing replication"). `netprobe rulesreport` compares a host's trace +// with its clients' (docs/review/multiplayer-desync-rules.md). `clock` is the +// machine's monotonic clock (shared by the processes on one computer), +// `frame` the frame's session time. +// +// RS clock frame player index count kind +// this machine's HUD showed its car clearing waypoint `index` (`count` +// waypoints passed after it); kind 0 as it predicted it, 1 when the +// host's word brought it +// RX clock frame player index +// the host's word took a waypoint this machine had shown back +// RH clock frame player sample index count lap +// host: its referee counted player's car clearing `index` after the +// car's sample `sample` +// RF clock frame player ms +// this machine's results took player's finish (ms 86400000: did not) +// RE clock frame place racers +// this machine's "Place: n/N" changed +// CG clock frame type car value +// Cops and Robbers: this machine's HUD showed a gold event +// (CopsAndRobbers::EventType; value: the points, or 1 for a hit) +// CS clock frame n id score ... +// Cops and Robbers: every player's score, when one changed + +#include +#include +#include +#include + +namespace mm2::game { + +void traceRuleShown(std::FILE* f, double frame, int player, int index, int count, int kind); +void traceRuleTakenBack(std::FILE* f, double frame, int player, int index); +void traceRuleHostHit(std::FILE* f, double frame, int player, std::uint32_t sample, int index, int count, + int lap); +void traceRuleFinish(std::FILE* f, double frame, int player, std::uint32_t ms); +void traceRuleStanding(std::FILE* f, double frame, int place, int racers); +void traceCopsEvent(std::FILE* f, double frame, int type, int car, int value); +void traceCopsScores(std::FILE* f, double frame, const std::vector>& scores); + +} // namespace mm2::game diff --git a/src/game/session/CopsAndRobbers.cpp b/src/game/session/CopsAndRobbers.cpp index 4a46f0f..74910ab 100644 --- a/src/game/session/CopsAndRobbers.cpp +++ b/src/game/session/CopsAndRobbers.cpp @@ -437,13 +437,17 @@ std::vector CopsAndRobbers::updateHost(float dt, const return out; } -bool CopsAndRobbers::updatePredicted(float dt, const Car& me, int players) { +bool CopsAndRobbers::updatePredicted(float dt, const Car& me, const std::vector& cars, int players) { if (m_over) return false; tickLockouts(dt); if (me.wrecked && !locked(me.id)) lockOut(me.id, kWreckPenalty); tickLimits(dt); + // UpdateGold: the carried gold rides 2 m above its carrier. + for (const auto& c : cars) + if (c.id == m_carrier) + m_goldPos = c.position + Vec3{0.0f, 2.0f, 0.0f}; if (m_over || m_pending || players < 2 || !canTake(me)) return false; // UpdateGold on a machine that is not the host: MM2 asks the host diff --git a/src/game/session/CopsAndRobbers.h b/src/game/session/CopsAndRobbers.h index 7c3f3de..2706536 100644 --- a/src/game/session/CopsAndRobbers.h +++ b/src/game/session/CopsAndRobbers.h @@ -160,13 +160,14 @@ class CopsAndRobbers { // player's car in the game, `impacts` the damaging hits between them. // Returns the decisions, in order. std::vector updateHost(float dt, const std::vector& cars, const std::vector& impacts); - // A machine that is not the host: the clock and its warnings, and its - // own car `me` taking free gold within reach (UpdateGold's test, the + // A machine that is not the host: the clock and its warnings, the gold + // riding above its carrier among `cars` (as this machine has them), and + // its own car `me` taking free gold within reach (UpdateGold's test, the // car not locked out by a wreck or a lost gold): a prediction, shown at // once (GoldTaken), which the host's decision confirms or undoes // (applyHost, adopt). `players` counts the players in the game. Returns // true when it predicted a pickup. - bool updatePredicted(float dt, const Car& me, int players); + bool updatePredicted(float dt, const Car& me, const std::vector& cars, int players); // The host's decision (a Message from updateHost) on a machine that is // not the host: as receive(), except that a predicted pickup it confirms // shows nothing more, and one it gives to another car is undone. diff --git a/src/game/session/RaceReferee.cpp b/src/game/session/RaceReferee.cpp index 2db0d22..f494aa1 100644 --- a/src/game/session/RaceReferee.cpp +++ b/src/game/session/RaceReferee.cpp @@ -65,8 +65,10 @@ void RaceReferee::sample(std::uint8_t id, std::uint32_t seq, const Mat34& car, c const int index = p.wp.detect(m_config.checkpoints, car, inertiaBox); if (index >= 0) { std::vector steps; - if (p.wp.apply(m_config.checkpoints, index, car.m3, steps)) + if (p.wp.apply(m_config.checkpoints, index, car.m3, steps)) { p.hits.push_back(static_cast(index)); + p.hitSamples.push_back(seq); + } } // mmMultiRace / mmMultiCircuit / mmMultiBlitz::UpdateGame state 3. A // held car (the wreck penalty's state 7) is not checked until it goes @@ -96,7 +98,8 @@ void RaceReferee::decideFinish(std::uint8_t id, Player& p, float seconds) { // mmGameMulti::SortResults: a player is listed once, by time (a later // equal time goes after; did-not-finish last). if (std::ranges::none_of(m_results, [id](const Result& r) { return r.player == id; })) { - const auto at = std::ranges::find_if(m_results, [seconds](const Result& r) { return r.seconds > seconds; }); + const auto at = + std::ranges::find_if(m_results, [seconds](const Result& r) { return r.seconds > seconds; }); m_results.insert(at, Result{id, seconds}); } m_decisions.push_back({Decision::Kind::Finished, id, seconds}); diff --git a/src/game/session/RaceReferee.h b/src/game/session/RaceReferee.h index 8769d78..6da36ad 100644 --- a/src/game/session/RaceReferee.h +++ b/src/game/session/RaceReferee.h @@ -62,7 +62,8 @@ class RaceReferee { struct Player { WaypointTracker wp; - std::vector hits; // the waypoints its car hit, in order + std::vector hits; // the waypoints its car hit, in order + std::vector hitSamples; // the sample of each bool inRace = true; // in the session and not quit: waited for bool placed = false; // its car is in the world (the host simulates it) bool released = false; diff --git a/src/game/session/Session.cpp b/src/game/session/Session.cpp index 09cc267..273c844 100644 --- a/src/game/session/Session.cpp +++ b/src/game/session/Session.cpp @@ -526,7 +526,7 @@ void Session::cycleTarget(bool forward) { void Session::displayCleared(int index, int count) { // mmWaypoints::DisplayHUDMessage (the waypoint was marked cleared). - push(EventType::CheckpointCleared, index); + push(EventType::CheckpointCleared, index, m_netWord ? 1.0f : 0.0f); // Its sound: "Waypoint" in the crash course; for any-order and in-order // waypoints, except for the one that leaves only the finish; // "Lastwaypoint" when a circuit lap is completed. @@ -691,9 +691,19 @@ void Session::applyNetProgress(const NetProgress& progress, const Vec3& carPosit } log::debug("session: the host's word on the waypoints after sample {}: {} hits ({} predicted)", progress.evaluated, hits.size(), m_netHits.size()); + // The ones taken back: their markers show again, nothing is played. + std::vector kept; + for (const auto& h : hits) + kept.push_back(h.index); + const auto keptKeys = hitKeys(kept); + for (const auto& key : myKeys) + if (std::ranges::find(keptKeys, key) == keptKeys.end()) + push(EventType::CheckpointTakenBack, key.first); m_wp = std::move(wp); m_netHits = std::move(hits); + m_netWord = true; showWaypointSteps(shown); + m_netWord = false; } void Session::setNetStanding(int place, int racers) { diff --git a/src/game/session/Session.h b/src/game/session/Session.h index d70b787..ee0573f 100644 --- a/src/game/session/Session.h +++ b/src/game/session/Session.h @@ -460,6 +460,7 @@ class Session { std::uint32_t m_netEvaluated = 0; bool m_netFinishKnown = false; bool m_netAllCounted = false; + bool m_netWord = false; // showing what the host's word brought bool netRules() const { return m_options.netRules && multiplayer(); } // Crash course. diff --git a/src/game/session/Types.h b/src/game/session/Types.h index c9701c4..c7f26bf 100644 --- a/src/game/session/Types.h +++ b/src/game/session/Types.h @@ -105,6 +105,11 @@ enum class EventType : std::uint8_t { // (value = Session::kNetDnf): tell the other players // (mmGameMulti::SendFinishReq / SendFinishAck). NetFinished, + // Multiplayer under the host's authority (SessionOptions::netRules): the + // host's word took back waypoint `index`, which this machine had shown + // (the marker shows again; nothing is played). CheckpointCleared's value + // is 1 for a waypoint the host's word brought (0: as predicted). + CheckpointTakenBack, }; // What the modes ask of the announcer (mmRaceSpeech, mmCCSpeech). diff --git a/src/game/session/Waypoints.cpp b/src/game/session/Waypoints.cpp index becb1a3..496dde1 100644 --- a/src/game/session/Waypoints.cpp +++ b/src/game/session/Waypoints.cpp @@ -217,8 +217,8 @@ bool WaypointTracker::apply(std::span checkpoints, int index, return false; } -void WaypointTracker::update(std::span checkpoints, const Mat34& car, const Vec3& inertiaBox, - std::vector& steps) { +void WaypointTracker::update(std::span checkpoints, const Mat34& car, + const Vec3& inertiaBox, std::vector& steps) { const int index = detect(checkpoints, car, inertiaBox); if (index >= 0) apply(checkpoints, index, car.m3, steps); diff --git a/src/net/Protocol.h b/src/net/Protocol.h index 36e1342..042d745 100644 --- a/src/net/Protocol.h +++ b/src/net/Protocol.h @@ -35,7 +35,7 @@ inline constexpr std::uint16_t kProtocolMagic = 0x4D32; // "M2" // 6: in AmbientState a rail car's acceleration, curvature and speed over the // ground (the clients predict the shared traffic to their cars' time), and // up to 160 cars; no TrafficHit events. -inline constexpr std::uint16_t kProtocolVersion = 6; +inline constexpr std::uint16_t kProtocolVersion = 8; inline constexpr std::uint32_t kConnectData = (std::uint32_t{kProtocolMagic} << 16) | kProtocolVersion; // ENet channels. diff --git a/src/net/RulesState.h b/src/net/RulesState.h index d7d6ce2..3565bfa 100644 --- a/src/net/RulesState.h +++ b/src/net/RulesState.h @@ -97,7 +97,8 @@ namespace detail { template bool rulePosition(S& s, Vec3& v) { s.vec3(v); - if (std::fabs(v.x) > kMaxRulePosition || std::fabs(v.y) > kMaxRulePosition || std::fabs(v.z) > kMaxRulePosition) + if (std::fabs(v.x) > kMaxRulePosition || std::fabs(v.y) > kMaxRulePosition || + std::fabs(v.z) > kMaxRulePosition) return s.fail(); return s.ok(); } diff --git a/tests/game/test_netgame.cpp b/tests/game/test_netgame.cpp index 150f9c5..26e3faf 100644 --- a/tests/game/test_netgame.cpp +++ b/tests/game/test_netgame.cpp @@ -184,19 +184,27 @@ TEST(NetGame, HostJoinChatReadyCountdownAndState) { EXPECT_NEAR(cars[0].damage, 0.2f, 0.01f); EXPECT_EQ(cars[0].controls.gear, 2); - // Game events. + // Game events. A player's own word on the rules (a checkpoint, a + // finish) is refused: the host decides them (protocol 8, + // game/net/NetRules); other events come through. client.sendCheckpoint(3, 61234); client.sendFinish(123456, 1); + client.sendCollision(net::kHostPlayerId, {1, 2, 3}, 4.0f); + client.sendDamage(0.5f, net::kHostPlayerId); std::vector got; ASSERT_TRUE(pump({&host, &client}, [&] { for (auto& e : host.takeGameEvents()) got.push_back(std::move(e)); return got.size() == 2; })); - EXPECT_EQ(got[0].type, net::GameEventType::CheckpointReached); - ASSERT_TRUE(got[0].as()); - EXPECT_EQ(got[0].as()->index, 3); - EXPECT_EQ(got[1].as()->raceTime, 123456u); + pump({&host, &client}, [] { return false; }, 150); + for (auto& e : host.takeGameEvents()) + got.push_back(std::move(e)); + ASSERT_EQ(got.size(), 2u); + EXPECT_EQ(got[0].type, net::GameEventType::Collision); + ASSERT_TRUE(got[0].as()); + EXPECT_FLOAT_EQ(got[0].as()->impulse, 4.0f); + EXPECT_EQ(got[1].as()->damage, 0.5f); EXPECT_EQ(got[1].from, client.localId()); // Back to the lobby. diff --git a/tests/game/test_netgame_lobby.cpp b/tests/game/test_netgame_lobby.cpp index 6dd11f4..1831148 100644 --- a/tests/game/test_netgame_lobby.cpp +++ b/tests/game/test_netgame_lobby.cpp @@ -190,21 +190,21 @@ TEST(NetGameLobby, StaleGameEventsAreDroppedAtTheNextCountdown) { 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 + l.client.sendCollision(net::kHostPlayerId, {}, 1.0f); // 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); + l.client.sendCollision(net::kHostPlayerId, {}, 2.0f); 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); + EXPECT_EQ(got.front().type, net::GameEventType::Collision); } // Cops vs. Robbers gives every cop vpcop and every robber vpmustang99 (MM2's From 59ca11619e015882b83e879d516aadc3630f1f39 Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Fri, 9 Oct 2026 16:23:01 -0700 Subject: [PATCH 056/129] Let a client's replay meet a knocked prop's pieces after the knock A prop a client's car broke loose stood again for every replay in the next second, so a replay that started after the knock (the host's correction a few samples later) knocked the car a second time: corrections of up to 1.6 m and 8.6 m/s just after a prop hit, with the car alone. It now stands for the samples up to the one that broke it loose only (World::beginReplay takes the world time of the first sample run again, World::replayTime); the later samples meet the pieces where they were. The race screen puts the bodies around the car back where each sample started them; a real sample moves a simulated body on before the collisions, so the replay now does the same with its copy (the car met the pieces a sample behind and sank into them). A light body no longer gives way after one hit: it is back where it was for every sample. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/bangers.md | 3 +- docs/multiplayer.md | 14 ++-- docs/parity/openmm2-only.md | 2 +- src/game/bangers/BangerSet.cpp | 21 +++-- src/game/bangers/BangerSet.h | 5 +- src/game/net/PlayerCars.cpp | 5 +- src/phys/World.cpp | 32 +++++--- src/phys/World.h | 9 ++- tests/game/test_prop_sync.cpp | 139 ++++++++++++++++++++++++--------- 9 files changed, 164 insertions(+), 66 deletions(-) diff --git a/docs/bangers.md b/docs/bangers.md index 12e4a6e..b33d1db 100644 --- a/docs/bangers.md +++ b/docs/bangers.md @@ -211,4 +211,5 @@ needs, all of it inert in a single-player race: the body an active would give it (`heldInertia`: `dgBangerActive::Attach`'s mass and inertia, a mirror's host motion), which the world moves through the replay only; a prop its car broke loose in the last second stands - again for the replay (`replayGhost`), its pieces left out. + again for the samples up to the one that broke it loose (`replayGhost`), + its pieces left out; the later samples meet the pieces. diff --git a/docs/multiplayer.md b/docs/multiplayer.md index 04f3afb..3e2be44 100644 --- a/docs/multiplayer.md +++ b/docs/multiplayer.md @@ -944,9 +944,12 @@ them, and dent them, through single player's code. (`Instance::heldInertia`: the active `dgBangerActive::Attach` would give it) and moves on through the replay as the hits push it (without gravity or the city: a replay is short); a placed prop the car broke loose in the - last second stands again for the replay where it stood (its pieces left - out), so the replay takes the knock the real samples took; a flying piece - meets it with a copy of its body and gives way after one hit if light. + last second stands again where it stood for the samples up to the one + that broke it loose (its pieces left out), so the replay takes the knock + the real samples took, and the later samples meet its pieces where they + were (the client puts the bodies around its car back where they stood for + each sample run again); a flying piece meets it with a copy of its body, + moved on over the sample as a real sample moves it before the collisions. Nothing is knocked or moved in the world by a replay. A prop a client's car hits in its own samples is the same knock on the host, sample for sample: a client driving alone through props had no correction. @@ -1297,8 +1300,9 @@ messages, their sizes and limits), `test_fuzz.cpp` (both messages, decoded and sent to a client) and `tests/game/test_prop_sync.cpp` (a host and a client in one process through a lossy link: the host's knocks and pieces, a predicted knock handed over and one undone, other cars passing through a -client's props, a replayed car meeting a prop as a real sample does, loss -and reordering, a big crash, the catch-up, thrown car parts, refused input, +client's props, a replayed car meeting a prop as a real sample does +(through the knock, and from 1-8 samples after it), a replay moving no prop +for real, loss and reordering, a big crash, the catch-up, thrown car parts, refused input, and on retail data the same placement on every machine). ### Diagnosing replication diff --git a/docs/parity/openmm2-only.md b/docs/parity/openmm2-only.md index f7585fc..c112d9d 100644 --- a/docs/parity/openmm2-only.md +++ b/docs/parity/openmm2-only.md @@ -153,7 +153,7 @@ in the summary above. | `BangerSet::setReplica`, `phys::Instance::acceptsContact` | `dgPhysManager::CollideInstances` | deviation | A client's props take contacts from its own car and its own simulated props only; MM2's take them from every mover. | | `BangerSet::breakPlaced`, `restoreStanding`, `showMirror`, `hideMirror` | `dgUnhitBangerInstance::Impact`, `Reset`, `dgHitBangerInstance` | openmm2 | A client's placed prop leaves its room as Impact makes it leave (without a body), stands again as Reset makes it stand (a prediction the host did not confirm), and the host's pieces are hit instances of their own. | | `BangerSet::attachEntity` on a mirror | `dgBangerActive::Attach` | deviation | MM2 starts an active at rest (a placed or resting prop is); a client's mirror of a host piece in flight starts from the host's motion. | -| `phys::World::collideHeld` with `Instance::heldInertia`, `World::beginReplay`, `BangerSet::replayGhost` | (none: MM2 does not replay) | openmm2 | A client's replayed car meets a prop with the body a real hit gives it (`dgBangerActive::Attach`'s), which moves through the replay only as the hits push it (no gravity or city: inferred stand-in for its flight); a prop the car broke loose in the last second stands again for the replay, so the replay takes the same knock. | +| `phys::World::collideHeld` with `Instance::heldInertia`, `World::beginReplay`, `BangerSet::replayGhost` | (none: MM2 does not replay) | openmm2 | A client's replayed car meets a prop with the body a real hit gives it (`dgBangerActive::Attach`'s), which moves through the replay only as the hits push it (no gravity or city: inferred stand-in for its flight); a prop the car broke loose in the last second stands again for the replayed samples up to the one that broke it loose, so the replay takes the same knock, and the later ones meet its pieces where they were (`World::replayTime`), a simulated body moved on over the sample by its velocity, as a real sample moves it before the collisions. | | Thrown car parts (`ejectPart`'s tag, `game::carPartTag`) | `vehBreakableMgr::Eject` on every machine's simulation | deviation | The host throws them for everyone; a client takes another car's parts off without a throw and throws its own car's at once (handed over to the host's). | ## Files diff --git a/src/game/bangers/BangerSet.cpp b/src/game/bangers/BangerSet.cpp index 4709f93..142be46 100644 --- a/src/game/bangers/BangerSet.cpp +++ b/src/game/bangers/BangerSet.cpp @@ -427,7 +427,8 @@ void BangerSet::instancesIn(int room, std::vector& out) const { // lately, where they stood. if (m_world && m_world->replaying()) for (const Ghost& g : m_ghosts) - if (m_instances[g.prop].room == room && m_instances[g.prop].state == State::Gone) + if (ghostStands(g) && m_instances[g.prop].room == room && + m_instances[g.prop].state == State::Gone) out.push_back(m_props[g.prop].get()); } @@ -1162,19 +1163,29 @@ bool BangerSet::heldInertia(std::size_t i, phys::InertialCS& ics) const { return true; } +bool BangerSet::ghostStands(const Ghost& g) const { + // The samples up to the one that broke it loose (it started at g.time); + // the later ones meet its pieces where they were (the race screen puts + // them back there for each sample). A millisecond is far under a sample + // and far over the clocks' rounding. + return m_world && m_world->replaying() && m_world->replayTime() < g.time + 0.001; +} + bool BangerSet::replayGhost(std::size_t i) const { if (!m_world || !m_world->replaying() || m_ghosts.empty()) return false; - return std::ranges::any_of(m_ghosts, [i](const Ghost& g) { return g.prop == i; }); + return std::ranges::any_of(m_ghosts, [this, i](const Ghost& g) { return g.prop == i && ghostStands(g); }); } bool BangerSet::replayGhostPiece(std::size_t i) const { if (!m_world || !m_world->replaying() || m_ghosts.empty() || i >= m_instances.size()) return false; const int source = m_instances[i].source; - return m_instances[i].everHit && source >= 0 && std::ranges::any_of(m_ghosts, [source](const Ghost& g) { - return g.prop == static_cast(source); - }); + if (!m_instances[i].everHit || source < 0) + return false; + return std::ranges::any_of(m_ghosts, [this, source](const Ghost& g) { + return g.prop == static_cast(source) && ghostStands(g); + }); } bool BangerSet::isActiveBody(const phys::Instance* i) const { diff --git a/src/game/bangers/BangerSet.h b/src/game/bangers/BangerSet.h index faa58fd..aeccd3f 100644 --- a/src/game/bangers/BangerSet.h +++ b/src/game/bangers/BangerSet.h @@ -227,7 +227,9 @@ class BangerSet final : public InstanceSource { // A client's replay of its car (phys::World::replaying) meets a placed // prop its car broke loose in the last kGhostSeconds as it stood there // (a "ghost", standing again for the replay only), and not the pieces it - // became: the replayed car takes the knock the real one took. + // became, in the samples up to the one that broke it loose: the replayed + // car takes the knock the real one took. The later samples meet the + // pieces. bool replayGhost(std::size_t i) const; bool replayGhostPiece(std::size_t i) const; static constexpr double kGhostSeconds = 1.0; @@ -339,6 +341,7 @@ class BangerSet final : public InstanceSource { double time = 0.0; // world time it broke loose }; std::vector m_ghosts; // a client's own car's knocks, for its replays + bool ghostStands(const Ghost& g) const; // in the sample being run again std::size_t m_skipped = 0; bool m_ageMode = false; // dgBangerDataManager +0x2a8a8 (cleared by mmGame::Init) fx::FixedTicker m_ticker; diff --git a/src/game/net/PlayerCars.cpp b/src/game/net/PlayerCars.cpp index b2eac7f..6bcb40d 100644 --- a/src/game/net/PlayerCars.cpp +++ b/src/game/net/PlayerCars.cpp @@ -415,7 +415,10 @@ CarPrediction::Correction CarPrediction::acknowledge(SimVehicle& car, NetCarDriv std::size_t count = 1; if (phys::Trailer* t = car.trailer()) bodies[count++] = &t->body; - world.beginReplay(); // OpenMM2: the props the replay meets (World::collideHeld) + // OpenMM2: the props the replay meets (World::collideHeld), the samples + // having run for real one after another up to the world's time now. + const auto replays = static_cast(m_history.size() - 1 - index); + world.beginReplay(world.time() - replays * static_cast(phys::kFixedSampleStep)); for (std::size_t k = index + 1; k < m_history.size(); ++k) { Entry& e = m_history[k]; for (const auto& c : e.commands) diff --git a/src/phys/World.cpp b/src/phys/World.cpp index 4dc047b..4642488 100644 --- a/src/phys/World.cpp +++ b/src/phys/World.cpp @@ -524,7 +524,6 @@ void World::step(float dt) { m_stepObserver(); if (m_beforeSample) m_beforeSample(); - m_replayYielded.clear(); m_replayBodies.clear(); const float invDt = 1.0f / dt; // datTimeManager::SetTempOverSampling: Seconds is the sample's length. @@ -704,6 +703,7 @@ void World::replaySample(std::span bodies, float dt) { b->controller->afterCollisions(*b, dt, *this); m_stepping = stepping; m_replaying = false; + m_replayTime += static_cast(dt); } void World::bodiesNear(const Vec3& at, float radius, std::vector& out) const { @@ -729,15 +729,13 @@ bool World::collideHeld(Body& a, Instance& b) { // a static instance as itself. OpenMM2 (the props of a network race): // what gives way when a car really hits it meets the replayed car with // its mass, though the world is not changed. A simulated body (a - // knocked-over prop's active) with a copy of its ICS; a light one (under - // a quarter of the car's mass) has given way once hit, as the real one - // flies off (inferred stand-in for its flight). An instance that takes a - // body when hit (a standing or resting prop, Instance::heldInertia) with - // the body it would take, which then moves for the rest of the replay - // as the hits push it (no gravity, no city: a replay is short). + // knocked-over prop's active) with a copy of its ICS (the network + // client puts it back where it was for each sample run again, as the + // real car pushed it). An instance that takes a body when hit (a + // standing or resting prop, Instance::heldInertia) with the body it + // would take, which then moves for the rest of the replay as the hits + // push it (no gravity, no city: a replay is short). Body* entity = b.entity(); - if (std::ranges::find(m_replayYielded, &b) != m_replayYielded.end()) - return false; InertialCS* held = nullptr; bool replayBody = false; if (entity && !entity->kinematic && entity->ics.mass > 0.0f) { @@ -756,6 +754,16 @@ bool World::collideHeld(Body& a, Instance& b) { } } m_tempMatrixB = replayBody ? held->matrix : b.matrix(); + if (held && !replayBody) { + // A simulated body stands where this sample started it (the network + // client puts it back there); a real sample moves it on before the + // collisions, as this one does the replayed car. + const float dt = sampleTime().seconds; + const Vec3& v = held->linearVelocity; + m_tempMatrixB.m3 = {v.x * dt + m_tempMatrixB.m3.x, v.y * dt + m_tempMatrixB.m3.y, + v.z * dt + m_tempMatrixB.m3.z}; + held->matrix.m3 = m_tempMatrixB.m3; + } const Vec3 relPos = m_tempMatrixB.m3 - a.matrix().m3; if (held) m_tempB.init(boundB, &m_tempMatrixB, held); @@ -798,14 +806,12 @@ bool World::collideHeld(Body& a, Instance& b) { for (Impact& im : impacts) calcImpact(im, weight); } - if (held && !replayBody && colA->ics && held->mass < colA->ics->mass * 0.25f) - m_replayYielded.push_back(&b); return true; } -void World::beginReplay() { - m_replayYielded.clear(); +void World::beginReplay(double from) { m_replayBodies.clear(); + m_replayTime = from; } bool World::trivialCollide(const Instance& a, const Instance& b) const { diff --git a/src/phys/World.h b/src/phys/World.h index fd72e97..de1b80f 100644 --- a/src/phys/World.h +++ b/src/phys/World.h @@ -259,10 +259,13 @@ class World final : public GroundQuery { void replaySample(std::span bodies, float dt); // OpenMM2: the samples replaySample runs from here on are one replay // (the props the replayed car has pushed move on through it, until the - // next replay or sample; see collideHeld). - void beginReplay(); + // next replay or sample; see collideHeld), the first of them run for + // real at world time `from`. + void beginReplay(double from); // Inside replaySample (the level's sources may show what a replay meets). bool replaying() const { return m_replaying; } + // The world time the sample replaySample runs again was first run at. + double replayTime() const { return m_replayTime; } // OpenMM2 (network prediction): the bodies in the world (not removed) // whose centre lies within `radius` of `at`, appended to `out`. void bodiesNear(const Vec3& at, float radius, std::vector& out) const; @@ -364,13 +367,13 @@ class World final : public GroundQuery { Collider m_levelCollider; Collider m_tempA, m_tempB; InertialCS m_heldIcs; // replaySample: what a held body or prop would have (taken by nothing) - std::vector m_replayYielded; // replaySample: light bodies the replay has hit struct ReplayBody { const Instance* instance = nullptr; InertialCS ics; // the body the prop takes in the replay, pushed by its hits }; std::vector m_replayBodies; bool m_replaying = false; + double m_replayTime = 0; // replaySample: see replayTime Mat34 m_tempMatrixA, m_tempMatrixB; std::vector m_isectsA, m_isectsB; std::vector m_impacts; diff --git a/tests/game/test_prop_sync.cpp b/tests/game/test_prop_sync.cpp index 6df0167..58baba0 100644 --- a/tests/game/test_prop_sync.cpp +++ b/tests/game/test_prop_sync.cpp @@ -23,6 +23,7 @@ #include +#include #include #include #include @@ -125,6 +126,61 @@ struct Machine { } }; +// What the race screen keeps for a client's replays (RaceScreen bodyPoses / +// placeBodies): where the bodies around its car stood for each sample, put +// back there as that sample runs again, and where they stand now after. +struct StoodPose { + phys::Body* body; + Mat34 ics, bound; + Vec3 velocity, spin; +}; + +class BodyHistory { +public: + // Before a real sample. + void record(const phys::World& world, const phys::Body& car) { + std::vector near; + world.bodiesNear(car.ics.matrix.m3, 40.0f, near); + std::vector poses; + for (phys::Body* b : near) + if (b != &car) + poses.push_back(poseOf(*b)); + m_samples.push_back(std::move(poses)); + } + // A replay of the recorded samples, as reconcileNetCar runs it. + void replay(phys::World& world, phys::Body& car, double from) { + std::vector now; + for (const auto& poses : m_samples) + for (const auto& p : poses) + if (std::ranges::none_of(now, [&](const StoodPose& q) { return q.body == p.body; }) && + world.contains(p.body)) + now.push_back(poseOf(*p.body)); + phys::Body* bodies[] = {&car}; + world.beginReplay(from); + for (const auto& poses : m_samples) { + place(world, poses); + world.replaySample(bodies, kDt); + } + place(world, now); + } + +private: + static StoodPose poseOf(phys::Body& b) { + return {&b, b.ics.matrix, b.boundMatrix, b.ics.linearVelocity, b.ics.angularVelocity}; + } + static void place(const phys::World& world, const std::vector& poses) { + for (const auto& p : poses) { + if (!world.contains(p.body)) + continue; + p.body->ics.matrix = p.ics; + p.body->boundMatrix = p.bound; + p.body->ics.linearVelocity = p.velocity; + p.body->ics.angularVelocity = p.spin; + } + } + std::vector> m_samples; +}; + std::vector layout() { auto at = [](const char* model, const Vec3& p) { return PlacedProp{model, Mat34::translation(p), 1, PlacedProp::Source::Instance, true}; @@ -394,52 +450,62 @@ TEST(PropSync, ReplayThroughAKnockTakesTheSameKnock) { ASSERT_TRUE(client.set.standing(0)); const Mat34 matrix = car.body.ics.matrix; const Vec3 velocity = car.body.ics.linearVelocity; - for (int i = 0; i < 25; ++i) + const double from = client.world.time(); + BodyHistory history; + for (int i = 0; i < 25; ++i) { + history.record(client.world, car.body); client.step(); + } ASSERT_FALSE(client.set.standing(0)); // the real samples knocked it const float real = car.body.ics.linearVelocity.z; + const Vec3 realAt = car.body.ics.matrix.m3; // Back to the earlier state, and the same samples again as a replay. car.body.place(matrix); car.body.ics.linearVelocity = velocity; car.body.ics.linearMomentum = velocity * car.body.ics.mass; - phys::Body* bodies[] = {&car.body}; - client.world.beginReplay(); - for (int i = 0; i < 25; ++i) - client.world.replaySample(bodies, kDt); + history.replay(client.world, car.body, from); EXPECT_FALSE(client.set.standing(0)); // a replay changes no prop - EXPECT_NEAR(car.body.ics.linearVelocity.z, real, 0.5f) << "real " << real; + EXPECT_NEAR(car.body.ics.linearVelocity.z, real, 0.2f) << "real " << real; + EXPECT_LT(car.body.ics.matrix.m3.dist(realAt), 0.1f); EXPECT_GT(car.body.ics.linearVelocity.z, -9.9f); // it met the prop client.world.remove(&car.body); } -// A replay that starts just after the knock, the car still across the -// prop's place and pushing it, ends where the real samples did: it meets the -// prop where it stood and pushes it on. -TEST(PropSync, ReplayJustAfterAKnockEndsAsTheRealSamples) { - TempBangers files; - bangers::BangerDataLibrary lib(files.vfs); - Machine client(lib); - client.place(layout()); - Car car({0, 1, 4.5f}, {0, 0, -10}); - client.set.setReplica([&car](const phys::Instance& other) { return &other == &car.body; }); - client.world.add(&car.body); - while (client.set.standing(0)) - client.step(); - client.step(); // just after the knock, the car still across the prop's place - const Mat34 matrix = car.body.ics.matrix; - const Vec3 velocity = car.body.ics.linearVelocity; - for (int i = 0; i < 15; ++i) - client.step(); - const float real = car.body.ics.linearVelocity.z; - car.body.place(matrix); - car.body.ics.linearVelocity = velocity; - car.body.ics.linearMomentum = velocity * car.body.ics.mass; - phys::Body* bodies[] = {&car.body}; - client.world.beginReplay(); - for (int i = 0; i < 15; ++i) - client.world.replaySample(bodies, kDt); - EXPECT_NEAR(car.body.ics.linearVelocity.z, real, 0.3f) << "real " << real; - client.world.remove(&car.body); +// A replay that starts after the knock, the car still across the prop's +// place or past it, ends where the real samples did: it meets the pieces +// where they were, not the prop where it stood (which would knock the car a +// second time). +TEST(PropSync, ReplayAfterAKnockEndsAsTheRealSamples) { + for (const int after : {1, 4, 8}) { + TempBangers files; + bangers::BangerDataLibrary lib(files.vfs); + Machine client(lib); + client.place(layout()); + Car car({0, 1, 4.5f}, {0, 0, -10}); + client.set.setReplica([&car](const phys::Instance& other) { return &other == &car.body; }); + client.world.add(&car.body); + while (client.set.standing(0)) + client.step(); + for (int i = 0; i < after; ++i) + client.step(); + const Mat34 matrix = car.body.ics.matrix; + const Vec3 velocity = car.body.ics.linearVelocity; + const double from = client.world.time(); + BodyHistory history; + for (int i = 0; i < 15; ++i) { + history.record(client.world, car.body); + client.step(); + } + const float real = car.body.ics.linearVelocity.z; + const Vec3 realAt = car.body.ics.matrix.m3; + car.body.place(matrix); + car.body.ics.linearVelocity = velocity; + car.body.ics.linearMomentum = velocity * car.body.ics.mass; + history.replay(client.world, car.body, from); + EXPECT_NEAR(car.body.ics.linearVelocity.z, real, 0.06f) << after << " samples after, real " << real; + EXPECT_LT(car.body.ics.matrix.m3.dist(realAt), 0.03f) << after << " samples after"; + client.world.remove(&car.body); + } } // A replay knocks and moves no prop for real: the standing props stand, the @@ -451,6 +517,7 @@ TEST(PropSync, AReplayMovesNoPropForReal) { Car mine({6, 1, 4.5f}, {0, 0, -10}); // the client's own car knocks prop 1 r.clientCar = &mine.body; r.client.world.add(&mine.body); + const double from = r.client.world.time(); // the replay below meets prop 1 standing r.run(0.6); r.host.world.remove(&host.body); ASSERT_FALSE(r.client.set.standing(1)); @@ -476,7 +543,7 @@ TEST(PropSync, AReplayMovesNoPropForReal) { Car again({0, 1, 4.5f}, {3, 0, -12}); r.client.world.add(&again.body); phys::Body* replayed[] = {&again.body}; - r.client.world.beginReplay(); + r.client.world.beginReplay(from); for (int i = 0; i < 40; ++i) r.client.world.replaySample(replayed, kDt); r.client.world.remove(&again.body); @@ -513,7 +580,7 @@ TEST(PropSync, ReplayedCarMeetsAPropAsARealSampleDoes) { Car b({0, 1, 2.5f}, {0, 0, -10}); replay.world.add(&b.body); phys::Body* bodies[] = {&b.body}; - replay.world.beginReplay(); + replay.world.beginReplay(replay.world.time()); for (int i = 0; i < kSteps; ++i) replay.world.replaySample(bodies, kDt); EXPECT_TRUE(replay.set.standing(0)); // a replay knocks nothing From 94d542a22a40274679d644cd870b3102360b63f2 Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Fri, 9 Oct 2026 16:24:02 -0700 Subject: [PATCH 057/129] Keep the players' cars in player order and run far ones again alone Two players' cars collide in the order of the world's movers, and the order changes the outcome; every machine now keeps them in player order (the host's first), and a client runs them again in that order, so a client's prediction of a shunt resolves it as the host does. Other cars too far to meet the client's car in the samples run again (by their distance and how fast they close) run again alone with the car held where each sample had it: running the car's own samples again against props held still made it drift from the host's in a crash with nothing but the city. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/app/RaceScreen.cpp | 26 ++++++++++++++++- src/game/net/PlayerCars.cpp | 56 +++++++++++++++++++++++++++++++++---- src/game/net/PlayerCars.h | 9 ++++++ 3 files changed, 85 insertions(+), 6 deletions(-) diff --git a/src/app/RaceScreen.cpp b/src/app/RaceScreen.cpp index 0dc80c8..c0a1e90 100644 --- a/src/app/RaceScreen.cpp +++ b/src/app/RaceScreen.cpp @@ -4754,10 +4754,32 @@ class RaceScreen final : public Screen { } if (rv.predicted) { rv.input = it->input; - companions.push_back({rv.sim.get(), &rv.driver, &it->state, it->input}); + companions.push_back( + {rv.sim.get(), &rv.driver, &it->state, it->input, id < m_traceNet->localId()}); } } } + // OpenMM2: every machine keeps the players' cars in the world's movers in + // player order (the host's first), so that two of them collide in the + // same order everywhere: World::step runs a mover's collisions with the + // city, then with the movers after it, then with the instances, and the + // order changes the outcome. A car that joins puts the ones after it + // behind it again. + void orderPlayerCars() { + if (!m_world || !m_traceNet) + return; + std::vector> cars; + if (m_player) + cars.emplace_back(m_traceNet->localId(), m_player.get()); + for (const auto& [id, rv] : m_remotes) + if (rv.sim && m_world->contains(&rv.sim->sim().body)) + cars.emplace_back(id, rv.sim.get()); + std::ranges::sort(cars, {}, &std::pair::first); + for (const auto& [id, car] : cars) { + car->removeFrom(*m_world); + car->addTo(*m_world); + } + } bool predictedBody(const phys::Body* b) const { for (const auto& [id, rv] : m_remotes) if (rv.predicted && rv.sim && &rv.sim->sim().body == b) @@ -4827,6 +4849,7 @@ class RaceScreen final : public Screen { sim.body.kinematic = true; sim.body.resetCollider(); rv.sim->addTo(*m_world); + orderPlayerCars(); } rv.renderer = std::make_unique(ctx.device(), *m_textures, *m_models, rv.sim->model(), rv.color); @@ -4886,6 +4909,7 @@ class RaceScreen final : public Screen { game::NetCarDriver::command(*rv.sim, *first); rv.lastMoveSeq = first->seq; rv.sim->addTo(*m_world); + orderPlayerCars(); rv.placed = true; log::info("race: player {}'s car starts at ({:.1f}, {:.1f}, {:.1f})", p.id, first->position.x, first->position.y, first->position.z); diff --git a/src/game/net/PlayerCars.cpp b/src/game/net/PlayerCars.cpp index 37eff1a..ffce666 100644 --- a/src/game/net/PlayerCars.cpp +++ b/src/game/net/PlayerCars.cpp @@ -408,20 +408,66 @@ CarPrediction::Correction CarPrediction::acknowledge(SimVehicle& car, NetCarDriv m_history.erase(m_history.begin(), m_history.begin() + static_cast(index)); return out; } - if (replay) { + // Companions that cannot meet the car before the newest sample (farther + // from it, at the acknowledged sample and now, than companionReach and + // the way their speeds close in the samples between) run again alone, + // the car held where each sample had it: the car's own prediction is + // then not run again against bodies held still (a prop it pushed in + // those samples meets it as a wall when they run again). + bool alone = !differs && !companions.empty(); + const float span = static_cast(later + 1) * phys::kFixedSampleStep; + const Vec3 carNow = car.sim().body.ics.matrix.m3; + for (const Companion& c : companions) { + const float closing = (c.state->linearVelocity - predicted.linearVelocity).mag() * span; + const float reach = m_options.companionReach + closing; + if (c.state->matrix.m3.dist2(predicted.matrix.m3) < reach * reach || + c.car->sim().body.ics.matrix.m3.dist2(carNow) < reach * reach) + alone = false; + } + if (replay && alone) { + std::vector bodies; + for (const Companion& c : companions) { + applyOwnCarState(*c.car, *c.state); + bodies.push_back(&c.car->sim().body); + } + // (In the world's order: the companions come in player order.) + for (std::size_t k = index + 1; k < m_history.size(); ++k) { + restore(m_history[k - 1], car, driver); // the car as the sample met it + for (const Companion& c : companions) + c.driver->apply(*c.car, c.input); + if (beforeEach) + beforeEach(m_history[k].seq); + if (k + 1 == m_history.size() && beforeLast) + beforeLast(); + world.replaySample(bodies, phys::kFixedSampleStep); + ++out.replayed; + } + restore(m_history.back(), car, driver); + for (const auto& c : m_pending) + NetCarDriver::command(car, c); + out.rebased = true; + m_stats.replayedSamples += static_cast(out.replayed); + } else if (replay) { const Vec3 before = car.sim().modelMatrix().m3; restore(base, car, driver); if (differs) { applyOwnCarState(car, host); save(base, car, driver); } - std::vector bodies{&car.sim().body}; + // The bodies in the world's order (the players' cars by number), so + // that they collide in the order they do on the host. + std::vector bodies; + for (const Companion& c : companions) + if (c.first) + bodies.push_back(&c.car->sim().body); + bodies.push_back(&car.sim().body); if (phys::Trailer* t = car.trailer()) bodies.push_back(&t->body); - for (const Companion& c : companions) { + for (const Companion& c : companions) + if (!c.first) + bodies.push_back(&c.car->sim().body); + for (const Companion& c : companions) applyOwnCarState(*c.car, *c.state); - bodies.push_back(&c.car->sim().body); - } for (std::size_t k = index + 1; k < m_history.size(); ++k) { Entry& e = m_history[k]; for (const auto& c : e.commands) diff --git a/src/game/net/PlayerCars.h b/src/game/net/PlayerCars.h index 3899969..05df19a 100644 --- a/src/game/net/PlayerCars.h +++ b/src/game/net/PlayerCars.h @@ -153,6 +153,11 @@ class CarPrediction { float positionTolerance = 0.003f; // m float velocityTolerance = 0.03f; // m/s float rotationTolerance = 0.0015f; // matrix entries + // Other players' cars run with this one (Companion) farther than + // this from it, and than their speeds close in the samples run + // again, run again without it (m; two cars' half lengths and a + // margin). + float companionReach = 8.0f; }; CarPrediction() = default; explicit CarPrediction(const Options& o) : m_options(o) {} @@ -192,6 +197,10 @@ class CarPrediction { NetCarDriver* driver = nullptr; const net::OwnCarState* state = nullptr; net::CarInputFrame input; + // It comes before the car in the world's movers (a lower player + // number: every machine keeps the players' cars in that order), and + // collides first when they run again, as on the host. + bool first = false; }; // The host's state after sample `ack`. Forgets what it acknowledged; when // the state differs from the prediction for that sample, puts the car From c7e1cb1e207d3b4c9611414cbfb04a1240ca7c90 Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Fri, 9 Oct 2026 16:24:02 -0700 Subject: [PATCH 058/129] Report how far apart the screens are once their delay is taken out netprobe syncreport now also finds, for every viewer and car, the delay (behind or ahead of the car's own screen) that brings the two drawings closest, and what remains at it: a car drawn a fixed time behind its player's screen is that far off at its speed however exactly it follows, so the plain divergence grows with the cars' speeds. Co-Authored-By: Claude Opus 5.5 (1M context) --- tools/netprobe/SyncReport.cpp | 38 +++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/tools/netprobe/SyncReport.cpp b/tools/netprobe/SyncReport.cpp index 32a0b58..af0a45c 100644 --- a/tools/netprobe/SyncReport.cpp +++ b/tools/netprobe/SyncReport.cpp @@ -246,6 +246,44 @@ int syncReport(const SyncReportOptions& o) { } } + // 1b. The same against the player's own screen a little earlier or later: + // a car drawn a fixed time behind (or ahead of) its player's screen is + // that far off at the car's speed, however exactly it follows. The delay + // that brings the two closest, and what is left at it. + std::println("\nThe same at the delay that brings them closest (ms: the car is drawn that long behind"); + std::println("its player's screen; negative: ahead of it), what remains (metres):"); + std::println(" {:<10} {:<10} {:>7} {:>7} {:>7} {:>7}", "viewer", "car of", "delay", "median", "p90", + "p99"); + for (std::size_t v = 0; v < ms.size(); ++v) { + for (std::size_t own = 0; own < ms.size(); ++own) { + if (own == v || ms[own].self < 0) + continue; + const auto seen = ms[v].drawn.find(ms[own].self); + const auto truth = ms[own].drawn.find(ms[own].self); + if (seen == ms[v].drawn.end() || truth == ms[own].drawn.end()) + continue; + Stats best; + int bestDelay = 0; + for (int delay = -300; delay <= 300; delay += 5) { + Stats s; + for (const Drawn& d : seen->second) { + if (d.wall < ms[v].firstWall + skipMs || d.wall < ms[own].firstWall + skipMs) + continue; + V3 p; + if (at(truth->second, d.wall - delay, p)) + s.add((d.p - p).length()); + } + if (!s.v.empty() && (best.v.empty() || s.pct(0.5) < best.pct(0.5))) { + best = std::move(s); + bestDelay = delay; + } + } + if (!best.v.empty()) + std::println(" {:<10} {:<10} {:>7} {:>7.3f} {:>7.3f} {:>7.3f}", who(ms, v), who(ms, own), + bestDelay, best.pct(0.5), best.pct(0.9), best.pct(0.99)); + } + } + // 2. Jumps: a car's move from one drawn frame to the next that its // velocity does not explain. std::println("\nJumps: a frame's move that the car's velocity does not explain (frames 100 ms apart"); From fb5dfb5a90745d4b95ce615c4e7c8370e42bbd08 Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Fri, 9 Oct 2026 16:26:56 -0700 Subject: [PATCH 059/129] Record the props' final runs The final runs with the host's props and the replay fix: every knock on every machine, nothing different at the end, the pieces within 3 mm; 4 of 65 predicted knocks undone (each after a traffic car met only the host's copy of the car); a client's corrections over 30 cm just after a prop hit from 31 in eight runs to 7, the largest from 2.3 m and 8.6 m/s to 0.7 m and 2.4 m/s; 0.1-5 KB/s to each client. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/multiplayer.md | 15 +-- docs/review/multiplayer-desync-props.md | 155 +++++++++++++++--------- 2 files changed, 106 insertions(+), 64 deletions(-) diff --git a/docs/multiplayer.md b/docs/multiplayer.md index 3e2be44..b500f73 100644 --- a/docs/multiplayer.md +++ b/docs/multiplayer.md @@ -963,15 +963,16 @@ driving into props, traffic lights and parked cars (the record: `docs/review/multiplayer-desync-props.md`): with 0.3.1's props no knock on one machine was the same knock on another, 6-16 props differed between the screens at the end of a minute, and props two cars hit separately rested -10.6 m apart (0.1-2.1 m once the host simulated the client's car, in two -separate simulations); after, every knock reached every machine (150-260 ms +10.6 m apart (0.1-2.4 m once the host simulated the client's car, in two +separate simulations); after, every knock reached every machine (170-250 ms apart, a playout delay), no prop differed at the end, and the knocked-over -props, parked cars and parts rested within 5 mm of the host's (the +props, parked cars and parts rested within 3 mm of the host's (the position's quantization is 4 mm). A client alone among props had no -correction of its car. The host sent 0.07-6 KB/s to each client (10 s -averages; a big crash in the test, 27 props knocked by one car, 5 KB/s for a -few seconds, at most 425 bytes a message); the ring bounds a message at 40 -slots, about 1 KB. +correction of its car, and a client's corrections over 30 cm just after a +prop hit (nothing else around) went from 31 in eight runs to 7. The host +sent 0.1-5 KB/s to each client (10 s averages; a big crash in the test, 27 +props knocked by one car, 5 KB/s for a few seconds, at most 425 bytes a +message); the ring bounds a message at 40 slots, about 1 KB. **Deviations.** MM2 lets each machine knock its own props; OpenMM2 shows the host's everywhere, as it does the traffic. The ring's wrapping (the oldest diff --git a/docs/review/multiplayer-desync-props.md b/docs/review/multiplayer-desync-props.md index 6f9709f..72584fb 100644 --- a/docs/review/multiplayer-desync-props.md +++ b/docs/review/multiplayer-desync-props.md @@ -1,7 +1,8 @@ # Multiplayer desync: props and damage Worked on 2026-10-09 from `integration` at cd5ae29 (release 0.3.1), merged -with integration at 3f73059 (the host simulates every player's car), on the +with integration at 3f73059 (the host simulates every player's car), 52aa601 +(the shared traffic) and cae6989 (the players' cars' final work), on the maintainer's report that in a multiplayer cruise over the Internet "knocked-over props, parked cars, objects or car damage look different on each screen", and on the maintainer's decision that the host is the @@ -16,39 +17,44 @@ stood on every other screen, the props the host's shared traffic and police knocked stood on every client, and props two cars hit separately rested metres apart. Parked cars (network races) and traffic lights (cruise) are props and behaved the same. With the host simulating the players' cars -(integration at 3f73059) a client's knocks happened on the host too, but in -two separate simulations (pieces 0.1-2.1 m apart), and the host's own knocks -and its traffic's still reached no client. +(integration) a client's knocks happened on the host too, but in two +separate simulations (pieces 0.1-2.4 m apart), and the host's own knocks and +its traffic's still reached no client. After, the host simulates every prop for everyone. In every run every knock reached every machine a playout delay later, no prop differed at the end, and every knocked-over prop, piece, parked car and traffic light rested within -5 mm of the host's (the position's quantization is 4 mm). A client's own car -knocks props at once and hands them over to the host's when they rest; of 49 -such predictions in the final runs one was undone (the host's simulation of -the car missed the prop). A client driving alone through props had no correction of its car in -40 s, and its car's impacts and damage were the host's to the unit. +3 mm of the host's (the position's quantization is 4 mm). A client's own car +knocks props at once and hands them over to the host's when they rest; of 65 +such predictions in the final runs 4 were undone, all where the host's +simulation of the car had met a traffic car the client's had not. A client +driving alone through props had no correction of its car in 40 s, and its +car's impacts and damage were the host's to the unit; a client's +corrections just after a prop hit with nothing else around went from 31 over +30 cm (up to 2.3 m and 8.6 m/s) to 7 (up to 0.7 m and 2.4 m/s). | Run (a minute of driving; host and client) | Knocked on one machine only, at the end | Knocked on both | Knock times apart | Resting apart (both at rest) | | --- | --- | --- | --- | --- | | slalom, 80 ±20 ms, 2 % loss, 0.3.1 | 16 of 18 | 2 (separate hits) | 3.8 s | 10.6 m | -| slalom, 80 ±20 ms, 2 % loss, integration | 14 of 31 | 17 (separate simulations) | 172 ms median | 0.11 m median, 2.1 m max | -| slalom, 80 ±20 ms, 2 % loss, after | 0 of 8 | 8 | 161 ms median, 181 max | 2 mm max (8 pieces) | +| slalom, 80 ±20 ms, 2 % loss, integration | 14 of 31; 6 of 9 | 17; 3 (separate simulations) | 172 ms median | 0.11-1.2 m median, 2.4 m max | +| slalom, 80 ±20 ms, 2 % loss, after | 0 of 14 | 14 | 176 ms median, 226 max | 3 mm max (10 pieces) | | slalom, 150 ±20 ms, 5 % loss, 0.3.1 | 11 of 12 | 1 (separate hits) | 7.8 s | (still moving) | -| slalom, 150 ±20 ms, 5 % loss, after | 0 of 14 | 14 | 245 ms median, 260 max | 3 mm max (17) | +| slalom, 150 ±20 ms, 5 % loss, after | 0 of 20 | 20 | 235 ms median, 508 max | 2 mm max (11) | | follow, 80 ±20 ms, 2 % loss, 0.3.1 | 6 of 6 | 0 | | | -| follow, 80 ±20 ms, 2 % loss, after | 0 of 11 | 11 | 188 ms median, 219 max | 3 mm max (11) | +| follow, 80 ±20 ms, 2 % loss, after | 0 of 9 | 9 | 188 ms median, 190 max | 3 mm max (10) | | follow, 150 ±20 ms, 5 % loss, 0.3.1 | 12 of 12 | 0 | | | -| follow, 150 ±20 ms, 5 % loss, after | 0 of 13 | 13 | 248 ms median, 519 max | 3 mm max (17) | +| follow, 150 ±20 ms, 5 % loss, after | 0 of 29 | 29 | 240 ms median, 527 max | 3 mm max (24) | | parked cars (checkpoint race), 80 ±20 ms, 0.3.1 | 5 of 5 | 0 | | | -| parked cars (checkpoint race), 80 ±20 ms, after | 0 of 5 | 5 | 185 ms median, 816 max | 2 mm max (6) | -| host and two clients, 150 ±20 ms, 5 % loss, after | 0 of 20 (each pair) | 20 | 258-260 ms median | 5 mm max (13-15) | -| one client alone through props, 80 ±20 ms, after | 0 of 9 | 9 | 171 ms median | 3 mm max (6) | +| parked cars (checkpoint race), 80 ±20 ms, integration | 2 of 5 | 3 | 3.2 s median | 0.37 m median, 0.59 m max | +| parked cars (checkpoint race), 80 ±20 ms, after | 0 of 5 | 5 | 177 ms median, 185 max | 3 mm max (6) | +| host and two clients, 150 ±20 ms, 5 % loss, after | 0 of 15 (each pair) | 15 | 245-249 ms median (clients 492) | 3 mm max (14), clients 9 mm | +| one client alone through props, 80 ±20 ms, after | 0 of 7 | 7 | 173 ms | 3 mm max (4) | Props down on one machine and standing on the other, at any 250 ms tick: -6-16 at the end of the 0.3.1 runs (in 72-93 % of the ticks); after, at most -2 or 3 for a moment (a knock between the host's simulation and its display on -the client, 3-15 % of the ticks), 0 at every end. +6-16 at the end of the 0.3.1 runs (in 72-93 % of the ticks), 2-6 at the end +of the integration runs (72-93 %); after, at most 2 or 3 for a moment (a +knock between the host's simulation and its display on the client, 3-20 % of +the ticks), 0 at every end. ## How it was measured @@ -73,9 +79,14 @@ the client, 3-15 % of the ticks), 0 at every end. cause). `netprobe syncreport ...` compares every pair of machines' props at the same session times, after the cars. * "0.3.1" is this work's first build (on cd5ae29) with `OPENMM2_NETPROPS=local` - (added), which keeps each machine's own props; "integration" the final - build with `OPENMM2_NETPROPS=local`, which is 3f73059's props; "after" the - final build. + (added), which keeps each machine's own props; "integration" a build of + the merged branch with `OPENMM2_NETPROPS=local`, which is integration's + props (the first figure at 3f73059, the second at cae6989); "after" the + final build (59ca116). +* A client's corrections after a prop hit: the trace's corrections of its + car (`C`) over 30 cm within 1.5 s of an impact of its car with a prop or a + knock it predicted, with no impact of the car with the city, a car or the + traffic in that time on either machine. * Same-moment screenshots (`OPENMM2_DEBUG_NET_SHOT_MS`) with `OPENMM2_DEBUG_FOCUS=prop:` (added: a placed prop, or its first piece once knocked, framed alike on every machine): a parking meter a @@ -126,8 +137,11 @@ This is why OpenMM2 does not follow MM2 here (the maintainer's decision). | D5 | visible | `world::initGizmos` (network races) | Parked cars are props: the same as D1-D3. | parked runs: 5 knocked, none on both machines | as D1 | | D6 | visible | `RaceScreen::addTrafficLightProps` | Traffic lights are props (round 3): the same as D1-D3, the signal gone on one screen and standing on the other. | 1-5 traffic lights a run on one machine only | as D1 | | D7 | minor | `DamageSync` (0.3.0) | The cars' damage converged (each car's dents and level were its owner's; at most 1-3 dents apart for an event in flight), but the prop hits that caused a client's dents were knocks no other screen showed. | traces: dents apart mean ≤ 0.2 | as D1; damage is the host's at 3f73059 | -| D8 | visible (found after the merge) | `World::collideHeld` (3f73059) | A client replays its car when the host corrects it, with everything else held still. A prop there held like a wall (calcBangerImpact against a body without an ICS stops the car past the break limit) and a knocked-over piece like an infinitely heavy body: corrections after a prop hit of up to 1.5 m and 6.6 m/s that the real samples had not had. | merged build, slalom: 5 corrections over 30 cm within 1.5 s of a prop hit, 6.6 m/s | 0791996: the replay meets a prop with the body a real hit gives it, a light one giving way after its first hit | +| D8 | visible (found after the merge) | `World::collideHeld` (3f73059) | A client replays its car when the host corrects it, with everything else held still. A prop there held like a wall (calcBangerImpact against a body without an ICS stops the car past the break limit) and a knocked-over piece like an infinitely heavy body: corrections after a prop hit of up to 1.5 m and 6.6 m/s that the real samples had not had. | merged build, slalom: 5 corrections over 30 cm within 1.5 s of a prop hit, 6.6 m/s | 0791996: the replay meets a prop with the body a real hit gives it; 784b3b1: a prop the car broke loose stands again for the replay, which takes the same knock | | D9 | visible (found after the merge) | `PropClient` mirrors | With three machines, a traffic light's two pieces lay together on one client 6.4 m from where the host's rested: a piece the client simulated woke the other and they kept each other creeping down the hill. | three-machine run | 6764d4d: the host's pieces move on a client only for its own car; a local simulation of one ends after 4 s | +| D10 | minor (found in the final runs) | `BangerSet::acceptsFrom` | A client's own simulated piece knocked a parking meter 3 s after its car hit a street light; the host's piece of the same knock had gone elsewhere, and the meter stood up again 2 s later (an undone prediction). | client alone, one undone of 4 | f1f3b56: a piece this machine simulates knocks placed props for its first 0.5 s only | +| D11 | visible (found in the final runs) | `BangerSet::replayGhost` (784b3b1) | A prop the client's car broke loose stood again for every replay in the next second, so a replay that started after the knock (the host's correction a few samples later) knocked the car a second time. | client alone: corrections of 1.3-1.6 m and up to 8.6 m/s 250-550 ms after a hit; 31 corrections over 30 cm after prop hits alone in eight clients' runs | 59ca116: it stands for the samples up to the one that broke it loose (`World::replayTime`) | +| D12 | visible (found in the final runs) | `World::collideHeld` (0791996) | The client puts the bodies around its car back where each sample started them (c367288), but a real sample moves a simulated body on before the collisions: the replayed car met the pieces a sample behind and sank into them; and a light piece gave way after one hit, though the real car pushed it all along. | test: a replay from 1-8 samples after a knock ended 0.1-0.2 m from the real samples | 59ca116: the held copy moves on over the sample; no giving way (the replay ends within 15 mm and 0.04 m/s) | Damaging impacts on each machine's own car by cause, 0.3.1 runs: the city 47, props 3, traffic 2; no player-versus-player impact. After, each car's @@ -157,8 +171,10 @@ See docs/multiplayer.md, "Props", for the protocol and the rules; in short: when it shows the host's props at their time, a playout delay in the past as the players' cars; the host's ring is shown in mirror slots; its own car's knocks are predictions, handed over to the host's pieces when both - rest, undone after 2 s without the host's knock; its replays meet props - with their mass. + rest, undone after 2 s without the host's knock; its own pieces knock + other props for their first 0.5 s only; its replays meet props with their + mass, a prop its car broke loose standing again for the samples up to + that knock, the pieces after it where they were. * **Thrown car parts** are the host's ring like the rest. Deviations from MM2: the host's props on every screen instead of each @@ -169,8 +185,8 @@ at rest). Recorded in docs/parity/openmm2-only.md. ## Damage -The damage is the host's at 3f73059 (the players' cars agent): every car's -dents, parts and level from the host's own collisions. This work's part: +The damage is the host's (the players' cars agent): every car's dents, +parts and level from the host's own collisions. This work's part: * **Props hitting cars and cars hitting props on the host are single player's.** The host's props are a single-player `BangerSet` (its replica @@ -179,10 +195,10 @@ dents, parts and level from the host's own collisions. This work's part: on); `calcBangerImpact` is MM2's for every simulated body, bit for bit. The only difference from a single-player car is the mover type (3, not 4), which marks what it hits as "hit by the player" for the AI, not for damage. - Measured: a client driving alone through 9 props in 40 s had no + Measured: a client driving alone through 7 props in 40 s had no correction, and the trace's impacts of its car on the host and on the - client (a prop at 2369, the city eight times at 22850 in all, damage 36001) - were the same. + client (a prop at 2356.5, the city six times, damage 53690.3 in all) were + the same. * A prop that dents a client's car is now a knock every screen shows, at the same place, and rests where the host's does. * A client's car meets the host's flying pieces with their motion (before, @@ -190,21 +206,33 @@ dents, parts and level from the host's own collisions. This work's part: simulation of it does. * Thrown parts are the host's: a client takes the parts the host's records take off (its own car's included) without a throw. -* A client's corrections after a prop hit (within 1.5 s): at integration - (separate props) 184 of 336 corrections, 65 over 30 cm, the largest 5.6 m - and 13.4 m/s; after, slalom 80 ms: 55 of 395, 2 over 30 cm, the largest - 0.38 m and 2.4 m/s; none for the client alone. +* A client's corrections just after a prop hit, with no impact of its car + with the city, a car or the traffic on either machine in the 1.5 s before + (the car's own corrections being the players' cars' work): at integration + (each machine its own props) 184 of 336 corrections within 1.5 s of a + prop hit, 65 over 30 cm, the largest 5.6 m and 13.4 m/s; with the host's + props before D11 and D12, 31 over 30 cm in eight clients' runs, the + largest 2.3 m and 8.6 m/s; after, 7 in eight (two runs), the largest 0.7 m + and 2.4 m/s. A client alone, and the first client of three, had no + correction at all while knocking 7-8 props. + +Damaging impacts on each machine's own car by cause, 0.3.1 runs: the city +47, props 3, traffic 2; no player-versus-player impact. After, each car's +impacts as its own machine predicted them and as the host simulated them: +identical (cause, value and damage) for a client alone among props; where +its car also met the host's car or the traffic, the counts differed by one +or two (the players' cars' and the traffic's corrections). ## Bandwidth -Per client, from the host's 10-second logs over the final runs: 72 bytes/s -(props at rest, every 0.5 s) to 3.4 KB/s with two players, 5.95 KB/s to each -of two clients in the busiest 10 s (three cars knocking 14 props). The ring +Per client, from the host's 10-second logs over the final runs: 121 bytes/s +(props at rest) to 5.0 KB/s with one client, 4.4 KB/s to each of two clients +at most (20 messages a second, 18-23 slots, 5-9 states a message). The ring bounds a message at 40 slots (a flying piece 26 bytes, about 1 KB); knocks are 31 bits each in reliable events (250 per 1 KiB event). A big crash in the test (a car through 60 props, 27 knocked) cost 5 KB/s for a few seconds, 425 bytes at most a message. The host sends the same message to every client: for -seven clients about 0.5-42 KB/s in these conditions, at most about 140 KB/s +seven clients about 1-35 KB/s in these conditions, at most about 140 KB/s in a crash that keeps all 40 slots flying. ## Tests @@ -217,27 +245,37 @@ in a crash that keeps all 40 slots flying. a lossy link: the host's knocks and pieces on the client, a client's knock predicted and handed over, one undone, other cars passing through a client's props, a client's own pieces not moving the host's, a replayed - car meeting a prop as a real sample does, 30 % loss with reordering, a big - crash, the catch-up, thrown car parts, knocks counted from the host only, - refused input and a different placement, and on retail data the same - placement on every machine (cruise at the host's and a client's traffic - density, a checkpoint race with parked cars) for San Francisco and London. -* Full suite with game data: 964 of 964 (3 skipped as always); the opponent - sweep 516/517 finished, 508/517 across the line; single-player smoke - screenshots (a San Francisco cruise, a London circuit). + car meeting a prop as a real sample does (from before the knock, and from + 1, 4 and 8 samples after it, the bodies put back for each sample as the + race screen does), a replay knocking and moving no prop for real, 30 % + loss with reordering, a big crash, the catch-up, thrown car parts, knocks + counted from the host only, refused input and a different placement, and + on retail data the same placement on every machine (cruise at the host's + and a client's traffic density, a checkpoint race with parked cars) for + San Francisco and London. +* Full suite, the `_GLIBCXX_ASSERTIONS` suite, the opponent sweep and the + single-player smoke screenshots: see the final report. ## For the players' cars agent * A client's props accept contacts only from its own car and trailer (`ownCarBody`) and its own predicted pieces (`BangerSet::setReplica`). -* `World::beginReplay` (called by `CarPrediction::acknowledge`) starts a - replay: a light prop the replayed car hits gives way for the rest of it. -* A replay through the sample in which the car knocked a prop meets the - prop where it is now (knocked, flying away), not where it stood: the - replayed car then keeps the speed the knock took off, until the next - state. This is the largest remaining source of corrections after a prop - hit among other cars (up to 0.4-0.5 m in the runs); replaying the knock - itself needs the prediction's history to keep it. +* `World::beginReplay(from)` (called by `CarPrediction::acknowledge`) starts + a replay; `from` is the world time the first sample run again first ran at + (the samples ran one after another up to the world's time now), and + `World::replayTime` the current one's. A prop the car broke loose stands + again for the samples up to the one that broke it loose. +* `World::collideHeld` moves a simulated body's copy on by its velocity over + the sample before the test (the race screen puts it back where the sample + started it); the light bodies' giving way is gone. +* A body the history did not hold for a sample (one that came within 40 m, + or into the world, later, such as a piece of a knock the host's message + brought since) stands where it is now during that sample's replay. Leaving + it out of that sample would be closer to the real samples. +* In the checkpoint race the client's corrections ran at every + acknowledgement for the first 3-4 s after the start (0.56 m each, the sign + alternating, 58-59 samples run again), with the props kept local as well: + the race's start, not the props. ## Open @@ -246,5 +284,8 @@ in a crash that keeps all 40 slots flying. playout delay in the past. * Props are not interest-managed: every client gets every knock and the whole ring; the ring's 40 slots bound it. -* The `_GLIBCXX_ASSERTIONS` suite: see the final report (the disk was full - for a second build directory; the build was redone in place). +* A client's predicted knock is undone when the host's simulation of its car + went elsewhere (4 of 65 in the final runs, each after a traffic car met + the host's copy of the car and not the client's). +* The new files want listing in the parity manifest: `game/net/PropSync`, + `game/net/NetProps`, `net/PropState.h`, `tools/netprobe/PropDiff`. From 8185a2e5044671ecea65d33db3a6a43bb83e46df Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Fri, 9 Oct 2026 16:31:26 -0700 Subject: [PATCH 060/129] Test the host's rules, a client's corrections and the rules message test_net_race_rules: the tracker taking recorded hits as its gate test does; the referee timing each car from its own release, timing the race out a minute after the first finish (0x1fe, 0x211), counting a circuit's laps in order, ending a Blitz on each car's clock and the host's, and ranking every car as each player's machine would (UpdateScore); Cops and Robbers on the host for every car and a client's predicted pickup confirmed or undone; a client showing a predicted checkpoint once, taking back one the host never counted (without a sound) and showing one only the host counted; its finish, standings, timeout and end the host's; the host's message surviving a lost message, duplicates, forgeries, junk and another race; and, through live sessions, a cheating player's checkpoint, lap, finish, gold, places, limit and rules message reaching neither the host nor another player. test_rules_state: the message's round trips, limits and fixed-seed random and mutated payloads. updateHost now takes only the carrier's own impacts, as ImpactCallback gets them from the carrier's vehCarDamage::ApplyImpact. mp:race: [:] picks the host's race in scripts. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/app/frontend/FrontendScreen.cpp | 10 +- src/game/session/CopsAndRobbers.cpp | 4 +- tests/game/test_net_race_rules.cpp | 804 ++++++++++++++++++++++++++++ tests/net/test_rules_state.cpp | 254 +++++++++ 4 files changed, 1069 insertions(+), 3 deletions(-) create mode 100644 tests/game/test_net_race_rules.cpp create mode 100644 tests/net/test_rules_state.cpp diff --git a/src/app/frontend/FrontendScreen.cpp b/src/app/frontend/FrontendScreen.cpp index 25c3d79..7dbfc15 100644 --- a/src/app/frontend/FrontendScreen.cpp +++ b/src/app/frontend/FrontendScreen.cpp @@ -767,7 +767,7 @@ void Script::run(Frontend& fe, const std::string& cmd, const std::string& arg) { // Multiplayer automation: mp:host[:], mp:join:
[|], mp:chat:, // mp:ready, mp:start, mp:team:<0|1>, mp:mode:, // mp:car:[:], mp:traffic:[::] (the shared - // cruise traffic). + // cruise traffic), mp:race:[:]. 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") { @@ -814,6 +814,14 @@ void Script::run(Frontend& fe, const std::string& cmd, const std::string& arg) { applyNetTrafficDefaults(c); // as the host settings' game type lamps c.netTraffic = traffic; fe.ctx.netGame->setRaceConfig(c); + } else if (fe.ctx.netGame && sub == "race") { + // mp:race:[:]: the host's race (after mp:mode). + game::RaceConfig c = fe.ctx.netGame->raceConfig(); + const auto parts = str::split(rest, ':'); + c.raceIndex = static_cast(str::parseInt(parts.empty() ? "" : parts[0]).value_or(0)); + if (parts.size() > 1) + c.laps = std::max(1, static_cast(str::parseInt(parts[1]).value_or(3))); + 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, ':'); diff --git a/src/game/session/CopsAndRobbers.cpp b/src/game/session/CopsAndRobbers.cpp index 74910ab..757fda4 100644 --- a/src/game/session/CopsAndRobbers.cpp +++ b/src/game/session/CopsAndRobbers.cpp @@ -371,9 +371,9 @@ std::vector CopsAndRobbers::updateHost(float dt, const bool dropped = false; for (const auto& c : cars) { if (m_carrier == c.id) { + // The carrier's own impacts (its vehCarDamage::ApplyImpact). for (const auto& im : impacts) { - const int other = im.a == c.id ? im.b : (im.b == c.id ? im.a : -1); - if (other < 0 || other == c.id || im.impulse < kStealImpulse) + if (im.a != c.id || im.b < 0 || im.b == c.id || im.impulse < kStealImpulse) continue; lockOut(c.id, kDropLockout); drop(c.id, c.position, false, true); diff --git a/tests/game/test_net_race_rules.cpp b/tests/game/test_net_race_rules.cpp new file mode 100644 index 0000000..e5337f9 --- /dev/null +++ b/tests/game/test_net_race_rules.cpp @@ -0,0 +1,804 @@ +// The rules of a network race decided by the host (OpenMM2: the host is the +// authority, docs/multiplayer.md "Rules"): the host's referee +// (game::session::RaceReferee), a client's predicted checkpoints confirmed +// or corrected by the host's word (Session's net rules), the message that +// carries it (game::NetRules, net/RulesState.h), and Cops and Robbers' rules +// on the host with a client's predicted pickup. +#include "TestData.h" +#include "city/CityData.h" +#include "game/Strings.h" +#include "game/net/NetRules.h" +#include "game/session/CopsAndRobbers.h" +#include "game/session/Gate.h" +#include "game/session/RaceReferee.h" +#include "game/session/Session.h" +#include "game/session/Waypoints.h" +#include "net/VehicleDamage.h" +#include "vfs/GameSource.h" + +#include + +#include +#include +#include +#include +#include +#include + +using namespace mm2; +using namespace mm2::game; +using namespace mm2::game::session; + +namespace { + +constexpr float kStep = 1.0f / 60.0f; + +// A car facing -Z (heading 0) at `pos`. +Mat34 carAt(const Vec3& pos) { + Mat34 m = Mat34::identity(); + m.m3 = pos; + return m; +} + +// A straight course along -Z: the start at z = 0, then a gate every 100 m. +std::vector straightCourse(int gates) { + std::vector cps; + for (int i = 0; i <= gates; ++i) + cps.push_back(makeCheckpoint({0.0f, 0.0f, -100.0f * static_cast(i)}, 0.0f, 10.0f)); + return cps; +} + +const Vec3 kBox{2.0f, 1.0f, 4.0f}; + +// A car driven down the course, 1 m a sample, from z = `from`. +struct Car { + std::uint8_t id = 0; + std::uint32_t seq = 0; + float z = 5.0f; + void sample(RaceReferee& r, bool held, float metres = 1.0f) { + ++seq; + if (!held) + z -= metres; + r.sample(id, seq, carAt({0.0f, 0.0f, z}), kBox, held); + } +}; + +std::vector drain(RaceReferee& r) { return r.takeDecisions(); } + +} // namespace + +// --- The tracker ----------------------------------------------------------------------- + +TEST(NetRaceRules, TrackerAppliesARecordedHitAsItsGateTestDoes) { + // A car's hits applied again from mmWaypoints::Reset (a client taking + // the host's word) give the same waypoints as the gate tests did. + const auto cps = straightCourse(4); + WaypointTracker live, replay; + live.reset(cps, WaypointRule::CheckpointRace, 0, false); + replay.reset(cps, WaypointRule::CheckpointRace, 0, false); + std::vector hits; + for (float z = 5.0f; z > -420.0f; z -= 1.0f) { + std::vector steps; + const int index = live.detect(cps, carAt({0, 0, z}), kBox); + if (index >= 0 && live.apply(cps, index, {0, 0, z}, steps)) + hits.push_back(index); + } + ASSERT_EQ(hits, (std::vector{1, 2, 3, 4})); + EXPECT_TRUE(live.finished); + for (const int h : hits) { + std::vector steps; + EXPECT_TRUE(replay.apply(cps, h, {}, steps)); + } + EXPECT_EQ(replay.cleared, live.cleared); + EXPECT_EQ(replay.count, live.count); + EXPECT_TRUE(replay.finished); + // The rule refuses what it would not take: a waypoint already cleared, + // the finish before the last checkpoint. + WaypointTracker early; + early.reset(cps, WaypointRule::CheckpointRace, 0, false); + std::vector steps; + EXPECT_FALSE(early.apply(cps, 4, {}, steps)); + EXPECT_TRUE(early.apply(cps, 2, {}, steps)); + EXPECT_FALSE(early.apply(cps, 2, {}, steps)); +} + +// --- The host's referee ------------------------------------------------------------------ + +TEST(NetRaceRules, RefereeTimesEachCarFromItsOwnRelease) { + // A client's car is released at its own Go, which the host applies a + // trip later: both cars drive the same course alike and get the same + // time, whatever their samples' numbers. + RaceReferee::Config c; + c.mode = GameMode::Checkpoint; + c.checkpoints = straightCourse(3); + RaceReferee r(c); + r.addPlayer(0); + r.addPlayer(1); + Car host{0}, client{1}; + client.seq = 500; // a client numbers its own samples + for (int i = 0; i < 400; ++i) { + host.sample(r, i < 10); + client.sample(r, i < 30); + r.update(kStep); + } + const auto* h = r.player(0); + const auto* k = r.player(1); + ASSERT_TRUE(h && k); + EXPECT_EQ(h->hits, (std::vector{1, 2, 3})); + EXPECT_EQ(k->hits, h->hits); + ASSERT_TRUE(h->finish && k->finish); + EXPECT_FLOAT_EQ(*h->finish, *k->finish); + // The finish line is 300 m from the start, the car's nose 2.5 m ahead: + // the car crosses it in its sample 300 - ... (1 m a sample from z = 5). + EXPECT_NEAR(*h->finish, static_cast(h->hitSamples.back() - 10) * kStep, 1e-4f); + const auto& results = r.results(); + ASSERT_EQ(results.size(), 2u); + EXPECT_EQ(results[0].player, 0); // an equal time later goes after + // The first finish arms the timeout; both counted ends the race. + const auto d = drain(r); + EXPECT_TRUE(std::ranges::any_of( + d, [](const auto& e) { return e.kind == RaceReferee::Decision::Kind::AllCounted; })); + EXPECT_FALSE(r.timedOut()); +} + +TEST(NetRaceRules, RefereeTimesTheRaceOutAFinishLater) { + // mmMultiRace: 60 s after the first finish, 0x1fe: everyone still racing + // does not finish, and the race is counted (0x211). + RaceReferee::Config c; + c.mode = GameMode::Checkpoint; + c.checkpoints = straightCourse(2); + RaceReferee r(c); + r.addPlayer(0); + r.addPlayer(1); + r.addPlayer(2); // still loading: waited for, without a car + Car a{0}, b{1}; + for (int i = 0; i < 400 && !r.player(0)->finish; ++i) { + a.sample(r, false); + b.sample(r, i > 50, 0.2f); // stops after 50 samples + r.update(kStep); + } + ASSERT_TRUE(r.player(0)->finish); + EXPECT_FALSE(r.player(1)->finish); + drain(r); + for (int i = 0; i < 59 * 60; ++i) + r.update(kStep); + EXPECT_FALSE(r.timedOut()); + for (int i = 0; i < 2 * 60; ++i) + r.update(kStep); + EXPECT_TRUE(r.timedOut()); + const auto d = drain(r); + ASSERT_GE(d.size(), 4u); + EXPECT_EQ(d[0].kind, RaceReferee::Decision::Kind::TimedOut); + EXPECT_EQ(d[1].player, 1); + EXPECT_EQ(d[1].seconds, RaceReferee::kDnf); + EXPECT_EQ(d[2].player, 2); + EXPECT_EQ(d.back().kind, RaceReferee::Decision::Kind::AllCounted); + ASSERT_EQ(r.results().size(), 3u); + EXPECT_EQ(r.results()[0].player, 0); + // A circuit waits 120 s. + RaceReferee::Config cc = c; + cc.mode = GameMode::Circuit; + cc.laps = 1; + RaceReferee circuit(cc); + EXPECT_EQ(circuit.config().mode, GameMode::Circuit); +} + +TEST(NetRaceRules, RefereeCountsTheLapsOfACircuitInOrder) { + RaceReferee::Config c; + c.mode = GameMode::Circuit; + c.checkpoints = straightCourse(2); // 0, 1, 2 in order; 0 completes a lap + c.laps = 2; + RaceReferee r(c); + r.addPlayer(0); + Car a{0}; + auto lap = [&] { + a.z = 5.0f; + for (int i = 0; i < 230; ++i) + a.sample(r, false); + }; + lap(); // 1, 2 (gate 0 is behind the car) + a.z = 30.0f; + for (int i = 0; i < 260; ++i) // 0 (lap 1), 1, 2 + a.sample(r, false); + EXPECT_EQ(r.player(0)->wp.lap, 1); + a.z = 30.0f; + for (int i = 0; i < 40; ++i) // 0: lap 2, the finish + a.sample(r, false); + EXPECT_EQ(r.player(0)->hits, (std::vector{1, 2, 0, 1, 2, 0})); + EXPECT_TRUE(r.player(0)->finish); + // Out of order a gate does not count (the target is the next one). + RaceReferee r2(c); + r2.addPlayer(0); + Car b{0}; + b.z = -150.0f; // skips gate 1 + for (int i = 0; i < 100; ++i) + b.sample(r2, false); + EXPECT_TRUE(r2.player(0)->hits.empty()); +} + +TEST(NetRaceRules, RefereeEndsABlitzOnEachCarsClockAndTheHosts) { + RaceReferee::Config c; + c.mode = GameMode::Blitz; + c.checkpoints = straightCourse(3); + c.timeLimit = 2.0f; + c.host = 0; + RaceReferee r(c); + r.addPlayer(0); + r.addPlayer(1); + r.addPlayer(2); + Car host{0}, early{1}, late{2}; + for (int i = 0; i < 100; ++i) { + early.sample(r, false, 0.1f); + r.update(kStep); + } + for (int i = 0; i < 30; ++i) { + early.sample(r, false, 0.1f); + host.sample(r, i < 20, 0.1f); + late.sample(r, i < 25, 0.1f); + r.update(kStep); + } + // The early car's clock ran out (120 samples): it did not finish. + ASSERT_TRUE(r.player(1)->finish); + EXPECT_EQ(*r.player(1)->finish, RaceReferee::kDnf); + EXPECT_FALSE(r.timedOut()); + drain(r); + for (int i = 0; i < 110; ++i) { + host.sample(r, false, 0.1f); + late.sample(r, false, 0.1f); + r.update(kStep); + } + // The host's ran out: everyone still racing is out (0x1fe). + EXPECT_TRUE(r.timedOut()); + ASSERT_TRUE(r.player(2)->finish); + EXPECT_EQ(*r.player(2)->finish, RaceReferee::kDnf); + // A Blitz does not wait for everyone (no 0x211). + EXPECT_FALSE(r.allCounted()); +} + +TEST(NetRaceRules, RefereeRanksEveryCarAsItsMachineWould) { + RaceReferee::Config c; + c.mode = GameMode::Checkpoint; + c.checkpoints = straightCourse(4); + RaceReferee r(c); + for (std::uint8_t id : {0, 1, 2}) + r.addPlayer(id); + Car a{0}, b{1}, d{2}; + a.z = -150.0f; // has cleared nothing (it started past gate 1), and stops + for (int i = 0; i < 160; ++i) { + a.sample(r, i > 0); + b.sample(r, false, 1.0f); // clears 1 at about z = -97 + d.sample(r, false, 0.5f); // at z = -75 + } + r.update(kStep); + // b: one waypoint passed, ahead of both. + EXPECT_EQ(r.player(1)->place, 1); + EXPECT_EQ(r.player(1)->racers, 3); + EXPECT_EQ(r.iconPlace(0, 1), 1); + // a and d level at the start's count: d is 25 m from gate 1, a 51 m. + EXPECT_EQ(r.player(2)->place, 2); + EXPECT_EQ(r.player(0)->place, 3); + EXPECT_EQ(r.iconPlace(1, 2), 2); + EXPECT_EQ(r.iconPlace(1, 0), 3); + // On d's machine a's icon: d itself is ahead of it, b is. + EXPECT_EQ(r.iconPlace(2, 0), 3); + // Nobody's own car gets an icon; a player who left has none and no place. + EXPECT_EQ(r.iconPlace(1, 1), 0); + r.removePlayer(2); + r.update(kStep); + EXPECT_EQ(r.iconPlace(1, 2), 0); + EXPECT_EQ(r.player(0)->racers, 2); +} + +// --- Cops and Robbers on the host ------------------------------------------------------- + +namespace { + +CrLocations crPlaces() { + CrLocations loc; + for (int i = 0; i < 6; ++i) + loc.points.push_back({100.0f * static_cast(i), 0, 0}); + return loc; +} + +} // namespace + +TEST(NetRaceRules, HostRunsCopsAndRobbersForEveryCar) { + CrSettings st; + st.mode = CopsAndRobbersMode::FreeForAll; + st.seed = 7; + CopsAndRobbers host(st, crPlaces()); + host.addCar(0, CrTeam::Robber); + host.addCar(1, CrTeam::Robber); + using Type = CopsAndRobbers::Message::Type; + // Player 1's car (simulated by the host) reaches the gold: granted. + std::vector cars{{0, CrTeam::Robber, {5000, 0, 5000}, false, false}, + {1, CrTeam::Robber, host.set().gold, false, false}}; + auto out = host.updateHost(0.1f, cars, {}); + ASSERT_EQ(out.size(), 1u); + EXPECT_EQ(out[0].type, Type::GoldTaken); + EXPECT_EQ(out[0].car, 1); + EXPECT_EQ(host.goldCarrier(), 1); + EXPECT_EQ(host.playerScore(1), CopsAndRobbers::kPickupPoints); + // A hit from the host's car on the carrier knocks it loose there and + // locks the carrier out for 2 s; nobody takes it in that frame. + cars[0].position = cars[1].position = {320, 0, 10}; + out = host.updateHost(0.1f, cars, {{1, 0, 300.0f}}); + ASSERT_EQ(out.size(), 1u); + EXPECT_EQ(out[0].type, Type::GoldDropped); + EXPECT_TRUE(out[0].knocked); + EXPECT_EQ(host.goldCarrier(), -1); + // The next frame the host's car takes it (player 1 is locked out). + out = host.updateHost(0.1f, cars, {}); + ASSERT_EQ(out.size(), 1u); + EXPECT_EQ(out[0].car, 0); + // A weak hit, or one of another car's own, does not. + EXPECT_TRUE(host.updateHost(0.1f, cars, {{0, 1, 100.0f}, {1, 0, 900.0f}}).empty()); + // The carrier at its base delivers; the host draws the next places. + cars[0].position = host.deliveryTarget(CrTeam::Robber); + out = host.updateHost(0.1f, cars, {}); + ASSERT_EQ(out.size(), 2u); + EXPECT_EQ(out[0].type, Type::GoldDelivered); + EXPECT_EQ(out[1].type, Type::NewSet); + EXPECT_EQ(host.playerScore(0), CopsAndRobbers::kPickupPoints + CopsAndRobbers::kDeliveryPoints); + // A wrecked car sits out: it cannot take the gold. + cars[0].position = {5000, 0, 5000}; + cars[1].position = host.set().gold; + cars[1].wrecked = true; + EXPECT_TRUE(host.updateHost(0.1f, cars, {}).empty()); +} + +TEST(NetRaceRules, AClientsPredictedPickupIsConfirmedOrUndone) { + CrSettings st; + st.limitsFromHost = true; + st.seed = 7; + using E = CopsAndRobbers::EventType; + auto client = [&] { + CopsAndRobbers c(st, crPlaces()); + c.addCar(0, CrTeam::Robber); + c.addCar(1, CrTeam::Robber); + c.takeEvents(); // the first places + return c; + }; + // Confirmed: shown once. + CopsAndRobbers c = client(); + const CopsAndRobbers::Car me{1, CrTeam::Robber, c.set().gold, false, false}; + EXPECT_TRUE(c.updatePredicted(0.1f, me, {me}, 2)); + EXPECT_EQ(c.goldCarrier(), 1); + auto ev = c.takeEvents(); + ASSERT_EQ(ev.size(), 1u); + EXPECT_EQ(ev[0].type, E::GoldTaken); + EXPECT_FALSE(c.updatePredicted(0.1f, me, {me}, 2)); // not again + c.applyHost({CopsAndRobbers::Message::Type::GoldTaken, 1}, 1); + EXPECT_TRUE(c.takeEvents().empty()); + EXPECT_FALSE(c.pickupPending()); + // Another car had it: undone, and that car's pickup shown. + CopsAndRobbers d = client(); + EXPECT_TRUE(d.updatePredicted(0.1f, me, {me}, 2)); + d.takeEvents(); + d.applyHost({CopsAndRobbers::Message::Type::GoldTaken, 0}, 1); + ev = d.takeEvents(); + ASSERT_EQ(ev.size(), 2u); + EXPECT_EQ(ev[0].type, E::PickupUndone); + EXPECT_EQ(ev[1].type, E::GoldTaken); + EXPECT_EQ(ev[1].car, 0); + EXPECT_EQ(d.goldCarrier(), 0); + EXPECT_EQ(d.playerScore(1), 0); + // The host never granted it: the state undoes it once the host has seen + // the car long enough, not before. + CopsAndRobbers e = client(); + EXPECT_TRUE(e.updatePredicted(0.1f, me, {me}, 2)); + e.takeEvents(); + CopsAndRobbers::State hostState; + hostState.carrier = -1; + hostState.goldPosition = e.set().gold; + hostState.set = e.set(); + e.adopt(hostState, 1, false); + EXPECT_EQ(e.goldCarrier(), 1); + e.adopt(hostState, 1, true); + EXPECT_EQ(e.goldCarrier(), -1); + ev = e.takeEvents(); + ASSERT_EQ(ev.size(), 1u); + EXPECT_EQ(ev[0].type, E::PickupUndone); + // Alone in the game a car takes nothing (UpdateGold's other players). + CopsAndRobbers f = client(); + EXPECT_FALSE(f.updatePredicted(0.1f, me, {me}, 1)); +} + +// --- The message ---------------------------------------------------------------------------- + +TEST(NetRaceRules, TheHostRefusesEveryPlayersWordOnTheRules) { + using T = net::GameEventType; + const auto custom = static_cast(T::Custom); + for (const auto type : {T::CheckpointReached, T::LapCompleted, T::RaceFinished, T::GoldPickedUp, + T::GoldDropped, T::GoldDelivered}) { + EXPECT_FALSE(hostAcceptsGameEvent(1, static_cast(type))); + EXPECT_TRUE(hostAcceptsGameEvent(net::kHostPlayerId, static_cast(type))); + } + for (std::uint16_t t : {std::uint16_t(custom + 1), std::uint16_t(custom + 2), std::uint16_t(custom + 3), + net::kRulesEvent}) + EXPECT_FALSE(hostAcceptsGameEvent(3, t)); + EXPECT_TRUE(hostAcceptsGameEvent(1, static_cast(T::LeftRace))); + EXPECT_TRUE(hostAcceptsGameEvent(1, static_cast(T::Collision))); + EXPECT_TRUE(hostAcceptsGameEvent(1, net::kVehicleDamageEvent)); +} + +namespace { + +struct RulesRetail { + city::CityData sf; + Strings strings; +}; + +RulesRetail* rulesRetail() { + static std::unique_ptr r = []() -> std::unique_ptr { + if (!test::gameData()) + return nullptr; + auto src = vfs::probeGameSource(std::getenv("OPENMM2_GAME_DATA")); + auto city = city::loadCity(*test::gameData(), "sf"); + if (!src || !city) + return nullptr; + auto out = std::make_unique(); + out->sf = std::move(*city); + out->strings = Strings::load(*src); + return out; + }(); + return r.get(); +} + +std::unique_ptr netSession(GameMode mode, bool host, int laps = 2) { + RaceConfig cfg; + cfg.mode = mode; + cfg.city = "sf"; + cfg.raceIndex = 0; + cfg.laps = laps; + cfg.multiplayer = true; + SessionOptions o; + o.seed = 7; + o.netHost = host; + o.netRules = true; + o.playerName = host ? "Host" : "Client"; + std::string error; + auto s = Session::create(cfg, rulesRetail()->sf, *test::gameData(), rulesRetail()->strings, &error, o); + EXPECT_TRUE(s) << error; + if (s) { + s->start(); + s->setNetStart(0.0f); + } + return s; +} + +// A player's car at waypoint `index` of the session's race, facing its way. +PlayerState atWaypoint(const Session& s, int index) { + PlayerState p; + p.transform = spawnAt(s.checkpoints()[static_cast(index)]); + return p; +} + +PlayerState away(const Session& s) { + PlayerState p; + p.transform = s.playerSpawn(); + p.transform.m3 = p.transform.m3 + Vec3{0.0f, 0.0f, 3000.0f}; + return p; +} + +int count(const std::vector& events, EventType type) { + return static_cast(std::ranges::count_if(events, [type](const Event& e) { return e.type == type; })); +} + +int sounds(const std::vector& events, GameSound sound) { + return static_cast(std::ranges::count_if(events, [sound](const Event& e) { + return e.type == EventType::Sound && e.index == static_cast(sound) && e.value >= 0.0f; + })); +} + +} // namespace + +TEST(NetRaceRules, AClientShowsAPredictedCheckpointOnceAndTakesBackOneTheHostNeverCounted) { + MM2_REQUIRE_GAME_DATA(); + ASSERT_TRUE(rulesRetail()); + auto s = netSession(GameMode::Checkpoint, false); + ASSERT_TRUE(s); + ASSERT_GE(s->checkpoints().size(), 4u); + for (int i = 0; i < 3; ++i) + s->update(kStep, away(*s)); + ASSERT_EQ(s->phase(), Phase::Racing); + s->takeEvents(); + // Predicted at sample 100: shown at once, with its sound. + s->setNetSample(100); + s->update(kStep, atWaypoint(*s, 1)); + auto ev = s->takeEvents(); + EXPECT_EQ(count(ev, EventType::CheckpointCleared), 1); + EXPECT_EQ(sounds(ev, GameSound::Waypoint), 1); + EXPECT_EQ(s->checkpointsCleared(), 1); + // The host's word confirms it: nothing more. + s->applyNetProgress({103, 0, {1}}, s->checkpoints()[1].position); + ev = s->takeEvents(); + EXPECT_TRUE(ev.empty()); + EXPECT_EQ(s->netHits(), (std::vector{1})); + // Predicted at sample 200, the host's word without it: kept while the + // host has not run kNetHitMargin samples past it, then taken back + // without a sound. + s->setNetSample(200); + s->update(kStep, atWaypoint(*s, 2)); + s->takeEvents(); + EXPECT_EQ(s->checkpointsCleared(), 2); + s->applyNetProgress({200 + Session::kNetHitMargin - 1, 0, {1}}, {}); + EXPECT_TRUE(s->takeEvents().empty()); + EXPECT_EQ(s->checkpointsCleared(), 2); + s->applyNetProgress({200 + Session::kNetHitMargin, 0, {1}}, {}); + ev = s->takeEvents(); + EXPECT_EQ(count(ev, EventType::CheckpointTakenBack), 1); + EXPECT_EQ(count(ev, EventType::CheckpointCleared), 0); + EXPECT_EQ(count(ev, EventType::Sound), 0); + EXPECT_EQ(s->checkpointsCleared(), 1); + EXPECT_TRUE(s->checkpointVisible(2)); + // One the host counted that this machine did not see: shown when the + // word comes, once. + s->applyNetProgress({300, 0, {1, 3}}, {}); + ev = s->takeEvents(); + ASSERT_EQ(count(ev, EventType::CheckpointCleared), 1); + const auto shown = + std::ranges::find_if(ev, [](const Event& e) { return e.type == EventType::CheckpointCleared; }); + EXPECT_EQ(shown->index, 3); + EXPECT_EQ(shown->value, 1.0f); // brought by the host's word + EXPECT_EQ(sounds(ev, GameSound::Waypoint), 1); + s->applyNetProgress({330, 0, {1, 3}}, {}); + EXPECT_TRUE(s->takeEvents().empty()); + EXPECT_EQ(s->netHits(), (std::vector{1, 3})); +} + +TEST(NetRaceRules, AClientsFinishAndStandingsAreTheHosts) { + MM2_REQUIRE_GAME_DATA(); + ASSERT_TRUE(rulesRetail()); + auto s = netSession(GameMode::Checkpoint, false); + ASSERT_TRUE(s); + const int n = static_cast(s->checkpoints().size()); + for (int i = 0; i < 3; ++i) + s->update(kStep, away(*s)); + // Every waypoint and the finish predicted: no finish without the host. + std::uint32_t seq = 10; + for (int i = 1; i < n; ++i) { + s->setNetSample(seq += 10); + s->update(kStep, atWaypoint(*s, i)); + } + s->takeEvents(); + EXPECT_EQ(s->phase(), Phase::Racing); + EXPECT_EQ(s->playerHold(), PlayerHold::None); + // The host's standings. + s->setNetStanding(2, 3); + EXPECT_EQ(s->position(), 2); + EXPECT_EQ(s->racerCount(), 3); + // Another player's finish, then this one's with the host's time. + s->remoteFinished("Other", 80.0f); + s->netFinished(83.25f); + auto ev = s->takeEvents(); + EXPECT_EQ(count(ev, EventType::NetFinished), 0); // nothing to claim + EXPECT_EQ(count(ev, EventType::PlayerFinished), 1); + EXPECT_EQ(s->phase(), Phase::PostRace); + EXPECT_EQ(s->message2().text.find("1:23:25") != std::string::npos, true); + // The race waits for the host's 0x211. + for (int i = 0; i < 6 * 60; ++i) + s->update(kStep, away(*s)); + EXPECT_EQ(s->phase(), Phase::PostRace); + s->netAllCounted(); + s->update(kStep, away(*s)); + EXPECT_EQ(s->phase(), Phase::Done); + const auto r = s->result(); + ASSERT_EQ(r.standings.size(), 2u); + EXPECT_EQ(r.standings[1].name, "Client"); + EXPECT_FLOAT_EQ(r.standings[1].timeSeconds, 83.25f); + EXPECT_EQ(r.position, 2); +} + +TEST(NetRaceRules, AClientStillRacingAtTheHostsTimeoutDoesNotFinish) { + MM2_REQUIRE_GAME_DATA(); + ASSERT_TRUE(rulesRetail()); + auto s = netSession(GameMode::Circuit, false); + ASSERT_TRUE(s); + for (int i = 0; i < 3; ++i) + s->update(kStep, away(*s)); + s->remoteFinished("Other", 300.0f); + // No timeout of its own: only the host's word ends the race. + for (int i = 0; i < 130 * 60 / 10; ++i) + s->update(10.0f * kStep, away(*s)); + EXPECT_EQ(s->phase(), Phase::Racing); + s->takeEvents(); + s->netTimedOut(); + s->netFinished(Session::kNetDnf); + EXPECT_EQ(s->phase(), Phase::PostRace); + EXPECT_EQ(s->playerHold(), PlayerHold::Undrivable); + EXPECT_FALSE(s->result().finished); +} + +TEST(NetRaceRules, TheHostsMessageReachesAClientWhateverIsLostOrForged) { + MM2_REQUIRE_GAME_DATA(); + ASSERT_TRUE(rulesRetail()); + auto hs = netSession(GameMode::Checkpoint, true); + auto cs = netSession(GameMode::Checkpoint, false); + ASSERT_TRUE(hs && cs); + NetRules host, client; + host.begin({true, 0, 7, hs.get(), nullptr, {0, 1}}); + client.begin({false, 1, 7, cs.get(), nullptr, {0, 1}}); + host.setName(0, "Host"); + host.setName(1, "Client"); + client.setName(0, "Host"); + client.setName(1, "Client"); + std::vector> wire; + auto send = [&](std::uint8_t to, std::vector payload) { + EXPECT_EQ(to, 1); + wire.push_back(std::move(payload)); + }; + auto event = [](std::vector payload, std::uint8_t from = net::kHostPlayerId) { + NetGameEvent e; + e.from = from; + e.type = static_cast(net::kRulesEvent); + e.payload = std::move(payload); + return e; + }; + for (int i = 0; i < 3; ++i) { + hs->update(kStep, away(*hs)); + cs->update(kStep, away(*cs)); + } + // The client's car (on the host) crosses waypoint 1 at its sample 40; + // the client never predicted it. + const auto& cps = hs->checkpoints(); + std::uint32_t seq = 0; + auto drive = [&](int waypoint, int samples) { + for (int i = 0; i < samples; ++i) { + const Mat34 at = i == samples / 2 ? spawnAt(cps[static_cast(waypoint)]) + : away(*hs).transform; + host.hostSample(1, ++seq, at, kBox, false); + host.hostSample(0, seq, away(*hs).transform, kBox, false); + } + }; + drive(1, 80); + host.hostUpdate(kStep, 1000, away(*hs).transform.m3, send); + ASSERT_EQ(wire.size(), 1u); + // A forged message (from another player), a malformed one and one for + // another race are refused; then the host's is taken. + net::RulesMsg forged; + forged.race = 7; + forged.seq = 50; + forged.hits = {1, 2, 3}; + client.receive({event(net::encodePayload(forged), 2)}, {}); + std::vector junk(wire[0].begin(), wire[0].begin() + 5); + client.receive({event(junk)}, {}); + forged.race = 6; + client.receive({event(net::encodePayload(forged))}, {}); + EXPECT_EQ(client.stats().refused, 1u); + EXPECT_EQ(client.stats().malformed, 1u); + EXPECT_EQ(client.stats().stale, 1u); + EXPECT_TRUE(cs->netHits().empty()); + client.receive({event(wire[0])}, {}); + EXPECT_EQ(cs->netHits(), (std::vector{1})); + // The same message again (a duplicate) changes nothing. + client.receive({event(wire[0])}, {}); + EXPECT_EQ(client.stats().stale, 2u); + // The client finishes on the host; its message is lost; the next one's + // results stand in for the decision. + for (int w = 2; w < static_cast(cps.size()); ++w) + drive(w, 40); + wire.clear(); + host.hostUpdate(kStep, 2000, away(*hs).transform.m3, send); + ASSERT_EQ(wire.size(), 1u); + ASSERT_FALSE(host.decisions().empty()); + EXPECT_EQ(host.decisions().back().type, net::RuleDecision::Finished); + wire.clear(); // lost (ENet would resend it; the state does not need it) + host.hostUpdate(kStep, 2000 + NetRules::kStateIntervalMs, away(*hs).transform.m3, send); + ASSERT_EQ(wire.size(), 1u); + client.receive({event(wire[0])}, {}); + EXPECT_TRUE(cs->netFinishKnown()); + EXPECT_EQ(cs->phase(), Phase::PostRace); + EXPECT_TRUE(client.finished(1)); + // The host's own session has the client's finish in its results. + EXPECT_TRUE(host.finished(1)); +} + +// --- Through live sessions ------------------------------------------------------------------ + +namespace { + +// This file's block of ports (below 49152; see test_netgame.cpp). +std::uint16_t rulesPort() { + static const std::uint16_t port = static_cast( + 25000 + (std::chrono::steady_clock::now().time_since_epoch().count() / 1000) % 1000 * 4); + return port; +} + +NetOptions rulesOptions(const std::string& name) { + NetOptions o; + o.playerName = name; + o.port = rulesPort(); + o.portMapping = false; + o.discoveryPort = static_cast(rulesPort() + 1); + return o; +} + +bool pumpAll(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(NetRaceRules, APlayersOwnWordOnTheRulesReachesNobody) { + NetGame host(rulesOptions("Hosty")), cheat(rulesOptions("Cheaty")), honest(rulesOptions("Honesty")); + RaceConfig race; + race.mode = GameMode::Checkpoint; + race.city = "sf"; + race.raceIndex = 0; + NetHostOptions ho; + ho.advertiseOnLan = false; + std::string err; + ASSERT_TRUE(host.host(race, ho, {"vpbug", 0, 0}, &err)) << err; + const std::string at = std::format("127.0.0.1:{}", rulesPort()); + ASSERT_TRUE(cheat.join(at, "", {"vpcab", 0, 0}, &err)) << err; + ASSERT_TRUE(honest.join(at, "", {"vpcab", 0, 0}, &err)) << err; + ASSERT_TRUE(pumpAll({&host, &cheat, &honest}, [&] { + return host.players().size() == 3 && cheat.phase() == NetGame::Phase::Lobby && + honest.phase() == NetGame::Phase::Lobby && honest.players().size() == 3; + })); + cheat.setReady(true); + honest.setReady(true); + ASSERT_TRUE(pumpAll({&host, &cheat, &honest}, [&] { return host.everyoneReady(false); })); + host.startRace(); + ASSERT_TRUE(pumpAll({&host, &cheat, &honest}, [&] { + return host.phase() == NetGame::Phase::Countdown && cheat.phase() == NetGame::Phase::Countdown && + honest.phase() == NetGame::Phase::Countdown; + })); + // The cheat claims checkpoints, a finish, the gold, Cops and Robbers' + // places and limit and a rules message of its own, to everyone and to + // one player; then a collision, which is no claim. + cheat.sendCheckpoint(9, 1000); + cheat.sendLap(3, 1000); + cheat.sendFinish(1000, 1); + cheat.sendGold(net::GameEventType::GoldPickedUp, {}, 0); + cheat.sendGold(net::GameEventType::GoldDelivered, {}, 0); + const auto custom = static_cast(net::GameEventType::Custom); + for (std::uint16_t t : {std::uint16_t(custom + 1), std::uint16_t(custom + 2), std::uint16_t(custom + 3)}) + cheat.sendEvent(t, net::encodePayload(net::GoldEvent{})); + net::RulesMsg forged; + forged.race = host.raceNumber(); + forged.seq = 1; + forged.hits = {1, 2, 3}; + cheat.sendEvent(net::kRulesEvent, net::encodePayload(forged)); + cheat.sendEvent(net::kRulesEvent, net::encodePayload(forged), honest.localId()); + cheat.sendCollision(net::kHostPlayerId, {}, 5.0f); + std::vector atHost, atHonest; + ASSERT_TRUE(pumpAll({&host, &cheat, &honest}, [&] { + for (auto& e : host.takeGameEvents()) + atHost.push_back(std::move(e)); + for (auto& e : honest.takeGameEvents()) + atHonest.push_back(std::move(e)); + return !atHost.empty() && !atHonest.empty(); + })); + pumpAll({&host, &cheat, &honest}, [] { return false; }, 200); + for (auto& e : host.takeGameEvents()) + atHost.push_back(std::move(e)); + for (auto& e : honest.takeGameEvents()) + atHonest.push_back(std::move(e)); + ASSERT_EQ(atHost.size(), 1u); + EXPECT_EQ(atHost[0].type, net::GameEventType::Collision); + ASSERT_EQ(atHonest.size(), 1u); + EXPECT_EQ(atHonest[0].type, net::GameEventType::Collision); + // The host's own rules message reaches its player, from the host. + net::RulesMsg word; + word.race = host.raceNumber(); + word.seq = 1; + host.sendEvent(net::kRulesEvent, net::encodePayload(word), honest.localId()); + std::vector got; + ASSERT_TRUE(pumpAll({&host, &honest}, [&] { + for (auto& e : honest.takeGameEvents()) + got.push_back(std::move(e)); + return !got.empty(); + })); + EXPECT_EQ(static_cast(got[0].type), net::kRulesEvent); + EXPECT_EQ(got[0].from, net::kHostPlayerId); +} diff --git a/tests/net/test_rules_state.cpp b/tests/net/test_rules_state.cpp new file mode 100644 index 0000000..1c3530c --- /dev/null +++ b/tests/net/test_rules_state.cpp @@ -0,0 +1,254 @@ +// The host's rules message (net/RulesState.h, protocol 8): its encoding, +// its limits, and fixed-seed random and mutated payloads, which must never +// crash, read out of bounds (run under the sanitizers) or decode to a value +// the writer could not have produced. +#include "net/RulesState.h" + +#include + +#include +#include +#include + +using namespace mm2; +using namespace mm2::net; + +namespace { + +RulesMsg raceMessage() { + RulesMsg m; + m.race = 3; + m.seq = 17; + m.evaluated = 123456; + RuleDecisionMsg f; + f.seq = 4; + f.type = RuleDecision::Finished; + f.player = 2; + f.ms = 83250; + m.decisions.push_back(f); + RuleDecisionMsg t; + t.seq = 5; + t.type = RuleDecision::TimedOut; + m.decisions.push_back(t); + RuleDecisionMsg a; + a.seq = 6; + a.type = RuleDecision::AllCounted; + m.decisions.push_back(a); + m.firstHit = 64; + m.hits = {1, 2, 3, 0, 1, 2, 3, 0}; + m.place = 2; + m.racers = 3; + m.icons = {{0, 1}, {2, 0}}; + m.results = {{2, 83250}, {0, kRuleDnfMs}}; + m.timedOut = true; + m.allCounted = true; + return m; +} + +RulesMsg copsMessage() { + RulesMsg m; + m.race = 9; + m.seq = 1; + m.evaluated = 77; + m.cops = true; + RuleDecisionMsg taken; + taken.seq = 1; + taken.type = RuleDecision::GoldTaken; + taken.player = 1; + RuleDecisionMsg dropped; + dropped.seq = 2; + dropped.type = RuleDecision::GoldDropped; + dropped.player = 1; + dropped.position = {12.5f, -3.0f, 900.0f}; + dropped.flag = true; + RuleDecisionMsg delivered; + delivered.seq = 3; + delivered.type = RuleDecision::GoldDelivered; + delivered.player = 0; + RuleDecisionMsg set; + set.seq = 4; + set.type = RuleDecision::NewSet; + set.gold = {1, 2, 3}; + set.bank = {4, 5, 6}; + set.hideout = {7, 8, 9}; + RuleDecisionMsg limit; + limit.seq = 5; + limit.type = RuleDecision::Limit; + limit.flag = true; + limit.player = 0; + limit.value = 225; + m.decisions = {taken, dropped, delivered, set, limit}; + m.carrier = kInvalidPlayerId; + m.goldActive = true; + m.goldPosition = {1, 2, 3}; + m.bank = {4, 5, 6}; + m.setGold = {1, 2, 3}; + m.hideout = {7, 8, 9}; + m.scores = {{0, 225}, {1, 25}}; + m.over = true; + return m; +} + +std::optional roundTrip(const RulesMsg& m) { + const auto bytes = encodePayload(m); + RulesMsg out; + if (!decodePayload(bytes, out)) + return std::nullopt; + return out; +} + +// Every value a decoded message holds is one a writer could have sent. +void expectSane(const RulesMsg& m) { + EXPECT_LE(m.decisions.size(), kMaxRuleDecisions); + EXPECT_LE(m.hits.size(), kMaxRuleHits); + EXPECT_LE(m.icons.size(), kMaxPlayers); + EXPECT_LE(m.results.size(), kMaxPlayers); + EXPECT_LE(m.scores.size(), kMaxPlayers); + auto finite = [](const Vec3& v) { + return std::isfinite(v.x) && std::isfinite(v.y) && std::isfinite(v.z) && + std::fabs(v.x) <= kMaxRulePosition && std::fabs(v.y) <= kMaxRulePosition && + std::fabs(v.z) <= kMaxRulePosition; + }; + for (const auto& d : m.decisions) { + EXPECT_LE(static_cast(d.type), static_cast(RuleDecision::Last)); + EXPECT_LE(d.ms, kRuleDnfMs); + EXPECT_TRUE(finite(d.position) && finite(d.gold) && finite(d.bank) && finite(d.hideout)); + EXPECT_GE(d.value, 0); + EXPECT_LE(d.value, kMaxRuleScore); + } + for (const auto& [id, place] : m.icons) { + EXPECT_NE(id, kInvalidPlayerId); + EXPECT_LE(place, kMaxPlayers); + } + for (const auto& [id, ms] : m.results) { + EXPECT_NE(id, kInvalidPlayerId); + EXPECT_LE(ms, kRuleDnfMs); + } + if (!m.cops) { + EXPECT_GE(m.place, 1); + EXPECT_LE(m.place, kMaxPlayers); + EXPECT_GE(m.racers, 1); + EXPECT_LE(m.racers, kMaxPlayers); + } + EXPECT_TRUE(finite(m.goldPosition) && finite(m.bank) && finite(m.setGold) && finite(m.hideout)); + for (const auto& [id, score] : m.scores) { + EXPECT_NE(id, kInvalidPlayerId); + EXPECT_GE(score, 0); + EXPECT_LE(score, kMaxRuleScore); + } +} + +} // namespace + +TEST(RulesState, RaceAndCopsMessagesRoundTrip) { + const auto race = raceMessage(); + const auto r = roundTrip(race); + ASSERT_TRUE(r); + EXPECT_EQ(*r, race); + const auto cops = copsMessage(); + const auto c = roundTrip(cops); + ASSERT_TRUE(c); + EXPECT_EQ(*c, cops); + // Both fit an event with room to spare at their limits. + RulesMsg big = raceMessage(); + big.hits.assign(kMaxRuleHits, 200); + big.decisions.assign(kMaxRuleDecisions, cops.decisions[3]); + big.icons.clear(); + big.results.clear(); + for (std::uint8_t i = 0; i < kMaxPlayers; ++i) { + big.icons.emplace_back(i, 3); + big.results.emplace_back(i, 1000u * i); + } + EXPECT_LE(encodePayload(big).size(), kMaxEventPayload); + EXPECT_TRUE(roundTrip(big)); +} + +TEST(RulesState, OutOfRangeMessagesAreRefused) { + auto refused = [](RulesMsg m) { return !roundTrip(m).has_value(); }; + RulesMsg m = raceMessage(); + m.hits.assign(kMaxRuleHits + 1, 1); + EXPECT_TRUE(refused(m)); + m = raceMessage(); + m.decisions.assign(kMaxRuleDecisions + 1, m.decisions[0]); + EXPECT_TRUE(refused(m)); + m = raceMessage(); + m.decisions[0].ms = kRuleDnfMs + 1; + EXPECT_TRUE(refused(m)); + m = raceMessage(); + m.decisions[0].player = kInvalidPlayerId; // a finish is somebody's + EXPECT_TRUE(refused(m)); + m = raceMessage(); + m.place = 0; + EXPECT_TRUE(refused(m)); + m = raceMessage(); + m.racers = 40; + EXPECT_TRUE(refused(m)); + m = raceMessage(); + m.icons.emplace_back(kInvalidPlayerId, 1); + EXPECT_TRUE(refused(m)); + m = raceMessage(); + m.icons[0].second = 200; + EXPECT_TRUE(refused(m)); + m = raceMessage(); + m.results[0].second = kRuleDnfMs + 5; + EXPECT_TRUE(refused(m)); + RulesMsg c = copsMessage(); + c.decisions[1].position.x = std::numeric_limits::quiet_NaN(); + EXPECT_TRUE(refused(c)); + c = copsMessage(); + c.decisions[3].bank.z = 1e9f; + EXPECT_TRUE(refused(c)); + c = copsMessage(); + c.goldPosition.y = std::numeric_limits::infinity(); + EXPECT_TRUE(refused(c)); + c = copsMessage(); + c.scores.emplace_back(kInvalidPlayerId, 3); + EXPECT_TRUE(refused(c)); + // A score beyond the range is clamped by the writer, never read as more. + c = copsMessage(); + c.scores[0].second = kMaxRuleScore * 2; + const auto out = roundTrip(c); + ASSERT_TRUE(out); + EXPECT_EQ(out->scores[0].second, kMaxRuleScore); + // A truncated message is refused. + auto bytes = encodePayload(raceMessage()); + RulesMsg in; + EXPECT_TRUE(decodePayload(bytes, in)); + bytes.resize(bytes.size() / 3); + EXPECT_FALSE(decodePayload(bytes, in)); +} + +TEST(RulesState, RandomAndMutatedPayloadsDecodeSafely) { + std::mt19937 rng(20261009); + std::uniform_int_distribution byte(0, 255); + std::uniform_int_distribution len(0, 600); + int decoded = 0; + for (int i = 0; i < 4000; ++i) { + std::vector bytes(static_cast(len(rng))); + for (auto& b : bytes) + b = static_cast(byte(rng)); + RulesMsg m; + if (decodePayload(bytes, m)) { + ++decoded; + expectSane(m); + } + } + const std::vector> seeds{encodePayload(raceMessage()), + encodePayload(copsMessage())}; + for (int i = 0; i < 6000; ++i) { + auto bytes = seeds[static_cast(i) % seeds.size()]; + const int flips = 1 + i % 6; + for (int k = 0; k < flips && !bytes.empty(); ++k) { + const auto at = static_cast(rng()) % bytes.size(); + bytes[at] ^= static_cast(1 << (rng() % 8)); + } + if (i % 7 == 0) + bytes.resize(static_cast(rng()) % (bytes.size() + 1)); + RulesMsg m; + if (decodePayload(bytes, m)) { + ++decoded; + expectSane(m); + } + } + EXPECT_GT(decoded, 0); +} From 78886cf22d19b47458953dd61c30118326d4dbb4 Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Fri, 9 Oct 2026 16:33:47 -0700 Subject: [PATCH 061/129] Let the host reset a client's car only where the game's rules do A client's reset of its car is now checked against the host's own simulation of it (game::ResetRules): a start only as the first command; back to the reset position or a checkpoint only after nearly five seconds in the water (mmGameMulti::HitWaterHandler) or below -50 m (DropThruCityHandler, which multiplayer treats as the water), a checkpoint respawn only at one of the race's checkpoints facing its heading, at most four a second; a repair only for a wreck's penalty or in Cops and Robbers. Refused commands are counted and, with OPENMM2_DEBUG_NETCARS, logged; the client's car is corrected back. The near cars' states are also tested on the wire: their size, their keys left out, and a client refusing its own car or a car given twice. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/app/RaceScreen.cpp | 56 +++++++++++++++------- src/game/net/PlayerCars.cpp | 27 +++++++++++ src/game/net/PlayerCars.h | 37 +++++++++++++++ src/net/PlayerCars.h | 9 ++++ tests/net/test_player_cars.cpp | 87 ++++++++++++++++++++++++++++++++-- 5 files changed, 195 insertions(+), 21 deletions(-) diff --git a/src/app/RaceScreen.cpp b/src/app/RaceScreen.cpp index c0a1e90..d568647 100644 --- a/src/app/RaceScreen.cpp +++ b/src/app/RaceScreen.cpp @@ -4924,19 +4924,28 @@ class RaceScreen final : public Screen { dropRemoteCars(present); } - // Host: whether a client's command may move its car: in the city, and - // not more than four times a second (a hostile client would teleport). - bool netCommandAllowed(const RemoteVehicle& rv, const net::CarCommand& c) const { - if (c.kind != net::CarCommandKind::ResetTo && c.kind != net::CarCommandKind::RespawnAt) - return true; - if (rv.placed && c.seq < rv.lastMoveSeq + 15) - return false; - const Aabb& city = m_city->psdl.bounds; - constexpr float kMargin = 200.0f; - const Vec3& at = c.position; - return at.x >= city.min.x - kMargin && at.x <= city.max.x + kMargin && at.y >= city.min.y - kMargin && - at.y <= city.max.y + kMargin && at.z >= city.min.z - kMargin && at.z <= city.max.z + kMargin; - } + // Host: whether a client's command may reset its car (game::ResetRules, + // on this machine's simulation of it). + bool netCommandAllowed(const RemoteVehicle& rv, const net::CarCommand& c) { + static const bool debug = std::getenv("OPENMM2_DEBUG_RESPAWN_MS") != nullptr; + if (m_netRespawnPoints.empty() && m_session) + for (const auto& cp : m_session->setup().checkpoints) + m_netRespawnPoints.push_back(game::session::spawnAt(cp)); + game::ResetRules r; + r.placed = rv.placed && rv.sim; + r.lastMoveSeq = rv.lastMoveSeq; + r.waterSamples = rv.waterSamples; + if (rv.sim) { + r.height = rv.sim->sim().body.ics.matrix.m3.y; + r.wrecked = rv.sim->sim().damage.maxDamaged(); + } + r.copsAndRobbers = m_result.config.mode == game::GameMode::CopsAndRobbers; + r.debug = debug; + r.city = m_city->psdl.bounds; + r.respawnPoints = m_netRespawnPoints; + return r.allows(c); + } + std::vector m_netRespawnPoints; // host: the race's checkpoints' spawns // Host: a simulated player's car's impact (vehCarDamage::ApplyImpact): // its sound, sparks and damage, which everyone is sent. @@ -4982,14 +4991,24 @@ class RaceScreen final : public Screen { for (auto& [id, rv] : m_remotes) { if (!rv.simulated || !rv.placed || !rv.sim) continue; + // The samples it has been in the water (netCommandAllowed). + rv.waterSamples = rv.sim->sim().splash.active() ? rv.waterSamples + 1 : 0; auto next = rv.inputs.next(); if (!next) continue; for (const auto& c : next->commands) { - if (!netCommandAllowed(rv, c)) + if (c.kind == net::CarCommandKind::ResetTo && c.seq == rv.lastMoveSeq) + continue; // the placement, carried out already + if (!netCommandAllowed(rv, c)) { + static const bool verbose = std::getenv("OPENMM2_DEBUG_NETCARS") != nullptr; + if (verbose) + log::info("netcars: refused player {}'s {} at sample {}", id, + net::carCommandName(c.kind), c.seq); + ++rv.refused; continue; + } game::NetCarDriver::command(*rv.sim, c); - if (c.kind == net::CarCommandKind::ResetTo || c.kind == net::CarCommandKind::RespawnAt) + if (c.kind != net::CarCommandKind::ClearDamage) rv.lastMoveSeq = c.seq; ++rv.resets; hostCarReset(id, rv); @@ -5369,8 +5388,9 @@ class RaceScreen final : public Screen { std::string queues; for (const auto& [id, rv] : m_remotes) if (rv.simulated) - queues += std::format(" player {}: {} inputs missed, {} dropped to catch up;", id, - rv.inputs.missed(), rv.inputs.skipped()); + queues += std::format(" player {}: {} inputs missed, {} dropped to catch up, {} resets " + "refused;", + id, rv.inputs.missed(), rv.inputs.skipped(), rv.refused); log::info("netcars: host sent {} states ({:.0f} B/s);{}", m_netStatesSent, static_cast(m_netStatesBytes) / seconds, queues); } else if (verbose) { @@ -6240,6 +6260,8 @@ class RaceScreen final : public Screen { std::uint32_t resets = 0; // the commands carried out std::uint32_t lastMoveSeq = 0; // the sample of the last command that moved it std::set nearIds; // the other cars sent to its player in full + int waterSamples = 0; // the samples it has been in the water (netCommandAllowed) + std::uint64_t refused = 0; // its commands the host refused // Client (OpenMM2): a car near this machine's that the host sends in // full (net::NearCarState) is simulated here along with this // machine's car instead of placed at its interpolated states diff --git a/src/game/net/PlayerCars.cpp b/src/game/net/PlayerCars.cpp index ffce666..cb9176b 100644 --- a/src/game/net/PlayerCars.cpp +++ b/src/game/net/PlayerCars.cpp @@ -275,6 +275,33 @@ void applyOwnCarState(SimVehicle& car, const net::OwnCarState& s) { sim.body.collider.lastMatrix = sim.body.boundMatrix; } +bool ResetRules::allows(const net::CarCommand& c) const { + const auto inCity = [this](const Vec3& at) { + constexpr float kMargin = 200.0f; + return at.x >= city.min.x - kMargin && at.x <= city.max.x + kMargin && at.y >= city.min.y - kMargin && + at.y <= city.max.y + kMargin && at.z >= city.min.z - kMargin && at.z <= city.max.z + kMargin; + }; + switch (c.kind) { + case net::CarCommandKind::ResetTo: return !placed && inCity(c.position); + case net::CarCommandKind::ClearDamage: return placed && (debug || wrecked || copsAndRobbers); + case net::CarCommandKind::Reset: + case net::CarCommandKind::RespawnAt: break; + } + if (!placed || c.seq < lastMoveSeq + kMoveInterval) + return false; + if (c.kind == net::CarCommandKind::RespawnAt) { + if (!inCity(c.position)) + return false; + const bool atCheckpoint = std::ranges::any_of(respawnPoints, [&](const Mat34& at) { + return at.m3.dist2(c.position) < 0.25f && + std::abs(std::remainder(phys::resetRotationOf(at) - c.rotation, 6.2831853f)) < 0.05f; + }); + if (!atCheckpoint && !debug) + return false; + } + return debug || waterSamples >= kWaterSamples || height < kDropHeight; +} + net::VehicleSnapshot carSnapshot(const SimVehicle& car, const net::CarInputFrame& input) { const phys::CarSim& sim = car.sim(); const Mat34 model = sim.modelMatrix(); diff --git a/src/game/net/PlayerCars.h b/src/game/net/PlayerCars.h index 05df19a..6847764 100644 --- a/src/game/net/PlayerCars.h +++ b/src/game/net/PlayerCars.h @@ -134,6 +134,43 @@ class HostInputQueue { std::uint64_t m_missed = 0, m_skipped = 0; }; +// Whether the host carries out a client's reset of its car (OpenMM2: the +// game resets a network player's car only where its rules do, and the host +// checks them on its own simulation of the car; a hostile client would +// teleport, or repair its car at will): +// * ResetTo puts the car at its start: only the first command, which +// places it, in the city; +// * Reset and RespawnAt (mmPlayer::Reset from mmGameMulti::HitWaterHandler, +// and DropThruCityHandler, which multiplayer treats as the water): the car +// has been in the water (its vehSplash latched) for nearly +// HitWaterHandler's 5 s, or fell below mmGame::Update's -50 m (the client's +// rules see its own car a little ahead of the host's: a second's and 10 m's +// slack); RespawnAt (a race with checkpoints) only at one of the race's +// checkpoints, facing its heading (Session's respawn there); not more than +// four times a second; +// * ClearDamage (vehCar::ClearDamage): the wreck penalty's repair (the car is +// past its maximum damage), or Cops and Robbers' repair at a delivery of the +// gold (mmMultiCR's UpdateBank / UpdateHideout: allowed in that mode until +// its rules run on the host). +// `debug` (OPENMM2_DEBUG_RESPAWN_MS on the host, a development aid) lets any +// reset in the city pass. +struct ResetRules { + bool placed = false; // the car is in the race (its first command carried out) + std::uint32_t lastMoveSeq = 0; // the sample of the last command that moved it + int waterSamples = 0; // the samples it has been in the water + float height = 0.0f; // its centre of mass's y + bool wrecked = false; // past its maximum damage (mmPlayer::IsMaxDamaged) + bool copsAndRobbers = false; + bool debug = false; + Aabb city; // the city's bounds (200 m round them allowed) + std::span respawnPoints; // the race's checkpoints' spawns (game::session::spawnAt) + + static constexpr std::uint32_t kMoveInterval = 15; // samples + static constexpr int kWaterSamples = 4 * 60; // HitWaterHandler's 5 s less a second + static constexpr float kDropHeight = -50.0f + 10.0f; + bool allows(const net::CarCommand& c) const; +}; + // The car's state as the host sends it to its player, and the car put there // (the rest of its state stays as it was). net::OwnCarState ownCarState(const SimVehicle& car, std::uint32_t resets); diff --git a/src/net/PlayerCars.h b/src/net/PlayerCars.h index 4a0a748..9f07a62 100644 --- a/src/net/PlayerCars.h +++ b/src/net/PlayerCars.h @@ -79,6 +79,15 @@ enum class CarCommandKind : std::uint8_t { ClearDamage, // vehCar::ClearDamage (Cops and Robbers' repairs, a damage reset) Last = ClearDamage, }; +constexpr const char* carCommandName(CarCommandKind k) { + switch (k) { + case CarCommandKind::Reset: return "Reset"; + case CarCommandKind::ResetTo: return "ResetTo"; + case CarCommandKind::RespawnAt: return "RespawnAt"; + case CarCommandKind::ClearDamage: return "ClearDamage"; + } + return "?"; +} struct CarCommand { std::uint32_t seq = 0; CarCommandKind kind = CarCommandKind::Reset; diff --git a/tests/net/test_player_cars.cpp b/tests/net/test_player_cars.cpp index 44aae5a..a162b06 100644 --- a/tests/net/test_player_cars.cpp +++ b/tests/net/test_player_cars.cpp @@ -86,7 +86,7 @@ OwnCarState sampleOwn() { return o; } -CarStatesMsg sampleStates(std::size_t cars) { +CarStatesMsg sampleStates(std::size_t cars, std::size_t near = 0) { CarStatesMsg m; m.time = 98765; m.ack = 4321; @@ -103,6 +103,14 @@ CarStatesMsg sampleStates(std::size_t cars) { s.damage = 0.5f; m.cars.emplace_back(static_cast(i), s); } + for (std::size_t i = 0; i < near; ++i) { + NearCarState n; + n.id = static_cast(i); + n.state = sampleOwn(); + n.state.matrix.m3.x += 3.0f * static_cast(i); + n.input = sampleInput().frames[9]; + m.near.push_back(n); + } return m; } @@ -173,6 +181,20 @@ void checkStates(const CarStatesMsg& m) { ASSERT_LE(o.gear, 7); ASSERT_GE(o.stuckState, 0); ASSERT_LE(o.stuckState, 4); + ASSERT_LE(m.near.size(), kMaxNearCars); + for (const auto& n : m.near) { + ASSERT_EQ(n.input.events, 0); // never repeated + ASSERT_EQ(n.input.flags & ~kCarInputFlagMask, 0); + ASSERT_LE(n.input.extraMass, kMaxExtraMass); + const OwnCarState& c = n.state; + for (const Vec3& v : {c.matrix.m0, c.matrix.m1, c.matrix.m2}) + ASSERT_TRUE(std::abs(v.x) <= 2.0f && std::abs(v.y) <= 2.0f && std::abs(v.z) <= 2.0f); + ASSERT_TRUE(std::abs(c.matrix.m3.x) <= kOwnStateMaxCoordinate && + std::abs(c.matrix.m3.y) <= kOwnStateMaxCoordinate && + std::abs(c.matrix.m3.z) <= kOwnStateMaxCoordinate); + for (const Vec3& v : {c.linearMomentum, c.angularMomentum, c.linearVelocity, c.angularVelocity}) + ASSERT_TRUE(std::isfinite(v.x) && std::isfinite(v.y) && std::isfinite(v.z)); + } } Bytes mutate(const std::vector& corpus, std::mt19937& rng) { @@ -313,6 +335,33 @@ TEST(PlayerCars, StatesRoundTripTheOwnCarBitForBit) { EXPECT_NEAR(out.cars[3].second.position.x, 103.0f, 0.01f); } +TEST(PlayerCars, NearCarsTravelInFullWithoutTheirKeys) { + // Eight players: seven other cars, three of them near, in full. + CarStatesMsg m = sampleStates(kMaxPlayers / 2 - 1, kMaxNearCars); + m.near[1].input.events = kInputShiftUp; // a key: applied once on the host, never sent on + const auto bytes = encodeMessage(m); + // Within ENet's packet (1400 bytes) without fragments. + EXPECT_LT(bytes.size(), 1350u); + CarStatesMsg out; + ASSERT_TRUE(decodeMessage(bytes, out)); + ASSERT_EQ(out.near.size(), kMaxNearCars); + for (std::size_t i = 0; i < kMaxNearCars; ++i) { + EXPECT_EQ(out.near[i].id, m.near[i].id); + expectSameOwn(out.near[i].state, m.near[i].state); + CarInputFrame expected = m.near[i].input; + expected.events = 0; + EXPECT_EQ(out.near[i].input, expected); + } + // More than kMaxNearCars: the first ones only. + m.near.push_back(m.near[0]); + ASSERT_TRUE(decodeMessage(encodeMessage(m), out)); + EXPECT_EQ(out.near.size(), kMaxNearCars); + // A near car out of range refuses the message. + m = sampleStates(1, 2); + m.near[1].state.matrix.m3.y = -1.0e7f; + EXPECT_FALSE(decodeMessage(encodeMessage(m), out)); +} + TEST(PlayerCars, StatesOutOfRangeAreRefused) { CarStatesMsg out; auto bad = sampleStates(2); @@ -335,7 +384,7 @@ TEST(PlayerCars, StatesOutOfRangeAreRefused) { TEST(PlayerCars, DecodersSurviveMutatedMessages) { const std::vector corpus{encodeMessage(sampleInput()), encodeMessage(sampleStates(3)), - encodeMessage(sampleStates(0))}; + encodeMessage(sampleStates(0)), encodeMessage(sampleStates(2, 2))}; std::mt19937 rng(20261009); for (int i = 0; i < 40000; ++i) { const Bytes b = mutate(corpus, rng); @@ -387,12 +436,41 @@ TEST(PlayerCars, InputsAndStatesTravelDuringARaceOnly) { EXPECT_EQ(own[0].ack, 4321u); EXPECT_EQ(own[0].time, states.time); expectSameOwn(own[0].own, sampleOwn()); + EXPECT_TRUE(own[0].near.empty()); VehicleSnapshot s; EXPECT_NE(p.client.sampleRemoteAt(kHostPlayerId, states.time, s), SnapshotBuffer::Result::Empty); EXPECT_NEAR(s.position.x, 10.0f, 0.01f); EXPECT_EQ(p.client.sampleRemoteAt(p.client.localId(), states.time, s), SnapshotBuffer::Result::Empty); } +TEST(PlayerCars, AClientTakesNoNearStateOfItsOwnCarOrOfACarTwice) { + Pair p(3); + p.race(); + CarStatesMsg states = sampleStates(0, 3); + states.time = p.host.time(); + states.near[0].id = p.client.localId(); // its own car: refused + states.near[1].id = kHostPlayerId; + states.near[2].id = kHostPlayerId; // twice: both refused + p.host.sendCarStates(p.client.localId(), states); + std::vector own; + ASSERT_TRUE(pumpUntil({&p.host, &p.client}, [&] { + for (auto& u : p.client.takeOwnCarStates()) + own.push_back(u); + return !own.empty(); + })); + EXPECT_TRUE(own[0].near.empty()); + states.near.pop_back(); // the host's car once: taken + p.host.sendCarStates(p.client.localId(), states); + own.clear(); + ASSERT_TRUE(pumpUntil({&p.host, &p.client}, [&] { + for (auto& u : p.client.takeOwnCarStates()) + own.push_back(u); + return !own.empty(); + })); + ASSERT_EQ(own[0].near.size(), 1u); + EXPECT_EQ(own[0].near[0].id, kHostPlayerId); +} + TEST(PlayerCars, AFloodOfInputsKeepsToTheBudget) { Pair p(1); p.race(); @@ -412,7 +490,8 @@ TEST(PlayerCars, MutatedTrafficLeavesBothSidesWithinLimits) { p.race(); // The client sends mutated inputs, and a host-like peer is not needed: // the host's states reach the client through the same session. - const std::vector corpus{encodeMessage(sampleInput()), encodeMessage(sampleStates(3))}; + const std::vector corpus{encodeMessage(sampleInput()), encodeMessage(sampleStates(3)), + encodeMessage(sampleStates(2, 2))}; std::mt19937 rng(91); for (int i = 0; i < 2000; ++i) { const Bytes b = mutate(corpus, rng); @@ -429,7 +508,7 @@ TEST(PlayerCars, MutatedTrafficLeavesBothSidesWithinLimits) { checkInput(r.msg); for (const auto& u : p.client.takeOwnCarStates()) if (u.hasOwn) - checkStates({u.time, u.ack, u.waiting, true, u.own, {}}); + checkStates({u.time, u.ack, u.waiting, true, u.own, {}, u.near}); } } EXPECT_EQ(p.host.state(), Session::State::Active); From 38fcf08ab3137a1147ba132de4e23db55c9a5ac4 Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Fri, 9 Oct 2026 16:41:29 -0700 Subject: [PATCH 062/129] Carry what a car's sample hands the next with its state A car's state from the host now also holds the forces its wheels and engine set for the next sample, each tyre's rolling resistance, and in a contact the impulses and pushes: a client rebuilding another player's car from the state alone drifted from the host's at every state while the two pushed each other. With them a client's prediction of a shunt matches the host's (tests/game/test_player_cars.cpp: one correction of a millimetre, against 62 of up to 1.9 m with the other car held where its newest state put it; seven of at most 6 cm when the other player brakes). The near cars in full are now at most two, to keep a message to eight players in one packet. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/game/net/PlayerCars.cpp | 21 +++ src/net/PlayerCars.h | 37 +++++- tests/game/test_player_cars.cpp | 221 ++++++++++++++++++++++++++++++++ tests/net/test_player_cars.cpp | 44 +++++-- 4 files changed, 311 insertions(+), 12 deletions(-) diff --git a/src/game/net/PlayerCars.cpp b/src/game/net/PlayerCars.cpp index cb9176b..6f4f0f0 100644 --- a/src/game/net/PlayerCars.cpp +++ b/src/game/net/PlayerCars.cpp @@ -228,6 +228,18 @@ net::OwnCarState ownCarState(const SimVehicle& car, std::uint32_t resets) { s.swapThrottle = car.controls().swapThrottle; s.held = car.held(); s.resets = resets; + s.force = ics.linearForce; + s.torque = ics.angularTorque; + for (std::size_t i = 0; i < 4; ++i) + s.tireResistance[i] = sim.wheels[i].tireResistance; + s.linearImpulse = ics.linearImpulse; + s.angularImpulse = ics.angularImpulse; + s.linearPush = ics.linearPush; + s.turnForce = ics.turnForce; + s.framePush = ics.framePush; + const Vec3 zero{}; + s.contact = !(s.linearImpulse == zero && s.angularImpulse == zero && s.linearPush == zero && + s.turnForce == zero && s.framePush == zero); return s; } @@ -240,6 +252,15 @@ void applyOwnCarState(SimVehicle& car, const net::OwnCarState& s) { ics.linearVelocity = s.linearVelocity; ics.angularVelocity = s.angularVelocity; ics.lastPush = s.lastPush; + ics.linearForce = s.force; + ics.angularTorque = s.torque; + ics.linearImpulse = s.linearImpulse; + ics.angularImpulse = s.angularImpulse; + ics.linearPush = s.linearPush; + ics.turnForce = s.turnForce; + ics.framePush = s.framePush; + for (std::size_t i = 0; i < 4; ++i) + sim.wheels[i].tireResistance = s.tireResistance[i]; for (std::size_t i = 0; i < 4; ++i) { phys::Wheel& w = sim.wheels[i]; const auto& o = s.wheels[i]; diff --git a/src/net/PlayerCars.h b/src/net/PlayerCars.h index 9f07a62..dc6ce1f 100644 --- a/src/net/PlayerCars.h +++ b/src/net/PlayerCars.h @@ -195,7 +195,11 @@ bool serialize(S& s, PlayerInputMsg& m) { // A car's simulation as the client needs it to carry on from the host's // state: the rigid body exactly, and the parts whose state decides the next // samples (the wheels' spin, springs and tyres, the engine and gearbox, the -// drivetrains, the stuck watcher, the damage, the random stream). +// drivetrains, the stuck watcher, the damage, the random stream), with what +// a sample hands the next: the forces the wheels and the engine set for it, +// the tyres' rolling resistance, and in a contact the impulses and pushes +// (protocol 9; without them a client's car pushing another drifted from the +// host's at every state, the other car rebuilt from the state alone). struct OwnCarState { Mat34 matrix; // the body (centre of mass) frame Vec3 linearMomentum, angularMomentum, linearVelocity, angularVelocity; @@ -220,6 +224,13 @@ struct OwnCarState { bool swapThrottle = false; // the pedals swapped (automatic reverse) bool held = false; // held on the grid std::uint32_t resets = 0; // the commands the host has carried out (wraps) + // Handed to the next sample (protocol 9): phInertialCS's force and torque + // so far, each wheel's rolling resistance, and (`contact`: not all zero) + // the impulses and pushes of a contact. + Vec3 force, torque; + std::array tireResistance{}; + bool contact = false; + Vec3 linearImpulse, angularImpulse, linearPush, turnForce, framePush; }; // Ranges an OwnCarState must lie in (anything else is refused). @@ -264,6 +275,20 @@ bool serialize(S& s, OwnCarState& o) { s.boolean(o.swapThrottle); s.boolean(o.held); s.u32(o.resets); + s.vec3(o.force); + s.vec3(o.torque); + for (float& r : o.tireResistance) + s.f32(r); + s.boolean(o.contact); + if (o.contact) { + s.vec3(o.linearImpulse); + s.vec3(o.angularImpulse); + s.vec3(o.linearPush); + s.vec3(o.turnForce); + s.vec3(o.framePush); + } else if constexpr (S::kReading) { + o.linearImpulse = o.angularImpulse = o.linearPush = o.turnForce = o.framePush = {}; + } if constexpr (S::kReading) { // Untrusted: every number finite (f32 refuses the rest) and in range. const auto ok = [](const Vec3& v, float r) { @@ -273,7 +298,13 @@ bool serialize(S& s, OwnCarState& o) { bool good = ok(m.m0, 2.0f) && ok(m.m1, 2.0f) && ok(m.m2, 2.0f) && ok(m.m3, kOwnStateMaxCoordinate) && ok(o.linearMomentum, kOwnStateMaxValue) && ok(o.angularMomentum, kOwnStateMaxValue) && ok(o.linearVelocity, kOwnStateMaxValue) && ok(o.angularVelocity, kOwnStateMaxValue) && - ok(o.lastPush, kOwnStateMaxValue) && ok(o.stuckPosition, kOwnStateMaxCoordinate); + ok(o.lastPush, kOwnStateMaxValue) && ok(o.stuckPosition, kOwnStateMaxCoordinate) && + ok(o.force, kOwnStateMaxValue) && ok(o.torque, kOwnStateMaxValue) && + ok(o.linearImpulse, kOwnStateMaxValue) && ok(o.angularImpulse, kOwnStateMaxValue) && + ok(o.linearPush, kOwnStateMaxValue) && ok(o.turnForce, kOwnStateMaxValue) && + ok(o.framePush, kOwnStateMaxValue); + for (float r : o.tireResistance) + good = good && std::abs(r) <= kOwnStateMaxValue; for (const auto& w : o.wheels) for (float v : {w.rotationSpeed, w.rotation, w.suspension, w.suspensionVelocity, w.tireDispLat, w.tireDispLong}) @@ -296,7 +327,7 @@ struct NearCarState { OwnCarState state; CarInputFrame input; // the last applied (its keys, applied once, left out) }; -inline constexpr std::size_t kMaxNearCars = 3; +inline constexpr std::size_t kMaxNearCars = 2; struct CarStatesMsg { static constexpr MsgType kType = MsgType::CarStates; diff --git a/tests/game/test_player_cars.cpp b/tests/game/test_player_cars.cpp index 5d066a2..d6367c1 100644 --- a/tests/game/test_player_cars.cpp +++ b/tests/game/test_player_cars.cpp @@ -139,8 +139,229 @@ Outcome simulate(const vfs::Vfs& vfs, int samples, std::uint32_t upLatency, std: return run; } +// Two players' cars in one world, the host's (car `a`, player 0) first, as +// every machine orders them. +struct TwoCars { + phys::World world; + std::unique_ptr a, b; + NetCarDriver da, db; + explicit TwoCars(const vfs::Vfs& vfs) { + world.setStatic(flatGround()); + for (auto* car : {&a, &b}) { + std::string error; + *car = SimVehicle::loadPlayer(vfs, "vpbug", &error, false); + EXPECT_TRUE(*car) << error; + (*car)->sim().options.player = true; + (*car)->sim().setPolygonalBound(true); + (*car)->sim().ownRandom = true; + (*car)->sim().randomState = 1; + (*car)->addTo(world); + } + da.attach(*a); + db.attach(*b); + } +}; + +// The host's car's input: steady at `throttle` until sample `brakeAt`, then +// braking hard. +net::CarInputFrame leaderInput(std::uint32_t t, float throttle, std::uint32_t brakeAt) { + phys::PedalInput p; + p.accelerator = t < brakeAt ? throttle : 0.0f; + p.brake = t < brakeAt ? 0.0f : 1.0f; + net::CarInputFrame f = inputFrame(p); + f.flags = net::kInputAutomatic; + return f; +} + +struct ShuntOutcome { + int corrections = 0; + float largest = 0.0f; + float closest = 1e9f; // the cars' centres on the host (m) +}; + +// A client's car shunts the host's from behind: both start in line, the +// client's 9 m behind at full throttle, the host's at `throttle` (braking +// from sample `brakeAt`). The client simulates the host's car with its own +// from the host's full states (companion) when `companion`, and otherwise +// meets it held where the host's newest state put it (the host's car not +// simulated: a body that does not give way). +ShuntOutcome shunt(const vfs::Vfs& vfs, bool companion, float throttle, std::uint32_t brakeAt) { + TwoCars host(vfs), client(vfs); + const Mat34 front = Mat34::translation({0.0f, 0.6f, 0.0f}); + const Mat34 back = Mat34::translation({0.0f, 0.6f, 9.0f}); // facing -Z: behind + for (TwoCars* m : {&host, &client}) { + m->a->setResetPos(front); + m->a->reset(); + m->b->setResetPos(back); + m->b->reset(); + } + if (!companion) { + client.a->sim().body.kinematic = true; + client.a->sim().body.resetCollider(); + } + CarPrediction prediction; + HostInputQueue queue; + struct Down { + std::uint32_t ack = 0; + net::OwnCarState own; + net::NearCarState near; + }; + std::deque> up; + std::deque> down; + ShuntOutcome run; + const float dt = phys::kFixedSampleStep; + const std::uint32_t latency = 6; + net::CarInputFrame leader = leaderInput(0, throttle, brakeAt); + for (std::uint32_t t = 1; t <= 300; ++t) { + while (!down.empty() && down.front().due <= t) { + const Down& d = down.front().msg; + CarPrediction::Companion c{client.a.get(), &client.da, &d.near.state, d.near.input, true}; + const auto r = companion + ? prediction.acknowledge(*client.b, client.db, client.world, d.ack, d.own, {}, + {}, std::span(&c, 1)) + : prediction.acknowledge(*client.b, client.db, client.world, d.ack, d.own); + if (!companion) { + // The host's car where its newest state says, held there. + applyOwnCarState(*client.a, d.near.state); + } else { + leader = d.near.input; + } + if (r.corrected) { + ++run.corrections; + run.largest = std::max(run.largest, r.moved.mag()); + } + down.pop_front(); + } + if (prediction.nextSeq() == 1) + prediction.command(*client.b, {0, net::CarCommandKind::ResetTo, client.b->sim().resetPos(), + client.b->sim().resetRotation}); + if (companion) + client.da.apply(*client.a, leader); + phys::PedalInput full; + full.accelerator = 1.0f; + net::CarInputFrame mine = inputFrame(full); + mine.flags = net::kInputAutomatic; + prediction.beginSample(*client.b, client.db, mine); + client.world.step(dt); + prediction.endSample(*client.b, client.db); + if (const auto m = prediction.message()) + up.push_back({t + latency, *m}); + // Host. + while (!up.empty() && up.front().due <= t) { + queue.receive(up.front().msg); + up.pop_front(); + } + host.da.apply(*host.a, leaderInput(t, throttle, brakeAt)); + if (auto next = queue.next()) { + for (const auto& c : next->commands) + NetCarDriver::command(*host.b, c); + host.db.apply(*host.b, next->frame); + } + host.world.step(dt); + const Vec3& pa = host.a->sim().body.ics.matrix.m3; + run.closest = std::min(run.closest, pa.dist(host.b->sim().body.ics.matrix.m3)); + if (t % 3 == 0 && queue.lastApplied() != 0) { + Down d; + d.ack = queue.lastApplied(); + d.own = ownCarState(*host.b, 0); + d.near.id = 0; + d.near.state = ownCarState(*host.a, 0); + d.near.input = leaderInput(t, throttle, brakeAt); + down.push_back({t + latency, d}); + } + } + return run; +} + } // namespace +TEST(PlayerCars, AShuntPredictedWithTheOtherCarAsTheHostRunsIt) { + MM2_REQUIRE_GAME_DATA(); + // The host's car at a steady pace, the client's catching it up and + // pushing it: simulating the host's car with its own from the host's + // states and in the host's order, the client predicts the shunt as the + // host runs it. + const ShuntOutcome with = shunt(*test::gameData(), true, 0.3f, 1000); + std::printf("[ measure ] shunt with the other car simulated: %d corrections, largest %.3f m, " + "closest %.2f m\n", + with.corrections, static_cast(with.largest), static_cast(with.closest)); + EXPECT_LT(with.closest, 4.6f); // they touched + EXPECT_LE(with.corrections, 1); + EXPECT_LT(with.largest, 0.05f); + // Meeting the host's car as a body that does not give way, it is + // corrected through the shunt. + const ShuntOutcome without = shunt(*test::gameData(), false, 0.3f, 1000); + std::printf("[ measure ] shunt with the other car held: %d corrections, largest %.3f m\n", + without.corrections, static_cast(without.largest)); + EXPECT_GT(without.corrections, with.corrections + 5); +} + +TEST(PlayerCars, AShuntIsCorrectedOnlyWhereTheOtherPlayerChangedItsInput) { + MM2_REQUIRE_GAME_DATA(); + // The host's car brakes hard while the client's pushes it: the client + // learns of it a trip late and is corrected, by less than a metre. + const ShuntOutcome run = shunt(*test::gameData(), true, 0.3f, 200); + std::printf("[ measure ] shunt with the host's car braking: %d corrections, largest %.3f m\n", + run.corrections, static_cast(run.largest)); + EXPECT_GE(run.corrections, 1); + EXPECT_LT(run.largest, 1.0f); +} + +TEST(PlayerCars, TheHostResetsAClientsCarOnlyWhereTheRulesDo) { + ResetRules r; + r.city = {{-2000.0f, -100.0f, -2000.0f}, {2000.0f, 300.0f, 2000.0f}}; + const Mat34 checkpoint = Mat34::rotationY(1.0f) * Mat34::translation({100.0f, 5.0f, 200.0f}); + const Mat34 points[] = {checkpoint}; + r.respawnPoints = points; + const net::CarCommand placeAt{1, net::CarCommandKind::ResetTo, {10.0f, 2.0f, 30.0f}, 0.5f}; + // The start: once, in the city. + EXPECT_TRUE(r.allows(placeAt)); + net::CarCommand far = placeAt; + far.position.x = 9000.0f; + EXPECT_FALSE(r.allows(far)); + r.placed = true; + r.lastMoveSeq = 1; + EXPECT_FALSE(r.allows({600, net::CarCommandKind::ResetTo, {10.0f, 2.0f, 30.0f}, 0.5f})); + // Back to the reset position: only from the water or below the city. + const net::CarCommand reset{600, net::CarCommandKind::Reset, {}, 0.0f}; + r.height = 2.0f; + EXPECT_FALSE(r.allows(reset)); + r.waterSamples = ResetRules::kWaterSamples - 1; + EXPECT_FALSE(r.allows(reset)); + r.waterSamples = ResetRules::kWaterSamples; + EXPECT_TRUE(r.allows(reset)); + r.waterSamples = 0; + r.height = -60.0f; + EXPECT_TRUE(r.allows(reset)); + // Not four times a second. + r.lastMoveSeq = 595; + EXPECT_FALSE(r.allows(reset)); + r.lastMoveSeq = 1; + // At a checkpoint, facing its heading. + r.height = -60.0f; + const float heading = phys::resetRotationOf(checkpoint); + EXPECT_TRUE(r.allows({600, net::CarCommandKind::RespawnAt, checkpoint.m3, heading})); + EXPECT_FALSE(r.allows({600, net::CarCommandKind::RespawnAt, checkpoint.m3, heading + 0.5f})); + EXPECT_FALSE(r.allows({600, net::CarCommandKind::RespawnAt, checkpoint.m3 + Vec3{3.0f, 0, 0}, heading})); + r.height = 2.0f; + EXPECT_FALSE(r.allows({600, net::CarCommandKind::RespawnAt, checkpoint.m3, heading})); + // Repairs: a wreck's, or Cops and Robbers'. + const net::CarCommand repair{600, net::CarCommandKind::ClearDamage, {}, 0.0f}; + EXPECT_FALSE(r.allows(repair)); + r.wrecked = true; + EXPECT_TRUE(r.allows(repair)); + r.wrecked = false; + r.copsAndRobbers = true; + EXPECT_TRUE(r.allows(repair)); + // The development hook lets any reset in the city through. + ResetRules d = r; + d.debug = true; + d.copsAndRobbers = false; + EXPECT_TRUE(d.allows(reset)); + EXPECT_TRUE(d.allows({600, net::CarCommandKind::RespawnAt, {0.0f, 1.0f, 0.0f}, 0.0f})); + EXPECT_FALSE(d.allows({600, net::CarCommandKind::RespawnAt, {0.0f, 1.0f, 9000.0f}, 0.0f})); +} + TEST(PlayerCars, InputFramesCarryTheRecordedPedalsExactly) { for (int s = -127; s <= 127; ++s) { phys::PedalInput p; diff --git a/tests/net/test_player_cars.cpp b/tests/net/test_player_cars.cpp index a162b06..afa2d7e 100644 --- a/tests/net/test_player_cars.cpp +++ b/tests/net/test_player_cars.cpp @@ -83,6 +83,15 @@ OwnCarState sampleOwn() { o.swapThrottle = true; o.held = false; o.resets = 7; + o.force = {-13.98f, 22884.0f, -51.13f}; + o.torque = {-461.3f, -16.6f, 419.6f}; + o.tireResistance = {4.77f, -0.42f, -2320.78f, 0.0f}; + o.contact = true; + o.linearImpulse = {1500.0f, 0.0f, -20.0f}; + o.angularImpulse = {0.0f, 300.0f, 0.0f}; + o.linearPush = {0.001f, 0.0f, 0.002f}; + o.turnForce = {0.0f, 0.0001f, 0.0f}; + o.framePush = {0.003f, 0.0f, 0.0f}; return o; } @@ -140,6 +149,13 @@ void expectSameOwn(const OwnCarState& a, const OwnCarState& b) { EXPECT_EQ(a.swapThrottle, b.swapThrottle); EXPECT_EQ(a.held, b.held); EXPECT_EQ(a.resets, b.resets); + EXPECT_TRUE(vec(a.force, b.force) && vec(a.torque, b.torque)); + for (std::size_t i = 0; i < 4; ++i) + EXPECT_TRUE(sameBits(a.tireResistance[i], b.tireResistance[i])); + EXPECT_EQ(a.contact, b.contact); + EXPECT_TRUE(vec(a.linearImpulse, b.linearImpulse) && vec(a.angularImpulse, b.angularImpulse)); + EXPECT_TRUE(vec(a.linearPush, b.linearPush) && vec(a.turnForce, b.turnForce) && + vec(a.framePush, b.framePush)); } // What a decoded message may hold, whatever arrived. @@ -323,7 +339,7 @@ TEST(PlayerCars, StatesRoundTripTheOwnCarBitForBit) { const CarStatesMsg m = sampleStates(kMaxPlayers - 1); const auto bytes = encodeMessage(m); // Fits a packet below the ENet MTU with 15 other cars. - EXPECT_LT(bytes.size(), 1200u); + EXPECT_LT(bytes.size(), 1250u); CarStatesMsg out; ASSERT_TRUE(decodeMessage(bytes, out)); EXPECT_EQ(out.time, m.time); @@ -336,12 +352,14 @@ TEST(PlayerCars, StatesRoundTripTheOwnCarBitForBit) { } TEST(PlayerCars, NearCarsTravelInFullWithoutTheirKeys) { - // Eight players: seven other cars, three of them near, in full. + // Eight players: seven other cars, two of them near, in full, in a + // contact. CarStatesMsg m = sampleStates(kMaxPlayers / 2 - 1, kMaxNearCars); m.near[1].input.events = kInputShiftUp; // a key: applied once on the host, never sent on const auto bytes = encodeMessage(m); - // Within ENet's packet (1400 bytes) without fragments. - EXPECT_LT(bytes.size(), 1350u); + // In one ENet packet: its default MTU (1400 bytes) less its header and + // a fragment's (ENet splits anything longer). + EXPECT_LE(bytes.size(), 1372u); CarStatesMsg out; ASSERT_TRUE(decodeMessage(bytes, out)); ASSERT_EQ(out.near.size(), kMaxNearCars); @@ -377,6 +395,15 @@ TEST(PlayerCars, StatesOutOfRangeAreRefused) { bad.own.wheels[2].rotationSpeed = 1.0e20f; EXPECT_FALSE(decodeMessage(encodeMessage(bad), out)); bad = sampleStates(2); + bad.own.framePush.x = std::numeric_limits::quiet_NaN(); + EXPECT_FALSE(decodeMessage(encodeMessage(bad), out)); + // Out of a contact the impulses and pushes do not travel. + auto calm = sampleStates(2); + calm.own.contact = false; + ASSERT_TRUE(decodeMessage(encodeMessage(calm), out)); + EXPECT_EQ(out.own.linearImpulse, Vec3{}); + EXPECT_LT(encodeMessage(calm).size(), encodeMessage(sampleStates(2)).size()); + bad = sampleStates(2); bad.waiting = 1000; // clamped to the range, not refused ASSERT_TRUE(decodeMessage(encodeMessage(bad), out)); EXPECT_EQ(out.waiting, kMaxReportedWaiting); @@ -446,11 +473,10 @@ TEST(PlayerCars, InputsAndStatesTravelDuringARaceOnly) { TEST(PlayerCars, AClientTakesNoNearStateOfItsOwnCarOrOfACarTwice) { Pair p(3); p.race(); - CarStatesMsg states = sampleStates(0, 3); + CarStatesMsg states = sampleStates(0, 2); states.time = p.host.time(); - states.near[0].id = p.client.localId(); // its own car: refused - states.near[1].id = kHostPlayerId; - states.near[2].id = kHostPlayerId; // twice: both refused + states.near[0].id = kHostPlayerId; + states.near[1].id = kHostPlayerId; // twice: both refused p.host.sendCarStates(p.client.localId(), states); std::vector own; ASSERT_TRUE(pumpUntil({&p.host, &p.client}, [&] { @@ -459,7 +485,7 @@ TEST(PlayerCars, AClientTakesNoNearStateOfItsOwnCarOrOfACarTwice) { return !own.empty(); })); EXPECT_TRUE(own[0].near.empty()); - states.near.pop_back(); // the host's car once: taken + states.near[0].id = p.client.localId(); // its own car: refused; the host's once: taken p.host.sendCarStates(p.client.localId(), states); own.clear(); ASSERT_TRUE(pumpUntil({&p.host, &p.client}, [&] { From 3f3d5e77a62fe06aaa08c9340cddb1815327396e Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Fri, 9 Oct 2026 16:48:02 -0700 Subject: [PATCH 063/129] Record the props' final checks Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/review/multiplayer-desync-props.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/review/multiplayer-desync-props.md b/docs/review/multiplayer-desync-props.md index 72584fb..a8100f8 100644 --- a/docs/review/multiplayer-desync-props.md +++ b/docs/review/multiplayer-desync-props.md @@ -253,8 +253,10 @@ in a crash that keeps all 40 slots flying. on retail data the same placement on every machine (cruise at the host's and a client's traffic density, a checkpoint race with parked cars) for San Francisco and London. -* Full suite, the `_GLIBCXX_ASSERTIONS` suite, the opponent sweep and the - single-player smoke screenshots: see the final report. +* Full suite with game data at 59ca116: 985 of 985 (3 skipped as always), + the same with `-D_GLIBCXX_ASSERTIONS`; the opponent sweep 516/517 + finished, 508/517 across the line (as before); single-player smoke + screenshots (a San Francisco cruise, a London circuit) as before. ## For the players' cars agent From 14dc2062a912921b1084a3e25ece0db818ff813f Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Fri, 9 Oct 2026 16:51:25 -0700 Subject: [PATCH 064/129] List the host-run props' files in the parity manifest Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/parity/README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/parity/README.md b/docs/parity/README.md index f68b635..a6d7aaa 100644 --- a/docs/parity/README.md +++ b/docs/parity/README.md @@ -416,10 +416,14 @@ audited in [openmm2-only.md](openmm2-only.md). | `src/game/net/DamageSync.h` | 283 | O | - | | `src/game/net/NetGame.cpp` | 659 | M | session | | `src/game/net/NetGame.h` | 229 | M | session | +| `src/game/net/NetProps.cpp` | 230 | O | - | +| `src/game/net/NetProps.h` | 120 | O | - | | `src/game/net/NetTrafficCars.cpp` | 101 | O | - | | `src/game/net/NetTrafficCars.h` | 106 | O | - | | `src/game/net/PlayerCars.cpp` | 483 | O | - | | `src/game/net/PlayerCars.h` | 235 | O | - | +| `src/game/net/PropSync.cpp` | 649 | O | - | +| `src/game/net/PropSync.h` | 263 | O | - | | `src/game/net/RaceStart.cpp` | 108 | M | session | | `src/game/net/RaceStart.h` | 106 | M | session | | `src/game/net/TrafficPrediction.cpp` | 164 | O | - | @@ -463,6 +467,8 @@ audited in [openmm2-only.md](openmm2-only.md). | `src/net/PlayerCars.h` | 315 | O | - | | `src/net/PortMapper.cpp` | 324 | O | - | | `src/net/PortMapper.h` | 164 | O | - | +| `src/net/PropState.cpp` | 12 | O | - | +| `src/net/PropState.h` | 215 | O | - | | `src/net/Protocol.cpp` | 31 | O | - | | `src/net/Protocol.h` | 587 | O | - | | `src/net/Session.cpp` | 942 | O | - | From 968a4287f0b7f1915ced5fbf131bbd7933e003ef Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Fri, 9 Oct 2026 16:52:34 -0700 Subject: [PATCH 065/129] Report how far the machines agree on a network race's rules netprobe rulesreport reads every machine's OPENMM2_NET_TRACE (the host's first) and prints, for each player's car, the checkpoints the host's referee counted against what that player's HUD showed (as predicted, on the host's word, taken back; shown and never counted; counted and not shown; how long before or after the host's count), the gate-0 passes, every machine's finishes and order against the host's measure, the standings, and Cops and Robbers' gold events and scores per machine. The autopilot (a development aid) backs up when the AI does (AUTO REVERSE's brake pedal), drives Cops and Robbers' car to the gold and then its base over the roads and the last stretch straight, and OPENMM2_DEBUG_START_GOLD= starts a Cops and Robbers car near the first gold. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/app/RaceScreen.cpp | 38 +++- src/game/net/Autopilot.cpp | 58 +++++- src/game/net/Autopilot.h | 13 ++ tools/netprobe/CMakeLists.txt | 2 +- tools/netprobe/RulesReport.cpp | 356 +++++++++++++++++++++++++++++++++ tools/netprobe/RulesReport.h | 21 ++ tools/netprobe/main.cpp | 12 +- 7 files changed, 491 insertions(+), 9 deletions(-) create mode 100644 tools/netprobe/RulesReport.cpp create mode 100644 tools/netprobe/RulesReport.h diff --git a/src/app/RaceScreen.cpp b/src/app/RaceScreen.cpp index 4b270d7..e3bfefb 100644 --- a/src/app/RaceScreen.cpp +++ b/src/app/RaceScreen.cpp @@ -2653,6 +2653,11 @@ class RaceScreen final : public Screen { m_crWaterHandled = false; } m_crImpacts.clear(); + if (m_autopilot) // OPENMM2_DEBUG_AUTOPILOT: to the gold, then to the base + m_autopilot->setTarget(m_player->sim(), + m_cr->goldCarrier() == m_crSelf ? m_cr->deliveryTarget(m_crMyTeam) + : m_cr->goldPosition(), + m_world.get()); // What happened, on this machine's HUD and car. auto name = [&](int id) { const auto* p = ctx.netGame->player(static_cast(id)); @@ -3056,19 +3061,42 @@ class RaceScreen final : public Screen { // Development aid: OPENMM2_DEBUG_AUTOPILOT=[,] drives this // machine's car along the race's opponent line through its inputs // (game/net/Autopilot.h), for automated runs. - if (const char* ap = std::getenv("OPENMM2_DEBUG_AUTOPILOT"); ap && m_player && m_world && s.session) { + // In Cops and Robbers it drives to the gold, then to its base. + if (const char* ap = std::getenv("OPENMM2_DEBUG_AUTOPILOT"); ap && m_player && m_world) { const auto parts = str::split(ap, ','); const int line = static_cast(str::parseInt(parts.empty() ? "" : parts[0]).value_or(0)); const float speed = parts.size() > 1 ? static_cast(str::parseDouble(parts[1]).value_or(0.0)) : 0.0f; std::string error; - m_autopilot = game::NetAutopilot::create(*m_city, ctx.game->vfs, m_session->setup(), - m_player->sim(), line, speed, m_world.get(), &error); + m_autopilot = s.session ? game::NetAutopilot::create(*m_city, ctx.game->vfs, m_session->setup(), + m_player->sim(), line, speed, m_world.get(), + &error) + : game::NetAutopilot::createFree(*m_city, ctx.game->vfs, m_session->setup(), + speed, &error); if (m_autopilot) log::info("race: the autopilot drives opponent line {}", line); else log::warn("race: no autopilot: {}", error); } + // Development aid: OPENMM2_DEBUG_START_GOLD= starts a Cops and + // Robbers car that far from the first gold, facing it, each player on + // its own side (every machine knows the first places when it loads). + if (const char* g = std::getenv("OPENMM2_DEBUG_START_GOLD"); g && m_cr && m_player && m_world) { + const float r = static_cast(str::parseDouble(g).value_or(30.0)); + const float around = 2.0943951f * static_cast(ctx.netGame->localId()); + const Vec3 gold = m_cr->set().gold; + Vec3 at = gold + Vec3{r * std::cos(around), 0.0f, r * std::sin(around)}; + phys::RayHit hit; + if (m_world->wheelProbe(at + Vec3{0.0f, 30.0f, 0.0f}, at - Vec3{0.0f, 30.0f, 0.0f}, hit, nullptr, + nullptr)) + at.y = hit.position.y + 1.0f; + const float angle = std::atan2(-(gold.x - at.x), -(gold.z - at.z)); + log::info("race: starts {:.0f} m from the gold at ({:.1f}, {:.1f}, {:.1f})", r, at.x, at.y, at.z); + m_player->setResetPos(at, angle); + m_player->reset(); + m_pose = m_player->pose(); + m_cams.reset(cameraTarget()); + } } // Client: the host's word on the rules among the frame's events. @@ -5530,8 +5558,8 @@ class RaceScreen final : public Screen { const bool go = (!multiplayer(ctx) || ctx.netGame->raceStarted()) && !(m_session && m_session->playerHeld()); const auto c = m_autopilot->drive(dt, m_player->sim(), !go); - pedals.accelerator = c.throttle; - pedals.brake = c.brake; + pedals.accelerator = c.reverse ? 0.0f : c.throttle; + pedals.brake = c.reverse ? c.throttle : c.brake; pedals.steering = c.steering; pedals.handbrake = 0.0f; } diff --git a/src/game/net/Autopilot.cpp b/src/game/net/Autopilot.cpp index 546d2fa..51a2be3 100644 --- a/src/game/net/Autopilot.cpp +++ b/src/game/net/Autopilot.cpp @@ -3,8 +3,11 @@ #include "ai/Opponent.h" #include "ai/World.h" #include "city/Race.h" +#include "core/Log.h" #include "core/StringUtil.h" +#include +#include #include namespace mm2::game { @@ -56,12 +59,65 @@ std::unique_ptr NetAutopilot::create(const city::CityData& city, c return pilot; } +std::unique_ptr NetAutopilot::createFree(const city::CityData& city, const vfs::Vfs& vfs, + const session::RaceSetup& setup, float speedLimit, + std::string* error) { + ai::Settings settings; + settings.trafficDensity = 0.0f; + settings.pedestrianDensity = 0.0f; + settings.maxPeds = 0; + std::unique_ptr pilot(new NetAutopilot); + pilot->m_ai = ai::World::create(city, vfs, settings, setup.aiMap ? &*setup.aiMap : nullptr, error); + if (!pilot->m_ai) + return nullptr; + pilot->m_vehicle = setup.config.vehicle; + pilot->m_speedLimit = speedLimit; + return pilot; +} + +void NetAutopilot::setTarget(phys::CarSim& car, const Vec3& target, const phys::GroundQuery* ground) { + // A new destination: the racer AI drives the city's roads to it. + if (m_target && m_target->dist2(target) < 25.0f * 25.0f && m_driver) + return; + m_target = target; + log::info("autopilot: to ({:.0f}, {:.0f}, {:.0f}) from ({:.0f}, {:.0f}, {:.0f})", target.x, target.y, target.z, + car.body.ics.matrix.m3.x, car.body.ics.matrix.m3.y, car.body.ics.matrix.m3.z); + std::vector path(2); + path[0].position = car.body.ics.matrix.m3; + path[1].position = target; + m_driver = ai::Opponent::create(m_ai->map(), car, path, {}, 1, 1, 0, nullptr, ground, m_vehicle); + if (!m_driver) + return; + if (m_speedLimit > 0.0f) + m_driver->setSpeedLimit(m_speedLimit); + m_driver->setResetCar([](const Mat34&) {}); + m_driver->setHeld(false); + const auto saved = car.saveState(); + m_driver->reset(); + car.restoreState(saved); +} + NetAutopilot::Controls NetAutopilot::drive(float dt, phys::CarSim& car, bool held) { + if (!m_driver) + return {}; const auto saved = car.saveState(); m_driver->setHeld(held); m_driver->update(dt, {}); - const Controls c{car.engine.throttle, car.brakes, car.steering}; + Controls c{car.engine.throttle, car.brakes, car.steering, + car.trans.getCurrentGear() == phys::Transmission::kReverse}; car.restoreState(saved); + // The last stretch to a free target (the gold off the road, a base): at + // it, past where the roads lead. + const Mat34& m = car.body.ics.matrix; + if (m_target && !held && m.m3.dist2(*m_target) < 80.0f * 80.0f) { + const float dx = m_target->x - m.m3.x, dz = m_target->z - m.m3.z; + const float angle = std::atan2(dx * m.m0.x + dz * m.m0.z, -(dx * m.m2.x + dz * m.m2.z)); + c.steering = std::clamp(angle * 1.33f, -1.0f, 1.0f); + const bool behind = std::fabs(angle) > 2.0f; + c.reverse = behind; + c.throttle = behind ? 0.5f : 0.6f; + c.brake = 0.0f; + } return c; } diff --git a/src/game/net/Autopilot.h b/src/game/net/Autopilot.h index 6775ac3..b87c5a4 100644 --- a/src/game/net/Autopilot.h +++ b/src/game/net/Autopilot.h @@ -16,6 +16,7 @@ #include "vfs/Vfs.h" #include +#include #include namespace mm2::ai { @@ -37,12 +38,21 @@ class NetAutopilot { const session::RaceSetup& setup, phys::CarSim& car, int line, float speedLimit, const phys::GroundQuery* ground, std::string* error = nullptr); + // Cops and Robbers: no line; setTarget() gives the place to drive to + // (the gold, then the base), routed over the city's roads. + static std::unique_ptr createFree(const city::CityData& city, const vfs::Vfs& vfs, + const session::RaceSetup& setup, float speedLimit, + std::string* error = nullptr); + void setTarget(phys::CarSim& car, const Vec3& target, const phys::GroundQuery* ground); ~NetAutopilot(); NetAutopilot(const NetAutopilot&) = delete; NetAutopilot& operator=(const NetAutopilot&) = delete; struct Controls { float throttle = 0.0f, brake = 0.0f, steering = 0.0f; + // The AI backs up (it puts the gearbox in reverse): with AUTO + // REVERSE the brake pedal drives the car backwards. + bool reverse = false; }; // The AI's frame for the car (held: on the grid). Controls drive(float dt, phys::CarSim& car, bool held); @@ -51,6 +61,9 @@ class NetAutopilot { NetAutopilot() = default; std::unique_ptr m_ai; std::unique_ptr m_driver; + std::string m_vehicle; + float m_speedLimit = 0.0f; + std::optional m_target; }; } // namespace mm2::game diff --git a/tools/netprobe/CMakeLists.txt b/tools/netprobe/CMakeLists.txt index 7f7f56b..f9222c7 100644 --- a/tools/netprobe/CMakeLists.txt +++ b/tools/netprobe/CMakeLists.txt @@ -1,4 +1,4 @@ if(TARGET mm2_net) - add_executable(netprobe main.cpp SyncReport.cpp) + add_executable(netprobe main.cpp RulesReport.cpp SyncReport.cpp) target_link_libraries(netprobe PRIVATE OpenMM2::net) endif() diff --git a/tools/netprobe/RulesReport.cpp b/tools/netprobe/RulesReport.cpp new file mode 100644 index 0000000..f61c681 --- /dev/null +++ b/tools/netprobe/RulesReport.cpp @@ -0,0 +1,356 @@ +// netprobe rulesreport: whether the machines of a network race agree on its +// rules. Reads each machine's OPENMM2_NET_TRACE file, the host's first: +// +// RS / RX what each machine's HUD showed of its own car's checkpoints +// (and took back) +// RH the host's referee counting a car's checkpoints +// RG a referee's finish that was not the race's (a build where the +// players still decided their own: the host's measure of it) +// RF a finish in a machine's results +// RE a machine's "Place: n/N" +// CG / CS Cops and Robbers' gold events and scores as each machine shows +// them +// +// The machines must have run on one computer: the clock is the machine's +// monotonic clock, which every process on it shares. + +#include "RulesReport.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace mm2::netprobe { +namespace { + +constexpr unsigned kDnfMs = 86400000; + +struct Shown { + double clock = 0, frame = 0; + int index = 0, count = 0, kind = 0; + bool takenBack = false; +}; +struct HostHit { + double clock = 0, frame = 0; + int player = 0; + unsigned sample = 0; + int index = 0, count = 0, lap = 0; +}; +struct Finish { + double clock = 0; + unsigned ms = 0; +}; +struct Gold { + double clock = 0; + int type = 0, car = 0, value = 0; +}; + +struct Machine { + std::string path; + int self = -1; + std::vector shown; // its own car's, as its HUD showed them + std::vector hostHits; + std::map finishes, refereeFinishes; + std::vector> standings; + std::vector gold; + std::map scores; + double firstClock = 0, lastClock = 0; +}; + +bool load(const std::string& path, Machine& m) { + std::ifstream in(path); + if (!in) + return false; + m.path = path; + std::string line; + while (std::getline(in, line)) { + const auto space = line.find(' '); + if (space == std::string::npos) + continue; + const std::string tag = line.substr(0, space); + std::istringstream s(line.substr(space + 1)); + double clock = 0, frame = 0; + if (!(s >> clock >> frame)) + continue; + if (m.firstClock == 0) + m.firstClock = clock; + m.lastClock = std::max(m.lastClock, clock); + if (tag == "D") { + int id = 0, own = 0; + if (s >> id >> own && own) + m.self = id; + } else if (tag == "RS") { + Shown e{clock, frame}; + int player = 0; + if (s >> player >> e.index >> e.count >> e.kind) { + m.self = player; + m.shown.push_back(e); + } + } else if (tag == "RX") { + int player = 0, index = 0; + if (!(s >> player >> index)) + continue; + // The newest showing of that waypoint not taken back yet. + for (auto it = m.shown.rbegin(); it != m.shown.rend(); ++it) + if (it->index == index && !it->takenBack) { + it->takenBack = true; + break; + } + } else if (tag == "RH") { + HostHit h{clock, frame}; + if (s >> h.player >> h.sample >> h.index >> h.count >> h.lap) + m.hostHits.push_back(h); + } else if (tag == "RF" || tag == "RG") { + int player = 0; + unsigned ms = 0; + if (!(s >> player >> ms)) + continue; + auto& map = tag == "RF" ? m.finishes : m.refereeFinishes; + map.try_emplace(player, Finish{clock, ms}); + } else if (tag == "RE") { + int place = 0, racers = 0; + if (s >> place >> racers) + m.standings.emplace_back(place, racers); + } else if (tag == "CG") { + Gold g{clock}; + if (s >> g.type >> g.car >> g.value) + m.gold.push_back(g); + } else if (tag == "CS") { + std::size_t n = 0; + if (!(s >> n)) + continue; + m.scores.clear(); + for (std::size_t i = 0; i < n; ++i) { + int id = 0, score = 0; + if (s >> id >> score) + m.scores[id] = score; + } + } + } + return true; +} + +// A hit's identity: the waypoint and how many times the car hit it before. +using Key = std::pair; +std::vector keys(const std::vector& seq) { + std::vector out; + std::map seen; + for (int i : seq) + out.emplace_back(i, seen[i]++); + return out; +} + +double percentile(std::vector v, double p) { + if (v.empty()) + return 0.0; + std::ranges::sort(v); + const auto k = static_cast(p * static_cast(v.size() - 1) + 0.5); + return v[std::min(k, v.size() - 1)]; +} + +std::string time(unsigned ms) { + if (ms >= kDnfMs) + return "DNF"; + return std::format("{}:{:02}.{:03}", ms / 60000, ms / 1000 % 60, ms % 1000); +} + +void checkpoints(const Machine& host, const Machine& m, double endClock) { + if (m.self < 0) { + std::println(" {}: no player of its own in the trace", m.path); + return; + } + std::vector counted; + std::vector hostHits; + for (const auto& h : host.hostHits) + if (h.player == m.self) { + counted.push_back(h.index); + hostHits.push_back(&h); + } + std::vector finalSeq; + std::vector finalShown; + int predicted = 0, fromHost = 0, takenBack = 0; + for (const auto& s : m.shown) { + (s.kind ? fromHost : predicted) += 1; + if (s.takenBack) { + ++takenBack; + continue; + } + finalSeq.push_back(s.index); + finalShown.push_back(&s); + } + const auto hostKeys = keys(counted); + const auto shownKeys = keys(finalSeq); + int shownNotCounted = 0, pendingAtEnd = 0, countedNotShown = 0; + std::vector leadPredicted, lagHostWord; + for (std::size_t i = 0; i < shownKeys.size(); ++i) { + const auto it = std::ranges::find(hostKeys, shownKeys[i]); + if (it == hostKeys.end()) { + (finalShown[i]->clock > endClock - 1000.0 ? pendingAtEnd : shownNotCounted) += 1; + continue; + } + const double dt = finalShown[i]->clock - hostHits[static_cast(it - hostKeys.begin())]->clock; + (finalShown[i]->kind ? lagHostWord : leadPredicted).push_back(dt); + } + for (const auto& k : hostKeys) + if (std::ranges::find(shownKeys, k) == shownKeys.end()) + ++countedNotShown; + const int hostLaps = static_cast(std::ranges::count(counted, 0)); + const int shownLaps = static_cast(std::ranges::count(finalSeq, 0)); + std::println(" player {} ({}): host counted {} checkpoints ({} at gate 0); its HUD showed {} ({} as " + "predicted, {} on the host's word), took {} back", + m.self, m.path, counted.size(), hostLaps, m.shown.size(), predicted, fromHost, takenBack); + std::println(" shown and never counted by the host: {} (+{} still on their way at the end); counted and " + "not shown: {}; gate-0 passes shown {} against {}", + shownNotCounted, pendingAtEnd, countedNotShown, shownLaps, hostLaps); + if (!leadPredicted.empty()) + std::println(" predicted ones shown {:.0f} ms before the host counted them (median; 1% {:.0f}, 99% {:.0f})", + -percentile(leadPredicted, 0.5), -percentile(leadPredicted, 0.99), + -percentile(leadPredicted, 0.01)); + if (!lagHostWord.empty()) + std::println(" the host's own shown {:.0f} ms after it counted them (median; 99% {:.0f})", + percentile(lagHostWord, 0.5), percentile(lagHostWord, 0.99)); +} + +void finishes(const std::vector& machines) { + std::set players; + for (const auto& m : machines) + for (const auto& [id, f] : m.finishes) + players.insert(id); + // The race's referee: a build that kept the players' own word has the + // host's measure in RG; otherwise the host's results are the referee's. + const Machine& host = machines.front(); + const auto& referee = host.refereeFinishes.empty() ? host.finishes : host.refereeFinishes; + int agree = 0, total = 0, offReferee = 0; + for (const int id : players) { + std::string line = std::format(" player {}:", id); + std::set values; + for (const auto& m : machines) { + const auto it = m.finishes.find(id); + line += it == m.finishes.end() ? " -" : " " + time(it->second.ms); + if (it != m.finishes.end()) + values.insert(it->second.ms); + } + if (const auto r = referee.find(id); r != referee.end()) { + line += std::format(" (the host's measure {})", time(r->second.ms)); + for (const auto& m : machines) + if (const auto it = m.finishes.find(id); it != m.finishes.end() && it->second.ms != r->second.ms) + ++offReferee; + } + ++total; + bool all = values.size() == 1; + for (const auto& m : machines) + all = all && m.finishes.contains(id); + agree += all ? 1 : 0; + std::println("{}", line); + } + std::println(" {} of {} finishes the same on every machine; {} results differ from the host's measure", agree, + total, offReferee); + auto order = [](const Machine& m) { + std::vector> v; + for (const auto& [id, f] : m.finishes) + v.emplace_back(f.ms, id); + std::ranges::sort(v); + std::vector ids; + for (const auto& [ms, id] : v) + ids.push_back(id); + return ids; + }; + bool same = true; + for (const auto& m : machines) + same = same && order(m) == order(machines.front()); + std::println(" finishing order the same on every machine: {}", same ? "yes" : "no"); +} + +void gold(const std::vector& machines) { + // The events that matter (taken, dropped, delivered, limits) in order, + // a client's predicted pickup that it took back left out. + auto events = [](const Machine& m, int* undone) { + std::vector> out; + for (const auto& g : m.gold) { + if (g.type == 7) { // PickupUndone + for (auto it = out.rbegin(); it != out.rend(); ++it) + if (it->first == 0 && it->second == g.car) { + out.erase(std::next(it).base()); + break; + } + ++*undone; + continue; + } + if (g.type == 3 || g.type == 4) // NewSet, TimeWarning + continue; + out.emplace_back(g.type, g.car); + } + return out; + }; + int hostUndone = 0; + const auto host = events(machines.front(), &hostUndone); + std::println(" host: {} gold events, scores{}", host.size(), + [&] { + std::string s; + for (const auto& [id, sc] : machines.front().scores) + s += std::format(" {}={}", id, sc); + return s; + }()); + for (std::size_t i = 1; i < machines.size(); ++i) { + int undone = 0; + const auto mine = events(machines[i], &undone); + // The longest common subsequence: what both had in the same order. + std::vector> lcs(host.size() + 1, std::vector(mine.size() + 1, 0)); + for (std::size_t a = 1; a <= host.size(); ++a) + for (std::size_t b = 1; b <= mine.size(); ++b) + lcs[a][b] = host[a - 1] == mine[b - 1] ? lcs[a - 1][b - 1] + 1 + : std::max(lcs[a - 1][b], lcs[a][b - 1]); + const int common = lcs[host.size()][mine.size()]; + std::string scores; + for (const auto& [id, sc] : machines[i].scores) + scores += std::format(" {}={}", id, sc); + std::println(" {}: {} gold events; host's missing here {}, here and not on the host {}; pickups " + "predicted and undone {}; scores{} ({})", + machines[i].path, mine.size(), static_cast(host.size()) - common, + static_cast(mine.size()) - common, undone, scores, + machines[i].scores == machines.front().scores ? "the same" : "different"); + } +} + +} // namespace + +int rulesReport(const RulesReportOptions& options) { + if (options.traces.empty()) { + std::println(stderr, "rulesreport: no traces"); + return 1; + } + std::vector machines(options.traces.size()); + for (std::size_t i = 0; i < options.traces.size(); ++i) + if (!load(options.traces[i], machines[i])) { + std::println(stderr, "rulesreport: cannot read {}", options.traces[i]); + return 1; + } + const Machine& host = machines.front(); + const bool cops = std::ranges::any_of(machines, [](const Machine& m) { return !m.gold.empty(); }); + if (!host.hostHits.empty() || !cops) { + std::println("checkpoints (each machine's HUD against the host's referee):"); + for (const auto& m : machines) + checkpoints(host, m, std::min(host.lastClock, m.lastClock)); + std::println("finishes (each machine's results):"); + finishes(machines); + for (const auto& m : machines) + if (!m.standings.empty()) + std::println(" {}: \"Place\" changed {} times, last {}/{}", m.path, m.standings.size(), + m.standings.back().first, m.standings.back().second); + } + if (cops) { + std::println("Cops and Robbers:"); + gold(machines); + } + return 0; +} + +} // namespace mm2::netprobe diff --git a/tools/netprobe/RulesReport.h b/tools/netprobe/RulesReport.h new file mode 100644 index 0000000..5892429 --- /dev/null +++ b/tools/netprobe/RulesReport.h @@ -0,0 +1,21 @@ +#pragma once + +// netprobe rulesreport: whether the machines of a network race agree on its +// rules (each player's checkpoints, laps and finish, the results, Cops and +// Robbers' gold events and scores), from each machine's OPENMM2_NET_TRACE +// (the R and C lines of game/net/RulesTrace.h; docs/review/ +// multiplayer-desync-rules.md). + +#include +#include + +namespace mm2::netprobe { + +struct RulesReportOptions { + std::vector traces; // the host's first, then the clients' +}; + +// Prints the report; returns 0, or 1 when a trace cannot be read. +int rulesReport(const RulesReportOptions& options); + +} // namespace mm2::netprobe diff --git a/tools/netprobe/main.cpp b/tools/netprobe/main.cpp index 163599e..0059e72 100644 --- a/tools/netprobe/main.cpp +++ b/tools/netprobe/main.cpp @@ -8,9 +8,11 @@ // netprobe relay [--port N] [--delay MS] [--jitter MS] [--loss PERCENT] [--reorder] // [--seed N] [--seconds N] // netprobe syncreport ... [--skip S] [--teleport M] +// netprobe rulesreport ... // divergence between machines (OPENMM2_NET_TRACE files) // // --seconds 0 runs until Ctrl-C. Port mappings are removed on exit. +#include "RulesReport.h" #include "SyncReport.h" #include "core/Log.h" #include "core/StringUtil.h" @@ -382,7 +384,7 @@ int cmdRelay(const Args& a) { } 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" @@ -390,7 +392,8 @@ int usage() { " 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]\n" - " syncreport ... [--skip S] [--teleport M]"); + " syncreport ... [--skip S] [--teleport M]\n" + " rulesreport ..."); return 2; } @@ -427,5 +430,10 @@ int main(int argc, char** argv) { o.teleportMetres = str::parseDouble(args.get("--teleport")).value_or(o.teleportMetres); return netprobe::syncReport(o); } + if (cmd == "rulesreport") { + netprobe::RulesReportOptions o; + o.traces = args.positional; + return netprobe::rulesReport(o); + } return usage(); } From e51e9bf8747b3fdaf66914977d004fa669976dc5 Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Fri, 9 Oct 2026 16:53:13 -0700 Subject: [PATCH 066/129] Run a client's corrected samples against the traffic where it was then The shared traffic's cars on their rails within 60 m of a client's car now stand, in each sample run again, where the host had them at that sample's time (TrafficClient::poseAt, through TrafficBodies::placeRailCars), instead of where this frame put them, up to a fifth of a second later. OPENMM2_NET_TRAFFIC_REPLAY=frame keeps the old placing for comparison. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/app/RaceScreen.cpp | 37 +++++++++++++++++++++++++++++++++++- src/game/TrafficBodies.cpp | 39 +++++++++++++++++++++++++++++++------- src/game/TrafficBodies.h | 14 ++++++++++++++ 3 files changed, 82 insertions(+), 8 deletions(-) diff --git a/src/app/RaceScreen.cpp b/src/app/RaceScreen.cpp index d568647..0a86bf0 100644 --- a/src/app/RaceScreen.cpp +++ b/src/app/RaceScreen.cpp @@ -5090,6 +5090,16 @@ class RaceScreen final : public Screen { m_netCarLead = m_netCarLead ? *m_netCarLead + (lead - *m_netCarLead) * 0.1 : lead; } + // Client: the session time sample `seq` of this machine's car ended at + // (m_netSampleTimes keeps each frame's last). + std::optional netSampleTime(std::uint32_t seq) const { + const auto e = std::ranges::find_if(m_netSampleTimes, [seq](const auto& x) { return x.first >= seq; }); + if (e == m_netSampleTimes.end()) + return std::nullopt; + const double step = static_cast(phys::kFixedSampleStep) * 1000.0 / m_netDilation; + return e->second - static_cast(e->first - seq) * step; + } + // Client: the host's states of this machine's car since the last frame: // the newest corrects the prediction when they differ; how many of its // inputs the host had in hand sets how fast the samples run. @@ -5144,6 +5154,18 @@ class RaceScreen final : public Screen { } std::vector companions; predictNearCars(newest->near, companions); + // The shared traffic's cars on their rails around this car: the + // samples run again meet each where it was at that sample's time on + // the host (TrafficClient::poseAt), as the frames placed them; after, + // where this frame put them. (OPENMM2_NET_TRAFFIC_REPLAY=frame keeps + // them where this frame put them, the comparison.) + static const bool railsHeld = [] { + const char* v = std::getenv("OPENMM2_NET_TRAFFIC_REPLAY"); + return v && std::string_view(v) == "frame"; + }(); + std::vector railsNow; + if (m_trafficClient && m_trafficBodies && !railsHeld) + railsNow = m_trafficBodies->railPoses(m_player->sim().body.ics.matrix.m3, 60.0f); // The bodies the samples run again may move, as they stand now. std::vector now; for (const auto& h : m_bodyHistory) @@ -5169,13 +5191,26 @@ class RaceScreen final : public Screen { m_drawnPhys.record(game::drawnKey(game::Drawn::RemoteCar, id), rv.sim->pose(), rv.sim->sim().resets); }, - [this](std::uint32_t seq) { + [&](std::uint32_t seq) { for (const auto& h : m_bodyHistory) if (h.seq == seq) placeBodies(h.poses); + if (railsNow.empty()) + return; + const auto t = netSampleTime(seq); + if (!t) + return; + const double at = *t + m_netCarLead.value_or(0.0); + std::vector then; + for (const auto& r : railsNow) + if (const auto p = m_trafficClient->poseAt(r.id, at)) + then.push_back({r.id, p->transform}); + m_trafficBodies->placeRailCars(then); }, companions); placeBodies(now); + if (!railsNow.empty()) + m_trafficBodies->placeRailCars(railsNow); m_netReplaying = false; // The other players' cars: what the host's states moved the ones // simulated here by, and a switch between simulating one and placing diff --git a/src/game/TrafficBodies.cpp b/src/game/TrafficBodies.cpp index b40f524..ae68330 100644 --- a/src/game/TrafficBodies.cpp +++ b/src/game/TrafficBodies.cpp @@ -682,24 +682,49 @@ void TrafficBodies::beforeStep() { if (r && !r->active && r->collidable && r->room != 0) m_world.declareInstance(r, 2, c.moverFlags); } - m_roomList.clear(); for (auto& r : m_railCars) { - if (!r) - continue; - if (!r->present) { + if (r && !r->present) { r->collidable = false; r->held = false; r->lost = false; r->room = 0; - continue; } - if (!r->active && r->collidable && r->room != 0) - m_roomList.emplace_back(r->room, r.get()); } + listRooms(); +} + +void TrafficBodies::listRooms() { + m_roomList.clear(); + for (auto& r : m_railCars) + if (r && r->present && !r->active && r->collidable && r->room != 0) + m_roomList.emplace_back(r->room, r.get()); // Already in id order; stable by room keeps it within a room. std::ranges::stable_sort(m_roomList, {}, &std::pair::first); } +std::vector TrafficBodies::railPoses(const Vec3& at, float radius) const { + std::vector out; + for (const auto& r : m_railCars) + if (r && r->present && !r->active && !r->held && r->collidable && + r->railMatrix.m3.dist2(at) <= radius * radius) + out.push_back({r->id, r->railMatrix}); + return out; +} + +void TrafficBodies::placeRailCars(std::span poses) { + const phys::Level* level = m_world.level(); + for (const RailPose& p : poses) { + RailCar* r = p.id >= 0 && static_cast(p.id) < m_railCars.size() + ? m_railCars[static_cast(p.id)].get() + : nullptr; + if (!r || !r->present || r->active || r->held) + continue; + r->railMatrix = p.transform; + r->room = level ? level->findRoom(r->position(), r->room) : 0; + } + listRooms(); +} + void TrafficBodies::afterStep() { // aiVehicleActive::PostUpdate for each active (dgPhysManager calls it // for every mover after the samples). diff --git a/src/game/TrafficBodies.h b/src/game/TrafficBodies.h index f473304..1648bbb 100644 --- a/src/game/TrafficBodies.h +++ b/src/game/TrafficBodies.h @@ -23,6 +23,7 @@ #include #include #include +#include #include #include #include @@ -132,6 +133,17 @@ class TrafficBodies final : public InstanceSource { // Cars with a body (at most 32). std::size_t activeCount() const { return static_cast(m_count); } + // OpenMM2 (a network client running its car's samples again): the cars + // on their rails (no body) within `radius` of `at` and where they are; + // and cars on their rails put somewhere for a while (their rooms follow; + // the AI's list is not touched, the next beforeStep puts them back). + struct RailPose { + int id = -1; + Mat34 transform; // model origin + }; + std::vector railPoses(const Vec3& at, float radius) const; + void placeRailCars(std::span poses); + // ?WeatherFriction@@3MA for the wheels' grip (mmGame::InitWeather: 0.8 // in rain, 0.75 in rain at night, else 1). void setWeatherFriction(float friction) { m_weatherFriction = friction; } @@ -151,6 +163,8 @@ class TrafficBodies final : public InstanceSource { RailCar& railCar(int id); const RailCar* findRailCar(int id) const; const BoundEntry& boundFor(const ai::VehicleData& data); + // The rail cars by room (instancesIn's list), after their rooms changed. + void listRooms(); // aiVehicleManager::Attach / Detach. Active& attach(RailCar& car); void release(Active& active); From 619ce6fecc79fabeb23f5f1ee0c42b84c66bd5ec Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Fri, 9 Oct 2026 17:06:44 -0700 Subject: [PATCH 067/129] Keep the player's impact callback under the autopilot ai::Opponent takes its car's impact callback for itself and gives it back when it goes; the autopilot (a development aid) builds a new one for each Cops and Robbers target, which left the player's car calling a destroyed driver (a crash) and without its own callback (the impact sounds, the damage the host sends, Cops and Robbers' hits). The car keeps its own. OPENMM2_DEBUG_START_GOLD puts the two cars on opposite sides of the gold, facing each other. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/app/RaceScreen.cpp | 4 ++-- src/game/net/Autopilot.cpp | 10 +++++++++- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/app/RaceScreen.cpp b/src/app/RaceScreen.cpp index eeb5245..0ac85eb 100644 --- a/src/app/RaceScreen.cpp +++ b/src/app/RaceScreen.cpp @@ -3099,10 +3099,10 @@ class RaceScreen final : public Screen { } // Development aid: OPENMM2_DEBUG_START_GOLD= starts a Cops and // Robbers car that far from the first gold, facing it, each player on - // its own side (every machine knows the first places when it loads). + // its own side, facing each other (every machine knows the first places when it loads). if (const char* g = std::getenv("OPENMM2_DEBUG_START_GOLD"); g && m_cr && m_player && m_world) { const float r = static_cast(str::parseDouble(g).value_or(30.0)); - const float around = 2.0943951f * static_cast(ctx.netGame->localId()); + const float around = 3.1415927f * static_cast(ctx.netGame->localId()); const Vec3 gold = m_cr->set().gold; Vec3 at = gold + Vec3{r * std::cos(around), 0.0f, r * std::sin(around)}; phys::RayHit hit; diff --git a/src/game/net/Autopilot.cpp b/src/game/net/Autopilot.cpp index 51a2be3..daa0ed9 100644 --- a/src/game/net/Autopilot.cpp +++ b/src/game/net/Autopilot.cpp @@ -45,8 +45,12 @@ std::unique_ptr NetAutopilot::create(const city::CityData& city, c if (!pilot->m_ai) return nullptr; const int laps = std::max(1, setup.laps); + // The AI takes the car's impact callback for itself; the player's car + // keeps its own. + const auto impacts = car.onImpactCallback; pilot->m_driver = ai::Opponent::create(pilot->m_ai->map(), car, *path, o.params, laps, 1, 0, error, ground, setup.config.vehicle); + car.onImpactCallback = impacts; if (!pilot->m_driver) return nullptr; if (speedLimit > 0.0f) @@ -85,7 +89,10 @@ void NetAutopilot::setTarget(phys::CarSim& car, const Vec3& target, const phys:: std::vector path(2); path[0].position = car.body.ics.matrix.m3; path[1].position = target; + const auto impacts = car.onImpactCallback; + m_driver.reset(); m_driver = ai::Opponent::create(m_ai->map(), car, path, {}, 1, 1, 0, nullptr, ground, m_vehicle); + car.onImpactCallback = impacts; if (!m_driver) return; if (m_speedLimit > 0.0f) @@ -114,8 +121,9 @@ NetAutopilot::Controls NetAutopilot::drive(float dt, phys::CarSim& car, bool hel const float angle = std::atan2(dx * m.m0.x + dz * m.m0.z, -(dx * m.m2.x + dz * m.m2.z)); c.steering = std::clamp(angle * 1.33f, -1.0f, 1.0f); const bool behind = std::fabs(angle) > 2.0f; + const float speed = -car.body.ics.linearVelocity.dot(m.m2); c.reverse = behind; - c.throttle = behind ? 0.5f : 0.6f; + c.throttle = behind ? 0.5f : (speed < 20.0f ? 1.0f : 0.0f); c.brake = 0.0f; } return c; From 4128b00648ebe303c94269be3dfb51dcd55da68f Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Fri, 9 Oct 2026 17:10:23 -0700 Subject: [PATCH 068/129] Refuse a respawn at a checkpoint the host never counted mmGameMulti::HitWaterHandler puts a car back at the last checkpoint it cleared, and a client's water handler sends that place to the host as a command. The host only checked it against the city's box (players' cars review O4); with the rules on the host it now takes it only at the start or at a checkpoint its referee counted for that car (RaceReferee:: mayRespawnAt), so a client cannot move its car on to a checkpoint it never reached. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/app/RaceScreen.cpp | 6 ++++++ src/game/net/NetRules.h | 6 ++++++ src/game/session/RaceReferee.cpp | 13 +++++++++++++ src/game/session/RaceReferee.h | 5 +++++ tests/game/test_net_race_rules.cpp | 20 ++++++++++++++++++++ 5 files changed, 50 insertions(+) diff --git a/src/app/RaceScreen.cpp b/src/app/RaceScreen.cpp index 0ac85eb..0736b27 100644 --- a/src/app/RaceScreen.cpp +++ b/src/app/RaceScreen.cpp @@ -5032,6 +5032,12 @@ class RaceScreen final : public Screen { for (const auto& c : next->commands) { if (!netCommandAllowed(rv, c)) continue; + // A respawn at a checkpoint the host never counted for it. + if (c.kind == net::CarCommandKind::RespawnAt && !m_netRules.respawnAllowed(id, c.position)) { + log::info("race: player {}'s respawn at ({:.1f}, {:.1f}, {:.1f}) refused", id, + c.position.x, c.position.y, c.position.z); + continue; + } // mmMultiCR::HitWaterHandler / DropThruCityHandler on that machine. if (c.kind == net::CarCommandKind::Reset || c.kind == net::CarCommandKind::RespawnAt) m_crResets.insert(id); diff --git a/src/game/net/NetRules.h b/src/game/net/NetRules.h index 9c7afdb..29212c3 100644 --- a/src/game/net/NetRules.h +++ b/src/game/net/NetRules.h @@ -77,6 +77,12 @@ class NetRules { // After each physics sample of a player's car (the host's own too): `seq` // the input's number, `held` whether it held the car. void hostSample(std::uint8_t id, std::uint32_t seq, const Mat34& car, const Vec3& inertiaBox, bool held); + // Whether a client's car may be put back at `position` (its water + // handler's RespawnAt; O4 of the players' cars review): in a race, at the + // start or a checkpoint the host counted for it. + bool respawnAllowed(std::uint8_t id, const Vec3& position) const { + return !m_referee || m_referee->mayRespawnAt(id, position); + } // A player left the session or quit the race. void playerLeft(std::uint8_t id); // Cops and Robbers: what the host's rules decided this frame. diff --git a/src/game/session/RaceReferee.cpp b/src/game/session/RaceReferee.cpp index f494aa1..7587f34 100644 --- a/src/game/session/RaceReferee.cpp +++ b/src/game/session/RaceReferee.cpp @@ -1,5 +1,7 @@ #include "game/session/RaceReferee.h" +#include "game/session/RaceSetup.h" + #include namespace mm2::game::session { @@ -183,6 +185,17 @@ void RaceReferee::update(float dt) { } } +bool RaceReferee::mayRespawnAt(std::uint8_t id, const Vec3& position) const { + const Player* p = player(id); + if (!p || m_config.checkpoints.empty()) + return false; + auto near = [&](int index) { + const auto k = static_cast(index); + return k < m_config.checkpoints.size() && spawnAt(m_config.checkpoints[k]).m3.dist2(position) < 0.25f; + }; + return near(0) || std::ranges::any_of(p->hits, [&](std::uint8_t h) { return near(h); }); +} + int RaceReferee::iconPlace(std::uint8_t viewer, std::uint8_t car) const { // mmGameMulti::UpdateScore's second half on `viewer`'s machine: a car's // IconIndex is 1 + the other cars ahead of it (more waypoints passed, diff --git a/src/game/session/RaceReferee.h b/src/game/session/RaceReferee.h index 6da36ad..26b0414 100644 --- a/src/game/session/RaceReferee.h +++ b/src/game/session/RaceReferee.h @@ -104,6 +104,11 @@ class RaceReferee { const std::vector& results() const { return m_results; } bool timedOut() const { return m_timedOut; } bool allCounted() const { return m_allCounted; } + // Whether a player's car may be put back at `position` (a water + // handler's respawn, mmGameMulti::HitWaterHandler: at the last + // checkpoint it cleared): the start or a checkpoint the host counted for + // it, within half a metre. + bool mayRespawnAt(std::uint8_t id, const Vec3& position) const; // A car's icon number on `viewer`'s machine (mmGameMulti::UpdateScore's // IconIndex): 0 no icon (finished, or no car), else its place. int iconPlace(std::uint8_t viewer, std::uint8_t car) const; diff --git a/tests/game/test_net_race_rules.cpp b/tests/game/test_net_race_rules.cpp index e5337f9..337089f 100644 --- a/tests/game/test_net_race_rules.cpp +++ b/tests/game/test_net_race_rules.cpp @@ -289,6 +289,26 @@ TEST(NetRaceRules, RefereeRanksEveryCarAsItsMachineWould) { EXPECT_EQ(r.player(0)->racers, 2); } +TEST(NetRaceRules, RefereeLetsACarRespawnOnlyWhereItHasBeen) { + // mmGameMulti::HitWaterHandler puts the car back at the last checkpoint + // it cleared: the host takes such a reset at the start or a checkpoint + // it counted for that car, not at one it never reached. + RaceReferee::Config c; + c.mode = GameMode::Checkpoint; + c.checkpoints = straightCourse(3); + RaceReferee r(c); + r.addPlayer(1); + Car a{1}; + for (int i = 0; i < 120; ++i) + a.sample(r, false); // clears gate 1 + ASSERT_EQ(r.player(1)->hits, (std::vector{1})); + EXPECT_TRUE(r.mayRespawnAt(1, c.checkpoints[0].position)); + EXPECT_TRUE(r.mayRespawnAt(1, c.checkpoints[1].position + Vec3{0.2f, 0.0f, 0.2f})); + EXPECT_FALSE(r.mayRespawnAt(1, c.checkpoints[2].position)); + EXPECT_FALSE(r.mayRespawnAt(1, c.checkpoints[1].position + Vec3{2.0f, 0.0f, 0.0f})); + EXPECT_FALSE(r.mayRespawnAt(7, c.checkpoints[0].position)); // nobody's +} + // --- Cops and Robbers on the host ------------------------------------------------------- namespace { From 760c8b060114eeb76c69a00429ad839f7bf4c1be Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Fri, 9 Oct 2026 17:14:46 -0700 Subject: [PATCH 069/129] Check every gold event against the host's simulation in the report The CG trace line carries where the gold is, and rulesreport checks each pickup against where the host draws the taker's car (within 5 m, 7 m allowed for a frame's drawing) and each knock against a collision of 250 or more of the carrier's own with another player's car on the host (mmMultiCR::ImpactCallback), whichever machine decided it. With OPENMM2_DEBUG_NETCARS the race also logs what the rules messages cost. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/app/RaceScreen.cpp | 12 ++++- src/game/net/RulesTrace.cpp | 5 +- src/game/net/RulesTrace.h | 9 ++-- tools/netprobe/RulesReport.cpp | 84 +++++++++++++++++++++++++++++++++- 4 files changed, 102 insertions(+), 8 deletions(-) diff --git a/src/app/RaceScreen.cpp b/src/app/RaceScreen.cpp index 0736b27..00e1826 100644 --- a/src/app/RaceScreen.cpp +++ b/src/app/RaceScreen.cpp @@ -2689,7 +2689,7 @@ class RaceScreen final : public Screen { const bool me = e.car == m_crSelf; if (e.type != E::TimeWarning) game::traceCopsEvent(trace, ctx.netGame->frameTime(), static_cast(e.type), e.car, - e.value); + e.value, m_cr->goldPosition()); switch (e.type) { case E::GoldTaken: if (me) { @@ -5360,7 +5360,17 @@ class RaceScreen final : public Screen { m_netReplayMs = m_netReplayWorstMs = 0.0; } m_netStatesBytes = m_netStatesSent = m_netInputBytes = m_netInputsSent = 0; + // The rules' messages (game/net/NetRules): sent by the host, taken by a client. + if (verbose && m_netRules.active()) { + const auto& r = m_netRules.stats(); + log::info("netrules: {} messages sent ({:.0f} B/s), {} taken, {} decisions, {} refused, {} malformed, " + "{} stale, {} decisions missed", + r.sent - m_netRulesSent.sent, static_cast(r.bytes - m_netRulesSent.bytes) / seconds, + r.received, r.decisions, r.refused, r.malformed, r.stale, r.gaps); + m_netRulesSent = r; + } } + game::NetRules::Stats m_netRulesSent; // the rules' statistics at the last log // mmPlayer::Reset, the HitWaterHandlers and vehCar::ClearDamage on this // machine's car. A client's command reaches the host with its inputs and diff --git a/src/game/net/RulesTrace.cpp b/src/game/net/RulesTrace.cpp index 738c45f..d3ecce2 100644 --- a/src/game/net/RulesTrace.cpp +++ b/src/game/net/RulesTrace.cpp @@ -40,9 +40,10 @@ void traceRuleStanding(std::FILE* f, double frame, int place, int racers) { std::fprintf(f, "RE %.3f %.3f %d %d\n", machineMs(), frame, place, racers); } -void traceCopsEvent(std::FILE* f, double frame, int type, int car, int value) { +void traceCopsEvent(std::FILE* f, double frame, int type, int car, int value, const Vec3& gold) { if (f) - std::fprintf(f, "CG %.3f %.3f %d %d %d\n", machineMs(), frame, type, car, value); + std::fprintf(f, "CG %.3f %.3f %d %d %d %.2f %.2f %.2f\n", machineMs(), frame, type, car, value, gold.x, + gold.y, gold.z); } void traceCopsScores(std::FILE* f, double frame, const std::vector>& scores) { diff --git a/src/game/net/RulesTrace.h b/src/game/net/RulesTrace.h index 644d440..f698872 100644 --- a/src/game/net/RulesTrace.h +++ b/src/game/net/RulesTrace.h @@ -21,12 +21,15 @@ // this machine's results took player's finish (ms 86400000: did not) // RE clock frame place racers // this machine's "Place: n/N" changed -// CG clock frame type car value +// CG clock frame type car value x y z // Cops and Robbers: this machine's HUD showed a gold event -// (CopsAndRobbers::EventType; value: the points, or 1 for a hit) +// (CopsAndRobbers::EventType; value: the points, or 1 for a hit), the +// gold then at (x, y, z) // CS clock frame n id score ... // Cops and Robbers: every player's score, when one changed +#include "core/Math.h" + #include #include #include @@ -40,7 +43,7 @@ void traceRuleHostHit(std::FILE* f, double frame, int player, std::uint32_t samp int lap); void traceRuleFinish(std::FILE* f, double frame, int player, std::uint32_t ms); void traceRuleStanding(std::FILE* f, double frame, int place, int racers); -void traceCopsEvent(std::FILE* f, double frame, int type, int car, int value); +void traceCopsEvent(std::FILE* f, double frame, int type, int car, int value, const Vec3& gold); void traceCopsScores(std::FILE* f, double frame, const std::vector>& scores); } // namespace mm2::game diff --git a/tools/netprobe/RulesReport.cpp b/tools/netprobe/RulesReport.cpp index f61c681..2035e81 100644 --- a/tools/netprobe/RulesReport.cpp +++ b/tools/netprobe/RulesReport.cpp @@ -17,6 +17,7 @@ #include "RulesReport.h" #include +#include #include #include #include @@ -51,6 +52,15 @@ struct Finish { struct Gold { double clock = 0; int type = 0, car = 0, value = 0; + double x = 0, y = 0, z = 0; // the gold then +}; +struct Pos { + double clock = 0, x = 0, y = 0, z = 0; +}; +struct Hit { + double clock = 0; + int a = 0, b = 0; + double strength = 0; }; struct Machine { @@ -62,6 +72,8 @@ struct Machine { std::vector> standings; std::vector gold; std::map scores; + std::map> drawn; // every player's car as this machine draws it + std::vector hits; // collisions between players' cars here double firstClock = 0, lastClock = 0; }; @@ -85,8 +97,17 @@ bool load(const std::string& path, Machine& m) { m.lastClock = std::max(m.lastClock, clock); if (tag == "D") { int id = 0, own = 0; - if (s >> id >> own && own) + Pos p{clock}; + if (!(s >> id >> own >> p.x >> p.y >> p.z)) + continue; + if (own) m.self = id; + m.drawn[id].push_back(p); + } else if (tag == "K") { + Hit h{clock}; + double x = 0, y = 0, z = 0; + if (s >> h.a >> h.b >> x >> y >> z >> h.strength) + m.hits.push_back(h); } else if (tag == "RS") { Shown e{clock, frame}; int player = 0; @@ -121,8 +142,10 @@ bool load(const std::string& path, Machine& m) { m.standings.emplace_back(place, racers); } else if (tag == "CG") { Gold g{clock}; - if (s >> g.type >> g.car >> g.value) + if (s >> g.type >> g.car >> g.value) { + s >> g.x >> g.y >> g.z; m.gold.push_back(g); + } } else if (tag == "CS") { std::size_t n = 0; if (!(s >> n)) @@ -269,6 +292,62 @@ void finishes(const std::vector& machines) { std::println(" finishing order the same on every machine: {}", same ? "yes" : "no"); } +// Whether the host's simulation backs each pickup and each knock (whoever +// decided it): the taker's car within 5 m of the gold as the host draws it +// (7 m allowed: a drawing is up to a frame off), a carrier knocked loose by +// a collision of its own of 250 or more with another player's car on the +// host within 0.3 s. +void backing(const std::vector& machines) { + const Machine& host = machines.front(); + auto nearest = [](const std::vector& v, double clock) -> const Pos* { + const Pos* best = nullptr; + for (const auto& p : v) + if (!best || std::abs(p.clock - clock) < std::abs(best->clock - clock)) + best = &p; + return best && std::abs(best->clock - clock) < 100.0 ? best : nullptr; + }; + int pickups = 0, farPickups = 0, knocks = 0, unbacked = 0; + for (const auto& g : host.gold) { + if (g.type != 0) + continue; + ++pickups; + const auto it = host.drawn.find(g.car); + const Pos* p = it == host.drawn.end() ? nullptr : nearest(it->second, g.clock); + const double d = p ? std::hypot(p->x - g.x, p->y - g.y, p->z - g.z) : 1e9; + if (d > 7.0) { + ++farPickups; + std::println(" player {}'s pickup at {:.0f}: its car {:.1f} m from the gold on the host", g.car, g.clock, + d); + } + } + // Each knock once: the host's own record of it (every car's since + // protocol 8, only its own car's before), else the carrier's machine's. + std::vector knockList; + for (const auto& g : host.gold) + if (g.type == 1 && g.value == 1) + knockList.push_back(&g); + for (std::size_t i = 1; i < machines.size(); ++i) + for (const auto& g : machines[i].gold) + if (g.type == 1 && g.value == 1 && g.car == machines[i].self && + std::ranges::none_of(knockList, [&](const Gold* k) { + return k->car == g.car && std::abs(k->clock - g.clock) < 1000.0; + })) + knockList.push_back(&g); + for (const Gold* g : knockList) { + ++knocks; + const bool backed = std::ranges::any_of(host.hits, [&](const Hit& h) { + return h.a == g->car && h.strength >= 250.0 && std::abs(h.clock - g->clock) < 300.0; + }); + if (!backed) { + ++unbacked; + std::println(" player {}'s gold knocked loose at {:.0f}: no such hit on the host", g->car, g->clock); + } + } + std::println(" the host's simulation: {} of {} pickups with the car more than 7 m from the gold, {} of {} " + "knocks without a hit of 250 there", + farPickups, pickups, unbacked, knocks); +} + void gold(const std::vector& machines) { // The events that matter (taken, dropped, delivered, limits) in order, // a client's predicted pickup that it took back left out. @@ -349,6 +428,7 @@ int rulesReport(const RulesReportOptions& options) { if (cops) { std::println("Cops and Robbers:"); gold(machines); + backing(machines); } return 0; } From 149321797d66010e5aeb3d77b9626fbd0fb5a105 Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Fri, 9 Oct 2026 17:16:44 -0700 Subject: [PATCH 070/129] End a race still counting down when the host times it out A machine still loading when the host started without it (the start's 60 s rule) can still be in its countdown when the host's finish timeout runs out; MM2's 0x1fe acts in any state but the finish's, so the host's did-not-finish now ends that race too instead of leaving it to start alone. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/game/session/Session.cpp | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/game/session/Session.cpp b/src/game/session/Session.cpp index 273c844..4548ed0 100644 --- a/src/game/session/Session.cpp +++ b/src/game/session/Session.cpp @@ -722,7 +722,8 @@ void Session::netFinished(float seconds) { // Did not finish: its clock ran out (a Blitz), or the host timed the // race out (its "Race over" came with netTimedOut). addNetResult(m_options.playerName, kNetDnf, true); - if (m_phase == Phase::Racing) { + // (A late loader the host gave up on may still be in its countdown.) + if (m_phase == Phase::Racing || m_phase == Phase::Countdown) { stopTimerWarning(); m_wp.stopped = true; if (mode() == GameMode::Blitz) { @@ -771,7 +772,7 @@ void Session::netTimedOut() { // 0x1fe: a machine still racing stops, braked, with the mode's line for // 5 s (a Blitz's with the net alert); MM2 then sends its did-not-finish, // which the host has decided already. - if (!netRules() || m_phase != Phase::Racing) + if (!netRules() || (m_phase != Phase::Racing && m_phase != Phase::Countdown)) return; m_netTimedOut = true; stopTimerWarning(); From dd41ea687c6e2a599b2821a49e8524289ded9860 Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Fri, 9 Oct 2026 17:18:45 -0700 Subject: [PATCH 071/129] Keep a race this machine ended stopped through the host's word A client rebuilds its waypoints from the host's list when the two disagree; a race its own Blitz clock or the host's timeout had stopped (mmWaypoints' stop flag, the finish's marker hidden by DeactivateFinish) stays so. docs/multiplayer.md gains the "Rules" section (MM2's model, the host's referee, the message, what a client predicts, what the host refuses, late and leaving players, the deviations) and the protocol 8 notes in the topology, versioning, untrusted input, game events, per-frame contract, automation, testing and trace sections. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/multiplayer.md | 210 ++++++++++++++++++++++++++++++++--- src/game/session/Session.cpp | 7 ++ 2 files changed, 200 insertions(+), 17 deletions(-) diff --git a/docs/multiplayer.md b/docs/multiplayer.md index b500f73..d0c414b 100644 --- a/docs/multiplayer.md +++ b/docs/multiplayer.md @@ -53,7 +53,11 @@ and every other player's car. A client runs its own car ahead on its inputs (no latency) and corrects it from the host's states. (Before protocol 5 each machine simulated its own car and sent its state, which the host passed on; `VehicleState` and `WorldState` remain in the protocol, unused by -the game.) +the game.) The host also decides the rules (protocol 8, see "Rules"): every +car's checkpoints, laps, finish and time, the standings, the finish timeout +and the end of a race, and Cops and Robbers' gold, scores and places; a +client predicts its own checkpoints and gold pickup for its HUD, and the +host's word confirms or corrects them. The other players' 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 @@ -207,7 +211,8 @@ the cars' damage, version 5 the players' cars simulated by the host host), version 6 what the clients predict the shared traffic with (a rail car's acceleration, curvature and speed over the ground), up to 160 shared cars a message and no traffic hit reports, version 7 the host's props -(`PropState`, the `PropKnocks` event). +(`PropState`, the `PropKnocks` event), version 8 the host's rules (the rules +event; a player's own checkpoint, lap, finish and gold events refused). ### Handshake @@ -313,7 +318,9 @@ packet to the game or discovery port, and whatever answers as the router. second (burst 180), queues at most 256, ignores inputs for samples already applied or more than 240 ahead, holds every car before the start whatever its input says, and lets a command move a car only inside the city's box - (with 200 m to spare) and at most four times a second. A `CarStates` + (with 200 m to spare) and at most four times a second, and in a race a + respawn only at the start or at a checkpoint its referee counted for that + car (see "Rules"). A `CarStates` holds at most 16 cars; its own-car state must be a rotation matrix (entries within ±2), a position within ±16384 m and every other value finite and within ±10^7, or the whole message is refused. @@ -333,6 +340,16 @@ packet to the game or discovery port, and whatever answers as the router. placement checksum differs from its own, every placed prop (it then simulates its props itself). A `PropKnocks` event holds at most 512 knocks and counts only from the host. +* **Rules.** The host refuses a player's own word on a rule (its event + filter: `CheckpointReached`, `LapCompleted`, `RaceFinished`, the gold + events, Cops and Robbers' 0x8001-0x8003 and the rules event) and relays + none of it. A rules message holds at most 8 decisions, 256 waypoints, 16 + icons, results and scores, player ids other than 255, times up to 24 hours + (the did-not-finish), places and racers 1-16, finite positions within + ±16384 m and scores within 0-2^20; a client takes it from the host only, + for its own race and newer than the last, shows a decision once, and + rebuilds its waypoints from the list with its own rules (a waypoint the + rules refuse is dropped). * **Shared traffic.** An `AmbientState` holds at most 160 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 @@ -405,8 +422,10 @@ runs backwards. `Session::sendGameEvent(type, payload, target)` delivers reliably and in order. The host relays events. A host-side filter (`setEventFilter`) can validate or -drop them, for example to check that a checkpoint is plausible. Well-known -types, with payload structs in `Protocol.h`: +drop them; the game's (`game::hostAcceptsGameEvent`) refuses every player's +own word on a rule (see "Rules"). Well-known types, with payload structs in +`Protocol.h` (the checkpoint, lap, finish and gold ones are no longer sent +since protocol 8: the host decides them): `CheckpointReached{index, raceTime}`, `LapCompleted{lap, lapTime}`, `RaceFinished{raceTime, position}`, `GoldPickedUp/GoldDropped/GoldDelivered @@ -414,9 +433,9 @@ types, with payload structs in `Protocol.h`: `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: Cops and Robbers uses 0x8001 to 0x8003 (0x8003 the -host's word that a limit was reached, `mmMultiCR::SendLimitReached`: the -host alone checks the time and point limits, `mmMultiCR::UpdateLimit`), a +up are free for the game: Cops and Robbers used 0x8001 to 0x8003 until +protocol 8 (the pickup request, the places and the host's limit; refused +now), the host's rules message (`net::kRulesEvent`, see "Rules") is 0x8040, a car's damage (`VehicleDamageEvent`, see "Damage") 0x8020, the host's knocked props (`PropKnocksEvent`, see "Props") 0x8030 (0x8010, the shared traffic's hit report until protocol 6, is unused). @@ -578,8 +597,9 @@ every player's car on the host: acknowledges it: the first sample's placement (where that machine started the car), mmPlayer::Reset (a fall, the debug respawn), the `HitWaterHandler`s' respawn at a checkpoint and `vehCar::ClearDamage` - (Cops and Robbers' repairs). The client applies it at once and the host at - the same sample, so a reset is no correction. + (the wreck penalty's repair). The client applies it at once and the host at + the same sample, so a reset is no correction. (A Cops and Robbers + delivery's repair is the host's own since protocol 8, see "Rules".) * **The host** (`game::HostInputQueue` per client) starts a client's car once three of its inputs are in hand, applies one per sample, repeats the last one (without its keys) for a quarter of a second when the next is @@ -980,6 +1000,125 @@ knocked-over prop disappears when the 41st is knocked) is the host's on every machine. A client's predicted knock that the host does not make stands up again, and a client's pieces move to the host's place when they rest. +### Rules + +**MM2** decided a network race's rules on each player's own machine: +`mmWaypoints::Update` on its own car, its waypoint count in every position +packet (`mmGameMulti::SendPosition`, mmPlayer +0x2254) for the others' +standings (`mmGameMulti::UpdateScore`), and its finish time from its own +timer, sent to the host (`mmGameMulti::SendFinishReq`, 0x206). The host +acknowledged each finish to everyone (`SendFinishAck`, 0x1f7; a client's own +"finished in" line appears only then), kept the results (`SortResults`, +`UpdateResults`), armed the finish timeout at the first finish +(`mmMultiRace::SetTimeoutOn`: 60 s in a checkpoint race, 120 s in a +circuit), told everyone still racing when it ran out (0x1fe: "Race over" and +a did-not-finish) and took everyone to the results once every player was +counted (0x211) (`mmMultiRace` / `mmMultiCircuit` / `mmMultiBlitz:: +UpdateGame`, `GameMessage`). In Cops and Robbers (`mmMultiCR`) each machine +ran `ImpactCallback`, the wreck and water handlers, `UpdateGold` and +`UpdateBank` / `UpdateHideout` for its own car and told the others (0x259 a +drop, 600 a delivery); a pickup asked the host (0x25e), which granted it +while nobody carried the gold (0x25a) and drew the next places after a +delivery (`GetNewSet`, 0x261); the host alone checked the limits +(`UpdateLimit`, `SendLimitReached`). + +**OpenMM2** (protocol 8; the maintainer's decision: the host is the authority +for everything) decides them on the host from the cars it simulates (see +"Players' cars"). MM2's rules themselves are unchanged; only who decides is. + +* **The referee** (`game::session::RaceReferee`) runs mmWaypoints' rules + (`game::session::WaypointTracker`, the code a player's own session runs) + on every player's car after every physics sample (MM2: once a frame on + each machine; the same at 60 frames a second), and does the host's part of + mmMultiRace / Circuit / Blitz: the results by time, the finish timeout from + the first finish, 0x1fe to everyone still racing, the end once every + player still in the race is counted (0x211), a Blitz car out of time on its + own clock and the host's own Blitz time running out ending everyone's race + (0x1fe), and `mmGameMulti::UpdateScore`'s place ("Place: n/N") and icon + numbers as each player's machine would compute them. +* **Finish times** are the host's measure: the samples (1/60 s each) the host + simulated the car from the first one its player's input released it (that + player's Go at the shared start; a late loader's own Go) to the one it + crossed the line in. Every player is timed alike, on the session's clock, + and every machine shows the same time; MM2's machines each sent their own + timer's. +* **Cops and Robbers**: the host runs mmMultiCR's per-car rules for every car + (`CopsAndRobbers::updateHost`): a carrier's own damaging impact of 250 or + more from another player's car knocks the gold loose where it is and locks + the carrier out for 2 s; a wrecked car sits out 5 s and drops the gold; a + water handler (a reset that player's input asked for) sends the gold back + to its place; free gold within 5 m in the car's room goes to the first car + in order, with another player in the game; a carrier within 12 m of its + base delivers and the host draws the next places; then the limits. The + gold's mass, throttle cap and regeneration on the cars the host simulates + follow its decisions, whatever their inputs say, and it repairs a + deliverer's car itself (600). +* **The message** (`net/RulesState.h`; game event 0x8040, reliable and + ordered, from the host to one player) carries what the host decided since + its last message to that player, each decision numbered (`Finished` with + the time or a did-not-finish, `TimedOut`, `AllCounted`; `GoldTaken`, + `GoldDropped`, `GoldDelivered`, `NewSet`, `Limit`), and the state as it + stands: the last sample of that player's car the rules saw, then in a race + that car's waypoints hit (the newest 64), its place and the racers, the + other cars' icon numbers, the results so far, the timeout and the end; in + Cops and Robbers the gold's carrier and place, the set, every score and + the end. It goes at once when something happened for that player and four + times a second otherwise. A client shows each decision once, in order, and + a missed one (a late loader whose queue of events overflowed) is made good + by the state, silently. +* **A client predicts** only what the HUD must show at once, and the host's + word confirms or corrects it: + * a checkpoint its own car clears (`Session::applyNetProgress`): shown at + once (the sound, the split time, the lap message), stamped with the + car's sample. When the host's list has it, nothing more happens. When + the host has run `Session::kNetHitMargin` (30) of the car's samples past + it without counting it, it is taken back silently: the marker shows + again and the readout drops, with no sound. One the host counted that the + client did not see is shown when the word arrives. The client rebuilds + its waypoints from the host's list and its hits still on their way, with + the same rules, so nothing is shown twice. + * a gold pickup (`CopsAndRobbers::updatePredicted`): "You have the Gold!" + at once (MM2's client asked the host and waited for 0x25a). The host's + `GoldTaken` for it confirms it; one for another car undoes it and shows + that car's; the state undoes it once the host has seen the car 30 + samples past the pickup without granting it. + * nothing else: the finishes, the results, the standings, drops, + deliveries, scores, the new places and the limits are the host's word + only. A client's own "finished in" line (MM2's client line: 149, 106, + 95) shows the host's time when the host's decision arrives, as MM2's did + on 0x1f7, and its race clock stops at that time. Its car keeps driving + until then: braking at a predicted finish the host had not yet seen could + stop the host's car short of the line. +* **A player's own word is refused**: the host's event filter + (`game::hostAcceptsGameEvent`) drops `CheckpointReached`, `LapCompleted`, + `RaceFinished`, `GoldPickedUp`, `GoldDropped`, `GoldDelivered`, Cops and + Robbers' old messages 0x8001-0x8003 and the rules message from any player + but itself, before relaying them, so nobody sees them; a client takes the + rules message from the host only, for its own race, and newer than the + last. In a race the host also takes a client's water respawn + (`mmGameMulti::HitWaterHandler`: at the last checkpoint the car cleared) + only at the start or at a checkpoint it counted for that car + (`RaceReferee::mayRespawnAt`). +* **Players who join late or leave**: every player in the session when the + race is ordered is waited for. A player still loading has no car (it counts + in the standings only once it has a finish) and its time starts at its own + Go. A player who leaves the session or quits the race is no longer waited + for, and a finish it had stays in the results (the deviation under "Race + start"). In Cops and Robbers the host drops a leaver's gold where it is + (`SystemMessage` 0x2d). +* **Cruise** has no rules to decide: its wreck penalty follows the host's + damage of the car, and its water reset is a command the host applies (see + "Players' cars"). + +**Deviations** (docs/review/multiplayer-desync-rules.md): the host decides +for every machine; the rules run after every physics sample rather than +once a frame; the finish time is the host's measure in samples, not each +machine's own timer; in Cops and Robbers two cars reaching the gold in the +same frame are decided in car order (MM2: whichever request reached the host +first); a client shows its own pickup before the host's answer; the place +uses the host's view of each player's target (the Next / Prev. Checkpoint +keys stay on that player's machine) and positions. + ### Disconnect reasons `Left`, `VersionMismatch`, `ServerFull`, `BadPassword`, `GameInProgress`, @@ -1147,11 +1286,17 @@ When `config.multiplayer && ctx.netGame`: 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, - team)`, `sendCollision`, `sendDamage`. Read others' with `takeGameEvents()` - (`event.as()` etc.) to rank players and show messages. +5. **Rules** (`game::NetRules`, see "Rules"): every machine's session runs + with `SessionOptions::netRules`. The host feeds every player's car to + `NetRules::hostSample` after every physics sample (the sample hooks), its + Cops and Robbers decisions to `hostDecided`, and calls `hostUpdate` after + the frame's rules (its own player takes the referee's word there; each + other player's message goes with `sendEvent(net::kRulesEvent, payload, + player)`). A client passes the frame's events to `NetRules::receive` before + its rules, stamps its predicted checkpoints with `Session::setNetSample`, + and reports a predicted pickup with `predictedPickup`. Nobody sends + `sendCheckpoint`, `sendLap`, `sendFinish` or `sendGold` any more (the host + refuses them). Read other events with `takeGameEvents()`. 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 @@ -1200,7 +1345,8 @@ off the lobby says which UDP port must be opened by hand. `mp:mode:`, `mp:car:[:]` (what SELECT VEHICLE, a pick in the garage and its PREV do), `mp:traffic:[::]` (the -shared cruise traffic), and the pages `hostoptions`, `address`, `hostsettings`, +shared cruise traffic), `mp:race:[:]` (the host's race, after +`mp:mode`), 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 @@ -1242,7 +1388,17 @@ much later than its car's time (negative: earlier), for measuring the prediction over another horizon. The host's `nettraffic` statistics give the share of the police damage bits and the knocked cars' wheels, the client's how many cars it draws on their bodies' wheels, and the -per-car lines say which cars carry wheels. `OPENMM2_DEBUG_LOAD_DELAY_MS=` keeps a race's loading screen up +per-car lines say which cars carry wheels. `OPENMM2_DEBUG_AUTOPILOT=[,]` +drives this machine's car in a network race with MM2's racer AI +(`ai::Opponent`) along the race's opponent line `line` (from 0), at up to +that speed, through the car's inputs only (`game::NetAutopilot`: the AI's +frame runs on the car and the car is put back, keeping only the throttle, +brakes, wheel and a reverse through AUTO REVERSE), so automated runs drive +through the checkpoints; in Cops and Robbers it drives to the gold, then to +the car's base, over the roads and the last 80 m straight. +`OPENMM2_DEBUG_START_GOLD=` starts a Cops and Robbers car that far +from the first gold, facing it, each player on its own side. +`OPENMM2_DEBUG_LOAD_DELAY_MS=` keeps a race's loading screen up until that long after its loading began (the frames go on and the session is serviced): a slow loader for trying the race start; the race logs each machine's report, countdown and Go in session time ("race N: countdown from @@ -1280,6 +1436,18 @@ drawing's corrections, and a host and a client over a simulated 60 ± 20 ms link with 2 % loss, which prints the same-moment error) and `tests/game/test_net_traffic_cars.cpp` (a client's own knocks, confirmed, withdrawn, recycled). +The rules by `tests/game/test_net_race_rules.cpp` (the tracker taking +recorded hits as its gate test does; the referee timing each car from its +own release, timing a race out after its first finish, counting a circuit's +laps in order, ending a Blitz on each car's clock and the host's, ranking +every car as its machine would; Cops and Robbers on the host and a client's +predicted pickup confirmed or undone; a client's predicted checkpoint shown +once, taken back without a sound, one only the host counted shown once; its +finish, standings, timeout and end the host's; the host's message through +loss, duplicates, forgeries, junk and another race; a cheating player's +rule events reaching nobody through live sessions) and +`tests/net/test_rules_state.cpp` (the message, its limits, fixed-seed random +and mutated payloads). The players' cars by `tests/net/test_player_cars.cpp` (the messages, their limits, mutated messages, delivery and the input budget through real sessions), `tests/game/test_player_cars.cpp` (the pedals' bytes, the host's @@ -1327,6 +1495,14 @@ each: count; a client's are its prediction); * `C clock frameTime sample replayed dx dy dz dv snapped`: a correction of this machine's car by the host's state; +* the rules' lines (`game/net/RulesTrace.h`): `RS` a checkpoint this + machine's HUD showed for its own car (as predicted, or on the host's + word), `RX` one the host's word took back, `RH` the host's referee + counting a car's checkpoint (with the car's sample), `RF` a finish in this + machine's results, `RE` its "Place: n/N", `CG` and `CS` Cops and Robbers' + gold events and scores as it shows them; `netprobe rulesreport ...` compares them (docs/review/ + multiplayer-desync-rules.md); * the props' lines (lowercase tags, `game::PropTrace`): every placed prop once (`p`), every prop that broke loose here and what broke it (`k`), a prediction undone (`x`), every 250 ms of session time (`t`) the props down diff --git a/src/game/session/Session.cpp b/src/game/session/Session.cpp index 4548ed0..ef145ac 100644 --- a/src/game/session/Session.cpp +++ b/src/game/session/Session.cpp @@ -689,6 +689,13 @@ void Session::applyNetProgress(const NetProgress& progress, const Vec3& carPosit else if (!wp.finished) wp.closestTarget(m_checkpoints, carPosition); } + // A race this machine has ended (its Blitz clock, the host's timeout) + // stays ended, its finish marker hidden. + if (m_wp.stopped) { + wp.stopped = true; + if (!wp.visible.empty() && !m_wp.visible.empty()) + wp.visible.back() = m_wp.visible.back(); + } log::debug("session: the host's word on the waypoints after sample {}: {} hits ({} predicted)", progress.evaluated, hits.size(), m_netHits.size()); // The ones taken back: their markers show again, nothing is played. From 60ddfd0f650f0cc6b6e606a29e7e77dea98a3071 Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Fri, 9 Oct 2026 17:20:49 -0700 Subject: [PATCH 072/129] Run a client's corrected samples against the traffic where each sample met it A client now records, with the bodies around its car at each sample, the shared traffic's cars on their rails within 60 m, and puts them back there for each sample it runs again; a body a sample did not meet (a piece the host's state brought later) waits out of the way for that sample. Driving through the traffic on a busy street (two runs each, 75 s), the corrections over 10 cm were 3 and 60 with the cars where each sample met them, 34 and 89 where the host had them at each sample's time (TrafficClient::poseAt), and 194 and 205 where the frame put them, as before; OPENMM2_NET_TRAFFIC_REPLAY=predicted|frame keeps the other two for comparison. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/app/RaceScreen.cpp | 76 ++++++++++++++++++++++++++++++++---------- 1 file changed, 58 insertions(+), 18 deletions(-) diff --git a/src/app/RaceScreen.cpp b/src/app/RaceScreen.cpp index ab93851..d8e0f0f 100644 --- a/src/app/RaceScreen.cpp +++ b/src/app/RaceScreen.cpp @@ -5075,7 +5075,9 @@ class RaceScreen final : public Screen { // Where the bodies around its car (the other players' cars, the // police, knocked traffic cars and props) stood for this sample: the // samples run again meet them there. - m_bodyHistory.push_back({m_prediction.nextSeq(), bodyPoses()}); + m_bodyHistory.push_back({m_prediction.nextSeq(), bodyPoses(), {}}); + if (m_trafficClient && m_trafficBodies && netTrafficReplay() != NetTrafficReplay::Frame) + m_bodyHistory.back().rails = m_trafficBodies->railPoses(m_player->sim().body.ics.matrix.m3, 60.0f); while (m_bodyHistory.size() > 240) m_bodyHistory.pop_front(); // Its car's first sample puts it where this machine started it, on @@ -5135,6 +5137,23 @@ class RaceScreen final : public Screen { m_netCarLead = m_netCarLead ? *m_netCarLead + (lead - *m_netCarLead) * 0.1 : lead; } + // Client: where the samples run again meet the shared traffic's cars on + // their rails: where each sample met them (History), or where the host + // had them at that sample's time (Predicted: TrafficClient::poseAt, from + // the newest messages), or where this frame put them (Frame). + // OPENMM2_NET_TRAFFIC_REPLAY=predicted|frame picks the others, for + // comparison. + enum class NetTrafficReplay { History, Predicted, Frame }; + static NetTrafficReplay netTrafficReplay() { + static const NetTrafficReplay mode = [] { + const char* v = std::getenv("OPENMM2_NET_TRAFFIC_REPLAY"); + const std::string_view s = v ? v : ""; + return s == "frame" ? NetTrafficReplay::Frame + : s == "predicted" ? NetTrafficReplay::Predicted : NetTrafficReplay::History; + }(); + return mode; + } + // Client: the session time sample `seq` of this machine's car ended at // (m_netSampleTimes keeps each frame's last). std::optional netSampleTime(std::uint32_t seq) const { @@ -5201,25 +5220,29 @@ class RaceScreen final : public Screen { std::vector companions; predictNearCars(newest->near, companions); // The shared traffic's cars on their rails around this car: the - // samples run again meet each where it was at that sample's time on - // the host (TrafficClient::poseAt), as the frames placed them; after, - // where this frame put them. (OPENMM2_NET_TRAFFIC_REPLAY=frame keeps - // them where this frame put them, the comparison.) - static const bool railsHeld = [] { - const char* v = std::getenv("OPENMM2_NET_TRAFFIC_REPLAY"); - return v && std::string_view(v) == "frame"; - }(); + // samples run again meet each where that sample met it (or, the + // experiment, where the host had it at that sample's time); after, + // where this frame put them (netTrafficReplay). + const NetTrafficReplay railReplay = netTrafficReplay(); std::vector railsNow; - if (m_trafficClient && m_trafficBodies && !railsHeld) + if (m_trafficClient && m_trafficBodies && railReplay != NetTrafficReplay::Frame) railsNow = m_trafficBodies->railPoses(m_player->sim().body.ics.matrix.m3, 60.0f); - // The bodies the samples run again may move, as they stand now. + // The bodies the samples run again may move, as they stand now: the + // ones a sample met, and the ones around the car now (a sample that + // did not meet one, say a piece the host's state brought later, runs + // without it: it waits far below the city). std::vector now; + const auto keep = [&](phys::Body* b) { + if (std::ranges::none_of(now, [&](const BodyPose& q) { return q.body == b; }) && + m_world->contains(b) && !predictedBody(b)) + now.push_back(poseOf(*b)); + }; for (const auto& h : m_bodyHistory) if (h.seq > ack) for (const auto& p : h.poses) - if (std::ranges::none_of(now, [&](const BodyPose& q) { return q.body == p.body; }) && - m_world->contains(p.body) && !predictedBody(p.body)) - now.push_back(poseOf(*p.body)); + keep(p.body); + for (const auto& p : bodyPoses()) + keep(p.body); const auto replayStart = std::chrono::steady_clock::now(); m_netReplaying = true; const auto c = m_prediction.acknowledge( @@ -5238,10 +5261,17 @@ class RaceScreen final : public Screen { rv.sim->sim().resets); }, [&](std::uint32_t seq) { - for (const auto& h : m_bodyHistory) - if (h.seq == seq) - placeBodies(h.poses); - if (railsNow.empty()) + for (const auto& h : m_bodyHistory) { + if (h.seq != seq) + continue; + placeBodies(h.poses); + for (const auto& p : now) + if (std::ranges::none_of(h.poses, [&](const BodyPose& q) { return q.body == p.body; })) + parkBody(p); + if (!railsNow.empty() && railReplay == NetTrafficReplay::History) + m_trafficBodies->placeRailCars(h.rails); + } + if (railsNow.empty() || railReplay != NetTrafficReplay::Predicted) return; const auto t = netSampleTime(seq); if (!t) @@ -5318,6 +5348,15 @@ class RaceScreen final : public Screen { out.push_back(poseOf(*b)); return out; } + // A body a sample run again did not meet, out of its way (1 km below). + void parkBody(const BodyPose& p) { + phys::Body& b = *p.body; + b.ics.matrix = p.ics; + b.ics.matrix.m3.y -= 1000.0f; + b.boundMatrix = p.bound; + b.boundMatrix.m3.y -= 1000.0f; + b.ics.linearVelocity = b.ics.angularVelocity = b.kinematicVelocity = b.kinematicSpin = {}; + } void placeBodies(const std::vector& poses) { for (const auto& p : poses) { if (!m_world->contains(p.body) || predictedBody(p.body)) @@ -6538,6 +6577,7 @@ class RaceScreen final : public Screen { struct BodyHistory { std::uint32_t seq = 0; std::vector poses; + std::vector rails; // the shared traffic's cars on their rails }; std::deque m_bodyHistory; // client: the bodies around its car at each recent sample std::deque m_netWaiting; // client: the host's latest counts of its inputs in hand From 12fe633dcf380a38a1422ff5afc9eec25eb28506 Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Fri, 9 Oct 2026 17:21:30 -0700 Subject: [PATCH 073/129] Wrap two long lines Co-Authored-By: Claude Opus 5.5 (1M context) --- src/app/RaceScreen.cpp | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/app/RaceScreen.cpp b/src/app/RaceScreen.cpp index d8e0f0f..5fee304 100644 --- a/src/app/RaceScreen.cpp +++ b/src/app/RaceScreen.cpp @@ -5077,7 +5077,8 @@ class RaceScreen final : public Screen { // samples run again meet them there. m_bodyHistory.push_back({m_prediction.nextSeq(), bodyPoses(), {}}); if (m_trafficClient && m_trafficBodies && netTrafficReplay() != NetTrafficReplay::Frame) - m_bodyHistory.back().rails = m_trafficBodies->railPoses(m_player->sim().body.ics.matrix.m3, 60.0f); + m_bodyHistory.back().rails = + m_trafficBodies->railPoses(m_player->sim().body.ics.matrix.m3, 60.0f); while (m_bodyHistory.size() > 240) m_bodyHistory.pop_front(); // Its car's first sample puts it where this machine started it, on @@ -5265,9 +5266,11 @@ class RaceScreen final : public Screen { if (h.seq != seq) continue; placeBodies(h.poses); - for (const auto& p : now) - if (std::ranges::none_of(h.poses, [&](const BodyPose& q) { return q.body == p.body; })) + for (const auto& p : now) { + const auto met = [&](const BodyPose& q) { return q.body == p.body; }; + if (std::ranges::none_of(h.poses, met)) parkBody(p); + } if (!railsNow.empty() && railReplay == NetTrafficReplay::History) m_trafficBodies->placeRailCars(h.rails); } From 8c0a8d1bf1adc1d40fba81ad19611967cb18f16d Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Fri, 9 Oct 2026 17:24:23 -0700 Subject: [PATCH 074/129] Test the rail cars standing elsewhere for a replay Co-Authored-By: Claude Opus 5.5 (1M context) --- tests/game/test_traffic_bodies.cpp | 41 ++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/tests/game/test_traffic_bodies.cpp b/tests/game/test_traffic_bodies.cpp index 659d5c8..48363db 100644 --- a/tests/game/test_traffic_bodies.cpp +++ b/tests/game/test_traffic_bodies.cpp @@ -14,6 +14,7 @@ #include #include #include +#include #include #include @@ -283,3 +284,43 @@ TEST(TrafficBodies, ThirtyThirdCarTakesTheFirstBody) { scene.traffic->instancesIn(1, listed); EXPECT_NE(std::ranges::find(listed, rail[0]), listed.end()); } + +// OpenMM2 (a network client running its car's samples again): the cars on +// their rails near a place, and those put elsewhere for a while, their room +// lists following, then back. +TEST(TrafficBodies, RailCarsStandElsewhereForAReplayAndComeBack) { + MM2_REQUIRE_GAME_DATA(); + const vfs::Vfs& vfs = *test::gameData(); + auto city = city::loadCity(vfs, "london"); + ASSERT_TRUE(city); + auto ai = londonTraffic(vfs, *city); + ASSERT_TRUE(ai); + const ai::AmbientCar* target = pickTarget(*ai, 0.0f); + ASSERT_TRUE(target); + const int id = target->id; + Scene scene(*ai, target->transform.m3); + scene.traffic->beforeStep(); + const auto near = scene.traffic->railPoses(target->transform.m3, 30.0f); + const auto it = std::ranges::find(near, id, &game::TrafficBodies::RailPose::id); + ASSERT_NE(it, near.end()); + EXPECT_EQ(it->transform.m3, target->transform.m3); + for (const auto& r : near) + EXPECT_LE(r.transform.m3.dist(target->transform.m3), 30.0f); + // Moved 3 m along its lane for a sample run again, then back. + game::TrafficBodies::RailPose moved = *it; + moved.transform.m3 = moved.transform.m3 - moved.transform.m2 * 3.0f; + scene.traffic->placeRailCars(std::span(&moved, 1)); + const auto there = [&](const Vec3& at) { + std::vector list; + scene.traffic->instancesIn(1, list); + return std::ranges::any_of(list, [&](const phys::Instance* i) { return i->matrix().m3 == at; }); + }; + EXPECT_TRUE(there(moved.transform.m3)); + scene.traffic->placeRailCars(near); + EXPECT_TRUE(there(target->transform.m3)); + EXPECT_FALSE(there(moved.transform.m3)); + // A car the AI no longer lists is left alone. + game::TrafficBodies::RailPose unknown{100000, moved.transform}; + scene.traffic->placeRailCars(std::span(&unknown, 1)); + EXPECT_FALSE(there(moved.transform.m3)); +} From 657a493521fb176b36da796148e44c46a5b24fa5 Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Fri, 9 Oct 2026 17:31:42 -0700 Subject: [PATCH 075/129] Tell the report which counted hits a HUD shows A checkpoint race's finish clears no waypoint (mmWaypoints::Update sets the race done without DisplayHUDMessage), so no HUD shows it; the referee now notes for each hit whether it cleared one, the RH trace line carries it, and rulesreport compares the HUD with those only. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/game/net/NetRules.cpp | 2 +- src/game/net/RulesTrace.cpp | 6 +++--- src/game/net/RulesTrace.h | 9 +++++---- src/game/session/RaceReferee.cpp | 2 ++ src/game/session/RaceReferee.h | 1 + tools/netprobe/RulesReport.cpp | 9 ++++++--- 6 files changed, 18 insertions(+), 11 deletions(-) diff --git a/src/game/net/NetRules.cpp b/src/game/net/NetRules.cpp index 5156275..efdecd3 100644 --- a/src/game/net/NetRules.cpp +++ b/src/game/net/NetRules.cpp @@ -271,7 +271,7 @@ void NetRules::hostUpdate(float dt, std::uint64_t nowMs, const Vec3& ownPosition std::size_t& traced = m_tracedHits[id]; for (; traced < p.hits.size(); ++traced) traceRuleHostHit(m_trace, m_frameTime, id, p.hitSamples[traced], p.hits[traced], - static_cast(traced) + 2, p.wp.lap); + static_cast(traced) + 2, p.wp.lap, p.hitShown[traced] != 0); } // The host's own player takes the referee's word as a client does, // with no delay. diff --git a/src/game/net/RulesTrace.cpp b/src/game/net/RulesTrace.cpp index d3ecce2..e7364e2 100644 --- a/src/game/net/RulesTrace.cpp +++ b/src/game/net/RulesTrace.cpp @@ -24,10 +24,10 @@ void traceRuleTakenBack(std::FILE* f, double frame, int player, int index) { } void traceRuleHostHit(std::FILE* f, double frame, int player, std::uint32_t sample, int index, int count, - int lap) { + int lap, bool shown) { if (f) - std::fprintf(f, "RH %.3f %.3f %d %u %d %d %d\n", machineMs(), frame, player, sample, index, count, - lap); + std::fprintf(f, "RH %.3f %.3f %d %u %d %d %d %d\n", machineMs(), frame, player, sample, index, count, + lap, shown ? 1 : 0); } void traceRuleFinish(std::FILE* f, double frame, int player, std::uint32_t ms) { diff --git a/src/game/net/RulesTrace.h b/src/game/net/RulesTrace.h index f698872..39f6440 100644 --- a/src/game/net/RulesTrace.h +++ b/src/game/net/RulesTrace.h @@ -14,9 +14,10 @@ // host's word brought it // RX clock frame player index // the host's word took a waypoint this machine had shown back -// RH clock frame player sample index count lap -// host: its referee counted player's car clearing `index` after the -// car's sample `sample` +// RH clock frame player sample index count lap shown +// host: its referee counted player's car hitting `index` after the +// car's sample `sample` (shown 0: a checkpoint race's finish, which +// clears no waypoint the HUD shows) // RF clock frame player ms // this machine's results took player's finish (ms 86400000: did not) // RE clock frame place racers @@ -40,7 +41,7 @@ namespace mm2::game { void traceRuleShown(std::FILE* f, double frame, int player, int index, int count, int kind); void traceRuleTakenBack(std::FILE* f, double frame, int player, int index); void traceRuleHostHit(std::FILE* f, double frame, int player, std::uint32_t sample, int index, int count, - int lap); + int lap, bool shown); void traceRuleFinish(std::FILE* f, double frame, int player, std::uint32_t ms); void traceRuleStanding(std::FILE* f, double frame, int place, int racers); void traceCopsEvent(std::FILE* f, double frame, int type, int car, int value, const Vec3& gold); diff --git a/src/game/session/RaceReferee.cpp b/src/game/session/RaceReferee.cpp index 7587f34..f99a959 100644 --- a/src/game/session/RaceReferee.cpp +++ b/src/game/session/RaceReferee.cpp @@ -70,6 +70,8 @@ void RaceReferee::sample(std::uint8_t id, std::uint32_t seq, const Mat34& car, c if (p.wp.apply(m_config.checkpoints, index, car.m3, steps)) { p.hits.push_back(static_cast(index)); p.hitSamples.push_back(seq); + p.hitShown.push_back(std::ranges::any_of( + steps, [](const WaypointStep& s) { return s.kind == WaypointStep::Kind::Cleared; })); } } // mmMultiRace / mmMultiCircuit / mmMultiBlitz::UpdateGame state 3. A diff --git a/src/game/session/RaceReferee.h b/src/game/session/RaceReferee.h index 26b0414..70d7bb7 100644 --- a/src/game/session/RaceReferee.h +++ b/src/game/session/RaceReferee.h @@ -64,6 +64,7 @@ class RaceReferee { WaypointTracker wp; std::vector hits; // the waypoints its car hit, in order std::vector hitSamples; // the sample of each + std::vector hitShown; // each cleared a waypoint (the HUD shows it) bool inRace = true; // in the session and not quit: waited for bool placed = false; // its car is in the world (the host simulates it) bool released = false; diff --git a/tools/netprobe/RulesReport.cpp b/tools/netprobe/RulesReport.cpp index 2035e81..84f4bdd 100644 --- a/tools/netprobe/RulesReport.cpp +++ b/tools/netprobe/RulesReport.cpp @@ -43,7 +43,7 @@ struct HostHit { double clock = 0, frame = 0; int player = 0; unsigned sample = 0; - int index = 0, count = 0, lap = 0; + int index = 0, count = 0, lap = 0, shown = 1; }; struct Finish { double clock = 0; @@ -127,8 +127,10 @@ bool load(const std::string& path, Machine& m) { } } else if (tag == "RH") { HostHit h{clock, frame}; - if (s >> h.player >> h.sample >> h.index >> h.count >> h.lap) + if (s >> h.player >> h.sample >> h.index >> h.count >> h.lap) { + s >> h.shown; m.hostHits.push_back(h); + } } else if (tag == "RF" || tag == "RG") { int player = 0; unsigned ms = 0; @@ -192,8 +194,9 @@ void checkpoints(const Machine& host, const Machine& m, double endClock) { } std::vector counted; std::vector hostHits; + // (A checkpoint race's finish clears no waypoint the HUD shows.) for (const auto& h : host.hostHits) - if (h.player == m.self) { + if (h.player == m.self && h.shown) { counted.push_back(h.index); hostHits.push_back(&h); } From baf989ae22b172b981bc2501812b72334811d66b Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Fri, 9 Oct 2026 17:44:58 -0700 Subject: [PATCH 076/129] Let automated Cops and Robbers runs draw the same first places OPENMM2_DEBUG_CR_SEED= (a development aid, set on every machine) seeds the first places with n instead of the race's order time, so runs can be compared. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/app/RaceScreen.cpp | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/app/RaceScreen.cpp b/src/app/RaceScreen.cpp index 00e1826..c4d3922 100644 --- a/src/app/RaceScreen.cpp +++ b/src/app/RaceScreen.cpp @@ -2531,6 +2531,10 @@ class RaceScreen final : public Screen { // host ordered the race seeds them, which every machine knows when it // loads; the host's sets follow by message). st.seed = std::max(1u, ctx.netGame->raceOrderTime()); + // Development aid: OPENMM2_DEBUG_CR_SEED= on every machine draws the + // same first places in every run (automated runs). + if (const char* seed = std::getenv("OPENMM2_DEBUG_CR_SEED")) + st.seed = std::max(1u, static_cast(str::parseInt(seed).value_or(1))); m_crRng = st.seed; // GetRandomPoints' picker: mmGame::RespawnXYZ(false, false, false) // less its 2 m: an intersection whose room (FindRoomId of its From 156908fb7f8911089278c6711dc8f4cff5b05dff Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Fri, 9 Oct 2026 17:56:24 -0700 Subject: [PATCH 077/129] Count corrections a minute of the whole race netprobe syncreport divided the corrections by the span they fell in, so a burst at the end of a calm race read as hundreds a minute. The sync review's S2 and S3 rows note the near cars a client now simulates. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/review/multiplayer-sync.md | 4 ++-- tools/netprobe/SyncReport.cpp | 6 +++++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/docs/review/multiplayer-sync.md b/docs/review/multiplayer-sync.md index 81d7a9b..dd7f51b 100644 --- a/docs/review/multiplayer-sync.md +++ b/docs/review/multiplayer-sync.md @@ -181,8 +181,8 @@ a cruise never took them, so they piled up (also capped by d7fb35b). | # | 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. | fixed in 5b27203 (rendering, docs/rendering.md "Drawing between simulation steps"): every simulated object is drawn between its last two steps (`game::StepHistory`, blended by `interpolationAlpha`), and the remote cars and a client's shared traffic from a sample one step further back, where that scene is; at 144 fps the host's car as a client draws it moves by 1.5 mm (median second difference per frame) instead of 112 mm. | -| 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`). | superseded (protocol 5, docs/review/multiplayer-desync-cars.md): the host simulates every player's car, so collisions between players happen there once and both cars give way. | -| 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, the maintainer's choice for the host-authoritative model too); measured with the others placed ahead instead in docs/review/multiplayer-desync-cars.md. | +| 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`). | superseded (protocol 5, docs/review/multiplayer-desync-cars.md): the host simulates every player's car, so collisions between players happen there once and both cars give way; since protocol 9 a client also simulates the players' cars near its own (its own collisions with them give way too, as on the host). | +| 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, the maintainer's choice for the host-authoritative model too); measured with the others placed ahead instead in docs/review/multiplayer-desync-cars.md. Since protocol 9 the cars near a client's are simulated there at its own car's time (they meet where the host's cars meet); the farther ones are still shown in the past. | | 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). | fixed in 1523740 (docs/multiplayer.md "Damage"): the network car's CurrentDamage follows the snapshot's level and `vehCarDamage::Update` makes its smoke, exhaust and tyre wobble; what the owner's `vehCarDamage::ApplyImpact` painted and broke travels as a reliable `VehicleDamage` event (each texel patch's point and random state, the parts, the impacts' sparks, shards and sound, a new epoch on every reset) and is replayed at the time it happened, so both machines show the same dents texel for texel (two instances, same-moment screenshots; test `DamageSync.ReplayedDentsMatchTheOwnersTexelForTexel`). The shared police get the same, and a client's knocked traffic cars their wheels. Deviation: a machine no longer paints its own collisions of a network car (MM2's did). Open: no tyre tracks or wheel particles on network cars. | | 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. | fixed in 276e508 (docs/review/multiplayer-desync-cars.md C3): the host alone ends the game and tells the others. | diff --git a/tools/netprobe/SyncReport.cpp b/tools/netprobe/SyncReport.cpp index 24f8497..ea71e23 100644 --- a/tools/netprobe/SyncReport.cpp +++ b/tools/netprobe/SyncReport.cpp @@ -406,7 +406,11 @@ int syncReport(const SyncReportOptions& o) { over10cm += d > 0.1 ? 1 : 0; snaps += c.snapped ? 1 : 0; } - const double span = (cs.back().wall - cs.front().wall) / 60000.0; + // A minute of the race as the machine drew it (not of the span the + // corrections fall in, which a burst would shrink). + double span = (cs.back().wall - cs.front().wall) / 60000.0; + if (const auto own = ms[m].drawn.find(ms[m].self); own != ms[m].drawn.end() && !own->second.empty()) + span = std::max(span, (own->second.back().wall - own->second.front().wall) / 60000.0); std::println(" {:<10} {} corrections ({:.1f} a minute), median {:.4f}, p90 {:.4f}, p99 {:.4f}, " "max {:.3f}; {} over 10 cm, {} over 1 m, {} snapped; steps replayed median {:.0f}, " "max {:.0f}", From 4ac627b5c170a2e46d5a0429ec22206fe8636fe0 Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Fri, 9 Oct 2026 18:18:28 -0700 Subject: [PATCH 078/129] Let a client wait for the host when the gold is contested A client predicting its own pickup while another car was at the gold too showed "You have the Gold!" and took it back a round trip later whenever the host gave the gold to the other car (its own car comes first, as MM2's host's did). With another car within the gold's 5 m as this machine has it, the client now waits for the host's word, as MM2's client always did (0x25e, 0x25a); an uncontested pickup still shows at once. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/multiplayer.md | 4 +++- src/game/session/CopsAndRobbers.cpp | 5 +++++ src/game/session/CopsAndRobbers.h | 5 +++-- tests/game/test_net_race_rules.cpp | 4 ++++ 4 files changed, 15 insertions(+), 3 deletions(-) diff --git a/docs/multiplayer.md b/docs/multiplayer.md index d0c414b..3e82378 100644 --- a/docs/multiplayer.md +++ b/docs/multiplayer.md @@ -1397,7 +1397,9 @@ brakes, wheel and a reverse through AUTO REVERSE), so automated runs drive through the checkpoints; in Cops and Robbers it drives to the gold, then to the car's base, over the roads and the last 80 m straight. `OPENMM2_DEBUG_START_GOLD=` starts a Cops and Robbers car that far -from the first gold, facing it, each player on its own side. +from the first gold, facing it, the two first players on opposite sides, +and `OPENMM2_DEBUG_CR_SEED=` (on every machine) draws the first places +from n rather than the race's order time, so runs can be compared. `OPENMM2_DEBUG_LOAD_DELAY_MS=` keeps a race's loading screen up until that long after its loading began (the frames go on and the session is serviced): a slow loader for trying the race start; the race logs each diff --git a/src/game/session/CopsAndRobbers.cpp b/src/game/session/CopsAndRobbers.cpp index 757fda4..773ad52 100644 --- a/src/game/session/CopsAndRobbers.cpp +++ b/src/game/session/CopsAndRobbers.cpp @@ -450,6 +450,11 @@ bool CopsAndRobbers::updatePredicted(float dt, const Car& me, const std::vector< m_goldPos = c.position + Vec3{0.0f, 2.0f, 0.0f}; if (m_over || m_pending || players < 2 || !canTake(me)) return false; + // Another car at the gold too: the host decides between them (its own + // car first), so this machine waits for its word as MM2's did. + for (const auto& c : cars) + if (c.id != me.id && c.position.dist2(m_goldPos) < kGoldRadius * kGoldRadius) + return false; // UpdateGold on a machine that is not the host: MM2 asks the host // (0x25e) and shows the gold taken when the host's 0x25a arrives; // OpenMM2 shows it at once and the host's decision confirms it. diff --git a/src/game/session/CopsAndRobbers.h b/src/game/session/CopsAndRobbers.h index 2706536..68125fb 100644 --- a/src/game/session/CopsAndRobbers.h +++ b/src/game/session/CopsAndRobbers.h @@ -165,8 +165,9 @@ class CopsAndRobbers { // its own car `me` taking free gold within reach (UpdateGold's test, the // car not locked out by a wreck or a lost gold): a prediction, shown at // once (GoldTaken), which the host's decision confirms or undoes - // (applyHost, adopt). `players` counts the players in the game. Returns - // true when it predicted a pickup. + // (applyHost, adopt); not while another car is at the gold too (the host + // decides between them). `players` counts the players in the game. + // Returns true when it predicted a pickup. bool updatePredicted(float dt, const Car& me, const std::vector& cars, int players); // The host's decision (a Message from updateHost) on a machine that is // not the host: as receive(), except that a predicted pickup it confirms diff --git a/tests/game/test_net_race_rules.cpp b/tests/game/test_net_race_rules.cpp index 337089f..c81027b 100644 --- a/tests/game/test_net_race_rules.cpp +++ b/tests/game/test_net_race_rules.cpp @@ -422,6 +422,10 @@ TEST(NetRaceRules, AClientsPredictedPickupIsConfirmedOrUndone) { // Alone in the game a car takes nothing (UpdateGold's other players). CopsAndRobbers f = client(); EXPECT_FALSE(f.updatePredicted(0.1f, me, {me}, 1)); + // With another car at the gold too, it waits for the host's word. + const CopsAndRobbers::Car other{0, CrTeam::Robber, f.set().gold + Vec3{2.0f, 0.0f, 0.0f}, false, false}; + EXPECT_FALSE(f.updatePredicted(0.1f, me, {me, other}, 2)); + EXPECT_EQ(f.goldCarrier(), -1); } // --- The message ---------------------------------------------------------------------------- From 04112e1c06eeff2f33f93b5c1065a9348ce18cd5 Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Fri, 9 Oct 2026 18:36:23 -0700 Subject: [PATCH 079/129] Record the second round of the players' cars' desync The review now holds the second round: what the shunts showed (most of the divergence is the delay at the cars' speed; the past image; the order; what the state left out), what changed, two runs of every scenario on 0.3.1, the first round and this one, the resets and the traffic in replays, and the findings' statuses (O1, O4, O7 fixed; C6-C8, O8, O9 new). docs/multiplayer.md describes the near cars, the order, the host's reset rules, protocol 9 and the new switches; the parity list gains the players' cars. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/multiplayer.md | 122 +++++++++---- docs/parity/openmm2-only.md | 16 ++ docs/review/multiplayer-desync-cars.md | 239 ++++++++++++++++++++++++- 3 files changed, 338 insertions(+), 39 deletions(-) diff --git a/docs/multiplayer.md b/docs/multiplayer.md index b500f73..a67c727 100644 --- a/docs/multiplayer.md +++ b/docs/multiplayer.md @@ -207,7 +207,9 @@ the cars' damage, version 5 the players' cars simulated by the host host), version 6 what the clients predict the shared traffic with (a rail car's acceleration, curvature and speed over the ground), up to 160 shared cars a message and no traffic hit reports, version 7 the host's props -(`PropState`, the `PropKnocks` event). +(`PropState`, the `PropKnocks` event), version 9 the players' cars near a +client's in full in `CarStates` and, in every car's full state, what a +sample hands the next (8 is the rules'). ### Handshake @@ -576,10 +578,22 @@ every player's car on the host: * **Commands.** What the client's rules decide about its car travels with the inputs, numbered with the sample it applies at, until the host acknowledges it: the first sample's placement (where that machine - started the car), mmPlayer::Reset (a fall, the debug respawn), the - `HitWaterHandler`s' respawn at a checkpoint and `vehCar::ClearDamage` - (Cops and Robbers' repairs). The client applies it at once and the host at - the same sample, so a reset is no correction. + started the car), mmPlayer::Reset (the water, a fall, the debug respawn), + the `HitWaterHandler`s' respawn at a checkpoint and `vehCar::ClearDamage` + (a wreck's repair, Cops and Robbers' repairs). The client applies it at + once and the host at the same sample, so a reset is no correction. The + host carries one out only where the game's rules would + (`game::ResetRules`, on its own simulation of the car): the placement only + first and in the city; a reset or a respawn only after nearly five seconds + in the water (the car's `vehSplash` latched; `mmGameMulti:: + HitWaterHandler`) or below -50 m (`DropThruCityHandler`, which + multiplayer treats as the water), a respawn only at one of the race's + checkpoints facing its heading, and not more than four a second; a repair + only for a car past its maximum damage (the wreck penalty's) or in Cops + and Robbers (until its rules run on the host). A refused command leaves + the car where the host has it, and the client's is corrected back + (`OPENMM2_DEBUG_RESPAWN_MS` on the host lets any reset in the city pass, + for development). * **The host** (`game::HostInputQueue` per client) starts a client's car once three of its inputs are in hand, applies one per sample, repeats the last one (without its keys) for a quarter of a second when the next is @@ -597,42 +611,70 @@ every player's car on the host: state after it at full precision (the body's matrix, momenta, velocities and last push; the wheels' spin, turn, springs and tyre deflections; the engine, gearbox, drivetrains, stuck watcher, damage and random stream; the - pedal swap and the hold: about 270 bytes), and every other player's car - as a `VehicleSnapshot`. + pedal swap and the hold; and what a sample hands the next: the force and + torque the wheels and engine set for it, each tyre's rolling resistance, + and in a contact the impulses and pushes: about 310 bytes, 370 in a + contact), every other player's car as a `VehicleSnapshot`, and the two + nearest other players' cars within 40 m of the client's (kept to 50 m; + not one towing a trailer) in full with the input the host last applied to + them (`net::NearCarState`). * **Prediction** (`game::CarPrediction`). A client runs its car on its inputs at once and keeps each sample's input and the car's whole state after it (`CarSim::saveState`, 3 s). When a host state for a sample differs from the prediction for it (by 3 mm, 3 cm/s, 0.0015 in the matrix, or in the damage, gear or hold), the client puts the car back to its saved state for that sample with the host's on top and runs the later - samples again on their inputs (`phys::World::replaySample`: the car alone, - everything it touches held still and moving at its own velocity, every - body within 40 m of it (the other players' cars, the police, knocked - traffic cars and props) where each sample first met it; the traffic cars - on their rails where the frame placed them; no sound or effect), at most - 120 samples. The drawing keeps the car where it was and eases it + samples again on their inputs (`phys::World::replaySample`: the car and + the other players' cars it simulates, everything else held still and + moving at its own velocity, every body within 40 m of it (the other + players' cars placed at their states, the police, knocked traffic cars + and props) where each sample first met it, and one that sample did not + meet out of the way; the shared traffic's cars on their rails where each + sample met them; no sound or effect), at most 120 samples. The drawing keeps the car where it was and eases it onto the corrected place with a 60 ms half-life (`game::CorrectionBlend`; more than 4 m is a jump, drawn at once). The client's simulation runs up to 3% faster when the host had fewer than one of its inputs in hand over the last second and 2% slower above three, which keeps its inputs a sample or three ahead of the host's need. -* **The other cars** on a client are drawn interpolated from the host's - states a playout delay in the past, as before, and its own car collides - with them there as kinematic bodies moving at their velocity; the host's - collision is the one that counts. (`OPENMM2_NET_OTHERS`, a development - aid, places them ahead instead; see - `docs/review/multiplayer-desync-cars.md`.) +* **The other cars near** a client's car (the ones its `CarStates` carries + in full) are simulated there along with its own: at every state the + client puts them to the host's state at the acknowledged sample and runs + them with its own car through the later samples (`CarPrediction`'s + companions), on the input the host last applied to them, and on between + states. Its car then meets them where the host's does and both give way + by their masses, as on the host; only the other player's change of input + since that state is unknown. Farther ones (by their distance and how fast + they close in the samples run again) run again without the client's car, + which stays where each sample had it. Every machine keeps the players' + cars in the world's movers in player order (the host's first), and a + replay runs them in that order: two cars collide in that order, and the + order changes the outcome. They are drawn between their last two samples + with what each state moves them by eased away (80 ms half-life), and the + switch between simulating one and placing it at its states eased over + 150 ms. +* **The other cars farther away** on a client are drawn interpolated from + the host's states a playout delay in the past, as before, and its own car + collides with them there as kinematic bodies moving at their velocity; the + host's collision is the one that counts. (`OPENMM2_NET_OTHERS`, a + development aid, places every other car ahead, or keeps all of them at + their states; see `docs/review/multiplayer-desync-cars.md`.) * **The host's own car** has no latency and no prediction. Measured through `netprobe relay` (60 ± 20 ms each way, 2% loss, reordering), a host and a client ramming each other: the client's car was -corrected about 4 times a second, by 4 cm at the median, 1 m or more about -4 times a minute (at collisions); the host never ran short of its inputs. -The inputs cost a client about 0.9 KB/s of payload (3.3 KB/s on the wire), -the states about 0.3 KB/s per client plus 35 bytes per other car per -message (6 KB/s on the wire for two players; about 9 KB/s per client and -60 KB/s for the host with eight). See -`docs/review/multiplayer-desync-cars.md` for the measurements against 0.3.1. +corrected 3-7 times a second, by 1-3 cm at the median and at most 33 times +a run over 10 cm, never over 1 m; shunting from behind, at most 7 times +over 1 m a run, and never drawn jumping over 1 m (the first round, without +the near cars: up to 53); the host never ran short of its inputs. Every +machine draws another player's car about 150 ms from its player's screen +(the host behind it, a client behind a far car and ahead of a near one), +as 0.3.1 did; at that delay a few centimetres remain at the median. The +inputs cost a client about 0.9 KB/s of payload (3.3 KB/s on the wire), the +states about 0.4 KB a message per client, 0.7 KB with a near car (8 KB/s +of payload for two players apart, 14 KB/s near; with eight players at most +about 22 KB/s per client and 160 KB/s for the host, about 11 and 78 KB/s +apart). See `docs/review/multiplayer-desync-cars.md` for the measurements +against 0.3.1. ### Shared traffic @@ -1214,9 +1256,16 @@ the last (`-1.png`, ...) and ends at the last. For the damage: `OPENMM2_DEBUG_NETCARS` logs every correction of a client's car (how far the prediction was off, how many samples ran again) and every 10 s what the players' cars cost and how often the host ran short of a client's inputs; -`OPENMM2_NET_OTHERS=ahead` (an experiment) places and draws the other -players' cars where the host will have them when it runs this machine's -sample, `=ghost` keeps this machine's car from touching them; +`OPENMM2_NET_OTHERS=ahead` (an experiment) places and draws every other +player's car where the host will have them when it runs this machine's +sample, `=ghost` keeps this machine's car from touching them, `=past` (or +any value) keeps them all at their interpolated states instead of +simulating the near ones; `OPENMM2_NET_TRAFFIC_REPLAY=predicted|frame` +meets the shared traffic's cars on their rails, in a client's samples run +again, where the host had them at each sample's time or where the frame put +them, instead of where each sample met them (comparisons); with +`OPENMM2_DEBUG_NETCARS` the host also logs every reset of a client's car +it refuses; `OPENMM2_DEBUG_NETCARS_NOISE=` nudges a client's car's momentum by about that fraction each sample, as a machine whose compiler rounds differently might (the prediction's tolerance); @@ -1282,9 +1331,13 @@ link with 2 % loss, which prints the same-moment error) and withdrawn, recycled). The players' cars by `tests/net/test_player_cars.cpp` (the messages, their limits, mutated messages, delivery and the input budget through real -sessions), `tests/game/test_player_cars.cpp` (the pedals' bytes, the host's -queue and its catch-up, a client predicting a host's car through a -simulated network with and without losses), `tests/game/test_net_rules.cpp` +sessions, the near cars in full in one packet, a client refusing its own +car or a car given twice among them), `tests/game/test_player_cars.cpp` +(the pedals' bytes, the host's queue and its catch-up, a client predicting +a host's car through a simulated network with and without losses, a client +predicting a shunt with the other car simulated, held, and braking, the +host's reset rules), `tests/game/test_traffic_bodies.cpp` (the rail cars +put elsewhere for a replay and back), `tests/game/test_net_rules.cpp` (Cops and Robbers' limits from the host) and `tests/phys/test_net_prediction.cpp` (a car's state saved and run again, a car's sample alone, the sample hooks, a car's own random stream). @@ -1341,7 +1394,10 @@ apart each machine draws each player's car from its own player's screen, the frames in which a car moves further than its velocity explains, the collisions between players each machine had (and how close every machine drew the two cars around them) and the corrections, then the props (by -session time, see below). +session time, see below). A car drawn a fixed time behind (or ahead of) its +player's screen is that far off at its speed however exactly it follows, +so the report also gives, for each viewer and car, the delay that brings +the two drawings closest and what remains at it. 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). diff --git a/docs/parity/openmm2-only.md b/docs/parity/openmm2-only.md index c112d9d..66f3259 100644 --- a/docs/parity/openmm2-only.md +++ b/docs/parity/openmm2-only.md @@ -109,6 +109,22 @@ 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. | +## Players' cars of a network race + +Added 2026-10-09 on the maintainer's decision (the host is the authority). +MM2 runs every network car on every machine as a `vehCar` driven on its +last pedals and pulled toward its packets (`mmNetObject::PositionUpdate`, +`Predict`), each machine with its own collisions (docs/multiplayer.md, +"Players' cars"; docs/review/multiplayer-desync-cars.md). These rows are not +counted in the summary above. + +| OpenMM2 | MM2 | Verdict | Notes | +| --- | --- | --- | --- | +| `game::NetCarDriver`, `HostInputQueue`, `CarPrediction`, `CorrectionBlend`, `net::PlayerInputMsg`, `CarStatesMsg` | `mmNetObject`, `mmGameMulti::SendPosition` | deviation | The host simulates every player's car from its inputs; a client predicts its own and is corrected by the host's states. | +| `CarPrediction`'s companions, `net::NearCarState` | `mmNetObject::Predict` (a network car driven on its last pedals) | deviation | A client simulates the other players' cars near its own from the host's full states and last inputs, put to the host's state at every one (MM2 pulled the car toward its packet along a path). | +| The players' cars in player order among the world's movers (`RaceScreen::orderPlayerCars`) | `dgPhysManager::DeclareMover` (the order of the declarations) | deviation | So that two players' cars collide in the same order on every machine. | +| `game::ResetRules` | `mmPlayer::Reset` from `mmGameMulti::HitWaterHandler` and `DropThruCityHandler` | openmm2 | The host carries out a client's reset only where those rules would. | + ## Shared traffic of a network cruise Added 2026-10-09 on the maintainer's decision (2026-10-09: the traffic and diff --git a/docs/review/multiplayer-desync-cars.md b/docs/review/multiplayer-desync-cars.md index 3c6f81c..03a3901 100644 --- a/docs/review/multiplayer-desync-cars.md +++ b/docs/review/multiplayer-desync-cars.md @@ -1,6 +1,8 @@ # Multiplayer desync: the players' cars -Reviewed and reworked on 2026-10-09 from 0.3.1 (cd5ae29). +Reviewed and reworked on 2026-10-09 from 0.3.1 (cd5ae29); a second round +the same day (below, "Second round") made shunting predictable, the host +check a client's resets and the replays meet the traffic where it was. The maintainer's report, for multiplayer cruise over the internet with everyone on 0.3.1: "another player's car is somewhere other than where they @@ -273,6 +275,208 @@ into each other on screen. The default stays as the maintainer chose. host with eight (0.3.1: about 6.5 and 46 KB/s), plus the shared traffic in cruise. +## Second round: shunting, resets, the traffic in replays + +After the first round merged (cae6989) the coordinator asked for three of +its open findings: shunting from behind (O1) at least as good as 0.3.1 with +no jump of a client's own car over 1 m and no other scenario worse; the +host checking a client's resets (O4); the traffic in replays (O7). + +### What the shunts showed + +* **Most of the divergence is a delay.** `netprobe syncreport` now also + finds, for each viewer and car, the delay that brings the two drawings + closest and what remains at it. In every build and scenario the host + draws a client's car about 140-160 ms behind that client's screen (230-260 + ms on the harsher link): in 0.3.1 the playout delay of the client's + snapshots, now the client's inputs' trip, the host's margin of inputs in + hand and a frame. At that delay a few centimetres remain at the median. + The plain divergence is that delay times the car's speed, and the rear + runs differ most in speed: the client's car moved at a median 1.9-4.6 m/s + in 0.3.1's runs, where it bounced off the host's past image, and at + 2.8-9 m/s once the host's car gave way. +* **The past image.** A client predicts its own car about 150 ms ahead of + the host and drew the other car about 150 ms behind: shunting it, its car + met the other's image some 2 m short of where the host's cars met, and an + image that does not give way. Each state then corrected it (up to 53 + corrections over 1 m in a run), and a correction could put the car inside + the image (the first round's shake). +* **The order.** Two cars collide in the order of the world's movers + (World::step runs a mover's collisions with the city, then with the movers + after it, then with the instances): the host had its own car first, a + client its own. +* **What the state left out.** Rebuilt from the full state alone (body, + wheels, engine, ...), the other car still drifted from the host's at every + state while the two pushed each other: a sample hands the next the force + and torque the wheels and engine set for it, each tyre's rolling + resistance (`Wheel::tireResistance`, which the drivetrain reads), and in a + contact the impulses and pushes. Found by restoring the host's values + group by group in a test of two cars in one process (below): with all of + them a steady shunt is predicted to the bit. + +### What changed + +1. **The near cars in full** (c5c1b4c, protocol 9): the host sends each + client the two nearest other players' cars within 40 m of its car (kept + to 50 m; not one towing a trailer) in full, with the input it last + applied to them (`net::NearCarState`). The client simulates them along + with its own car (`CarPrediction`'s companions): at every state it puts + them to the host's state at the acknowledged sample and runs them with + its car through the later samples on that input, and on between states. + Its car meets them where the host's does and both give way; only the + other player's change of input since that state is unknown. Drawn + between their last two samples, each state's move eased away (80 ms + half-life) and the switch between simulating a car and placing it at its + states over 150 ms. +2. **Player order; far companions alone** (94d542a): every machine keeps + the players' cars in the world's movers in player order (the host's + first), and a replay runs them in that order. Companions that cannot meet + the car in the samples run again (farther than 8 m and the way their + speeds close) run again without it, the car held where each sample had + it: running the car's own samples again against props held still made it + drift from the host's in a chase with no contact between the cars. +3. **What a sample hands the next** (38fcf08): in every car's full state + (own and near); at most two near cars, so that a message to one of eight + players stays one ENet packet (1356 bytes with two near cars in a + contact; ENet splits above 1372). +4. **Resets on the host's terms** (78886cf): `game::ResetRules`. +5. **The traffic in replays** (e51e9bf, 60ddfd0): the shared traffic's + cars on their rails where each sample met them, and a body a sample did + not meet out of that sample. +6. **The report** (c7e1cb1, 156908f): the delay-matched divergence; + corrections a minute of the whole race. + +### Before and after + +Two runs of each scenario on each build, interleaved (the same machine +load), 75 s each; "0.3.1" is 80b8b8e with the machine clock, "first round" +cae6989, "second round" this branch at 8c0a8d1. Metres, median / 99th +percentile, the two runs separated by a comma. + +Host ← client (the host's drawing of the client's car against the client's +screen): + +| Scenario | 0.3.1 | first round | second round | +| --- | --- | --- | --- | +| rear | 0.28 / 1.76, 0.71 / 2.51 | 0.47 / 2.89, 1.16 / 5.04 | 0.45 / 2.50, 0.43 / 2.19 | +| ram | 0.64 / 1.65, 0.65 / 1.64 | 0.61 / 1.38, 0.57 / 1.33 | 0.33 / 1.35, 0.60 / 1.33 | +| chase | 0.58 / 2.90, 0.26 / 2.84 | 0.90 / 2.70, 0.54 / 2.42 | 0.22 / 2.22, 1.07 / 2.99 | +| ram, 150 ms, 5% | 0.95 / 2.36, 0.99 / 2.39 | 1.11 / 2.67, 1.04 / 2.47 | 0.90 / 1.92, 0.92 / 2.00 | +| ram, 3 players (host ← clients) | 0.50-0.63 / 1.38-1.59 | 0.44-0.53 / 1.25-1.32 | 0.46-0.53 / 1.18-1.42 | +| ram, shared traffic | 0.56 / 1.51, 0.52 / 1.76 | 0.57 / 1.39, 0.52 / 1.39 | 0.59 / 1.42, 0.59 / 1.39 | + +Client ← host (and, with three players, client ← client): + +| Scenario | 0.3.1 | first round | second round | +| --- | --- | --- | --- | +| rear | 0.33 / 1.11, 0.36 / 1.10 | 0.13 / 2.21, 0.70 / 2.19 | 0.27 / 1.30, 0.07 / 1.77 | +| ram | 0.56 / 1.43, 0.59 / 1.61 | 0.60 / 1.68, 0.59 / 1.62 | 0.56 / 1.52, 0.60 / 1.59 | +| chase | 0.51 / 1.86, 0.20 / 1.49 | 0.59 / 1.91, 0.22 / 1.62 | 0.45 / 1.42, 0.23 / 1.67 | +| ram, 150 ms, 5% | 0.92 / 2.28, 0.93 / 2.43 | 1.01 / 2.54, 1.00 / 2.45 | 0.94 / 2.71, 0.96 / 2.81 | +| ram, 3 players (clients ← host) | 0.54-0.57 / 1.34-1.51 | 0.55-0.57 / 1.62-1.82 | 0.49-0.50 / 1.39-1.46 | +| ram, 3 players (client ← client) | 0.73-0.92 / 2.03-2.26 | 0.88-1.08 / 2.66-2.85 | 0.05-0.29 / 2.47-2.91 | +| ram, shared traffic | 0.56 / 1.36, 0.59 / 1.57 | 0.61 / 1.72, 0.54 / 1.69 | 0.56 / 1.52, 0.58 / 1.83 | + +What remains once the delay is taken out, host ← client in the rear runs +(the delay that brings the drawings closest; median / 99th percentile): +0.3.1 160 ms 0.010 / 0.09, 175 ms 0.021 / 0.17; first round 135 ms +0.18 / 2.32, 160 ms 0.21 / 3.64; second round 135 ms 0.008 / 0.53, 135 ms +0.015 / 0.51. The client's car's median speed in those runs: 0.3.1 1.9 and +4.2 m/s, first round 2.8 and 5.5, second round 3.1 and 3.2. + +The rear shunt is now drawn as 0.3.1 drew it at the median, at the same +delay, and its collisions happen on both screens (below). Its 99th +percentile, and that of a client's view of the host's car in the harsh +runs (2.7-2.8 m against 2.3-2.4), stay higher than 0.3.1's: in 0.3.1 every +player's own car was the truth and the others drew exactly its path a delay +late, never corrected (and each machine had its own collisions); with the +host the authority, what remains is the client's prediction missing the +other player's change of input in the last 150-250 ms (the host's car +braking hard in front of it), and a near car is drawn where the client +predicts it. With the near prediction off (`OPENMM2_NET_OTHERS=past`, the +same build) the harsh run's client ← host was 0.61 / 2.39 but its own car +was corrected 111 times over 10 cm, 8 over 1 m, and jumped once by 4.4 m. + +Collisions between players: the host's collisions the client predicted / +the client's predictions the host did not have (0.3.1: the host's collisions +the client also had / the client's the host also had): + +| Scenario | 0.3.1 | first round | second round | +| --- | --- | --- | --- | +| rear | 5 of 14, 8 of 17 / 5 of 10, 7 of 16 | 11 of 11, 5 of 5 / 6 of 17, 5 of 11 | 3 of 3, 11 of 11 / 1 of 4, 0 of 11 | +| ram | 4 of 4, 6 of 6 | 15 of 15, 18 of 18 / 1 of 16, 0 of 18 | 14 of 14, 18 of 18 / 0 of 14, 1 of 19 | +| ram, 150 ms, 5% | 6 of 7, 9 of 10 / 5 of 6, 10 of 12 | 18 of 18, 18 of 18 / 0 of 18, 2 of 20 | 18 of 18, 18 of 18 / 0, 0 | +| ram, 3 players | every pair matched in one run, one of the clients' collisions on one screen only in the other | 0-1 17 of 17, 10 of 11 / 1, 0; 0-2 1 of 3, 1 of 3 / 2 of 3, 4 of 5 | 0-1 12 of 12, 5 of 5 / 1, 0; 0-2 7 of 7, 4 of 4 / 0, 0 | +| ram, shared traffic | 4 of 4, 6 of 6 / 4 of 4, 6 of 7 | 19 of 19, 7 of 7 / 0, 0 | 16 of 16, 18 of 19 / 0, 0 | + +Corrections of a client's own car (a minute; over 10 cm; over 1 m) and its +own jumps over 1 m in a frame (none in any 0.3.1 run): + +| Scenario | first round | second round | +| --- | --- | --- | +| rear | 868, 610; 459, 571; 40, 53 (6 drawn at once); jumps 0, 16 | 386, 771; 31, 83; 1, 7; jumps 0, 0 | +| ram | 269, 248; 142, 133; 6, 3 | 410, 166; 33, 5; 0, 0 | +| chase (no contact between the cars) | 0, 0 | 15, 133; 0, 10; 0, 0 | +| ram, 150 ms, 5% | 236, 260; 121, 147; 5, 6 (jump 1) | 175, 181; 26, 12; 0, 0 | +| ram, 3 players (two clients) | 356 and 286, 221 and 307; 167 and 72, 99 and 63; 8 and 1, 0 and 1 | 221 and 181, 206 and 105; 9 and 22, 13 and 10; 0 | +| ram, shared traffic | 279, 345; 126, 171; 0, 7 | 219, 225; 23, 50; 2, 2 | + +Most corrections now are millimetres to a few centimetres (median 0.7-2.5 +cm against 1.4-21.5): a state that moves the other car moves this one a +little where they meet. The chase's are the props': since the props work +(14dc206) a client's car meets the host's props, and the same build with the +near prediction off was corrected 236 times a minute there, 16 over 10 cm. +Running the samples again costs more (every state runs the near cars +again): at most 20.6 ms a second and 2.8 ms in a frame. + +### Resets (O4) + +A client driving into the bay at San Francisco's cruise start: after five +seconds in the water its rules reset the car, the host carried the reset +out (the car's `vehSplash` latched for over four seconds) and the client +was not corrected at all; twice in 45 s. The same client resetting its car +on the road six seconds in (`OPENMM2_DEBUG_RESPAWN_MS` on the client only): +the host refused it (`netcars: refused player 1's Reset`) and the client's +car went back to where the host had it (a 40.9 m correction, drawn at once). +`tests/game/test_player_cars.cpp` checks every rule. + +### The traffic in replays (O7) + +Driving through the shared traffic along a busy street (z = -400 in San +Francisco), two runs of 75 s each per way of placing the cars on their rails +in the samples run again (corrections a minute; over 10 cm; over 1 m): + +| Placing | run 1 (a jam: the car pushed through queued cars) | run 2 | +| --- | --- | --- | +| where each sample met them (kept) | 904; 60; 6 | 184; 3; 0 | +| where the host had them then (`TrafficClient::poseAt`) | 820; 89; 4 | 183; 34; 4 | +| where the frame put them (before) | 1045; 194; 1 | 1020; 205; 4 | + +Where each sample met them is what the sample ran against, so a sample run +again with nothing else changed comes out the same; `poseAt`, newer, moves +the cars a sample run again meets. Pushing through a queue of cars corrects +at nearly every state whichever way (the host knocks them loose, the +replays meet them as walls): O8. + +### Bandwidth + +The host's states with a near car: about 700 bytes a message (the client's +car's full state, the other's, and a snapshot), 14 KB/s of payload for two +players in contact against 6.2 KB/s in the first round, 8 KB/s apart. With +eight players each client gets at most about 1.1 KB a message, 22 KB/s, and +the host sends at most about 160 KB/s (every player with two others near); +apart, as in the first round, about 11 and 78 KB/s. + +### Not reproduced + +* The props agent saw a client corrected at every state for 3-4 s after a + checkpoint race's start (0.56 m, alternating, 58-59 samples run again). + One checkpoint race on this branch (40 s, the race's own grid): one + correction of 4.5 cm as the host caught up with the client's backlog (55 + samples run again), then millimetres. +* A client's car pressed against a wall correcting: a car driven into a + wall at full throttle for 20 s was not corrected once. + ## Deviations from MM2 * The host simulates every player's car; MM2 ran every network car on every @@ -284,6 +488,14 @@ into each other on screen. The default stays as the maintainer chose. * The host decides every car's damage and Cops and Robbers' limits (MM2: each machine its own car's damage; the limits were already the host's in MM2, `UpdateLimit` / `SendLimitReached`). +* A client simulates the other players' cars near its own from the host's + states and their last inputs (MM2 drove every network car on its last + pedals and pulled it toward the packets, `mmNetObject::Predict`; OpenMM2 + puts it to the host's state instead), and keeps the players' cars in + player order among the world's movers (MM2: in the order they were + declared). +* The host resets a client's car only where the game's rules would (MM2's + peers reset their own cars and told the others). ## Findings @@ -294,13 +506,18 @@ into each other on screen. The default stays as the maintainer chose. | C3 | minor (parity) | `CopsAndRobbers::tickLimits` | Every machine checked the limits (sync review S6). | fixed (276e508) | | C4 | visible | `HostInputQueue` (new) | A client that loaded before the host started seconds behind its car. | fixed (127ac41) | | C5 | visible | `CarPrediction::acknowledge` (new) | Replays against the other cars' newest places made shunting corrections swing by up to 4 m. | fixed (19e8271) | -| O1 | visible | the prediction | Corrections of up to 2-3 m while shunting another player's car, which the client sees in the past. At worst (one rear run of six) a correction puts the client's car where the host has it, right behind the other car, which is inside that car's past image: the car is pushed out every sample and shakes from side to side by up to 1.5 m for under a second. | open: inherent to drawing the others in the past and predicting one's own car; the experiment above trades it for the other cars jumping. Narrower ways out: let a corrected car pass through another player's image until they part, or limit the push a car takes from an image in a sample | -| O2 | visible | props | Each machine runs its own props, so a car knocking them is corrected (the props work makes them the host's). | for the props agent | +| O1 | visible | the prediction | Corrections of up to 2-3 m while shunting another player's car, which the client sees in the past. At worst (one rear run of six) a correction puts the client's car where the host has it, right behind the other car, which is inside that car's past image: the car is pushed out every sample and shakes from side to side by up to 1.5 m for under a second. | fixed (c5c1b4c, 94d542a, 38fcf08): a client simulates the near cars with its own from the host's full states, in the host's order; corrections over 1 m in the rear runs 1 and 7 (first round 40 and 53), no jump over 1 m | +| O2 | visible | props | Each machine runs its own props, so a car knocking them is corrected (the props work makes them the host's). | the props work landed (14dc206): docs/review/multiplayer-desync-props.md; a chase with props knocked is now corrected about 130-240 times a minute by a few millimetres | | O3 | minor | rules | Checkpoints, laps, finishes and Cops and Robbers' gold are still decided on each player's own machine and relayed (a client could claim them); only the limits are the host's. | open: the host would run each car's gates (`game::session::playerGateHit` on its simulated cars, the laps and the finish) and Cops and Robbers' `CopsAndRobbers::update` over every car (it has every car and every collision now), and send the results; the clients would show them | -| O4 | minor | commands | The host checks a client's resets only against the city's box and four a second, not against the race's checkpoints and the water. | open (needs the rules on the host) | -| O5 | minor | `CarStatesMsg` | The own car's state is 270 bytes at 20 Hz to every client (78 KB/s for the host with eight players). | open: it could go only when it changed beyond the tolerance | +| O4 | minor | commands | The host checks a client's resets only against the city's box and four a second, not against the race's checkpoints and the water. | fixed (78886cf): `game::ResetRules` (the water, the fall, the race's checkpoints, a wreck's or Cops and Robbers' repair) on the host's simulation of the car | +| O5 | minor | `CarStatesMsg` | The own car's state is 270 bytes at 20 Hz to every client (78 KB/s for the host with eight players); since protocol 9 about 310 (370 in a contact), and up to two near cars in full: at most about 160 KB/s for the host with eight players all near each other. | open: it could go only when it changed beyond the tolerance, a near car only while the two can meet | | O6 | minor | MSVC against GCC | Not testable here; the noise test suggests corrections in crashes, eased away. | open | -| O7 | minor | replays | The shared traffic's cars still on their rails are held where the frame placed them while a client runs its samples again. | open: `TrafficClient::poseAt` could put each where its sample met it | +| O7 | minor | replays | The shared traffic's cars still on their rails are held where the frame placed them while a client runs its samples again. | fixed (e51e9bf, 60ddfd0): where each sample met them (corrections over 10 cm driving through the traffic 3 and 60 against 194 and 205; `poseAt` 34 and 89) | +| C6 | visible | `RaceScreen` (the players' cars' movers) | Each machine had its own car first among the world's movers, so two cars collided in a different order on the host and on a client. | fixed (94d542a): player order everywhere | +| C7 | visible | `net::OwnCarState` | The full state left out what a sample hands the next (the force and torque set for it, the tyres' rolling resistance, a contact's impulses and pushes): a car rebuilt from it drifted from the host's in a contact. | fixed (38fcf08), protocol 9 | +| C8 | minor | `netprobe syncreport` | Corrections a minute were counted over the span they fell in. | fixed (156908f) | +| O8 | minor | the shared traffic | A client pushing through a queue of traffic cars is corrected at nearly every state (by a few centimetres), whichever way the replays place them: the host knocks the cars loose, a replay meets them as walls. | open (with the shared traffic) | +| O9 | minor | the near cars | A near car is drawn where the client predicts it: in the harsh runs a client's view of the host's car reached 2.7-2.8 m at the 99th percentile (2.3-2.4 placed at its states), where the host braked or turned in the last 250 ms. | open: inherent to predicting another player; the client's own car is corrected far less in exchange | ## Reproducing @@ -319,3 +536,13 @@ ram: client at (-1149.345, 114.9, 137.194), angle π, inputs (-1149.945, 111.9, 175.194), angle 0, host `0.5,0,0,0/0.5,0,0,0/0,0.7,0,0/0.6,0,0.08,0/0.6,0,-0.08,0/0,1,0,0/0.5,0,0,0`, client `1,0,0,0/1,0,0.02,0/1,0,0,0/1,0,-0.03,0/0.8,0,0,0/1,0,0.04,0/0,1,0,0`. + +The second round's: water: client at (40.306, 1.918, 153.295), angle 0, +input `0.8,0,0.1,0` (San Francisco's cruise start, into the bay); traffic +(the shared traffic on): client at (-1560, 21, -401), angle -π/2, inputs +`0.8,0,0.12,0/0.8,0,-0.12,0/0.8,0,0.1,0/0.8,0,-0.1,0/0,0.5,0,0` for 0.7 s +each; wall: client at (-1149.945, 111.9, 183.194), angle π/2, input +`1,0,0,0`; the host waits at the hill (`0,0,0,0`) in all three. The checkpoint +race ran without `OPENMM2_DEBUG_START` (the race's grid). The two-car shunt +without a network is `PlayerCars.AShunt*` in +`tests/game/test_player_cars.cpp`. From d3ba49896108357347a28e9866546a985b9d4ce3 Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Fri, 9 Oct 2026 18:46:46 -0700 Subject: [PATCH 080/129] Record the host's rules in the session audit and the cars review docs/parity/session.md: the waypoint rows name WaypointTracker, and a "Host authority" section has the referee, the client's corrections and Cops and Robbers on the host against MM2's functions. The players' cars review's O3 is fixed and O4 fixed for races' respawns. docs/multiplayer.md: the contested pickup and the Cops and Robbers seed hook. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/multiplayer.md | 9 ++++---- docs/parity/session.md | 30 ++++++++++++++++++++------ docs/review/multiplayer-desync-cars.md | 4 ++-- 3 files changed, 31 insertions(+), 12 deletions(-) diff --git a/docs/multiplayer.md b/docs/multiplayer.md index 3e82378..9411dea 100644 --- a/docs/multiplayer.md +++ b/docs/multiplayer.md @@ -1078,10 +1078,11 @@ for everything) decides them on the host from the cars it simulates (see its waypoints from the host's list and its hits still on their way, with the same rules, so nothing is shown twice. * a gold pickup (`CopsAndRobbers::updatePredicted`): "You have the Gold!" - at once (MM2's client asked the host and waited for 0x25a). The host's - `GoldTaken` for it confirms it; one for another car undoes it and shows - that car's; the state undoes it once the host has seen the car 30 - samples past the pickup without granting it. + at once (MM2's client asked the host and waited for 0x25a), unless + another car is at the gold too, when it waits for the host's word as + MM2's did. The host's `GoldTaken` for it confirms it; one for another + car undoes it and shows that car's; the state undoes it once the host + has seen the car 30 samples past the pickup without granting it. * nothing else: the finishes, the results, the standings, drops, deliveries, scores, the new places and the limits are the host's word only. A client's own "finished in" line (MM2's client line: 149, 106, diff --git a/docs/parity/session.md b/docs/parity/session.md index e0f6c40..e674124 100644 --- a/docs/parity/session.md +++ b/docs/parity/session.md @@ -11,6 +11,11 @@ A second pass (2026-10-08) wired the race-side items the first pass and the other areas left open; its rows are in "Second pass" below, and the Missing table is updated. +The host-authority work (2026-10-09, docs/review/multiplayer-desync-rules.md) +moved mmWaypoints' rules into `WaypointTracker` (game/session/Waypoints.h) +unchanged, so the host's referee runs them too; its rows are in "Host +authority" below (8 more: verified 2, fixed 2, deviation 3, openmm2 1). + Scope: the game modes and race rules (`src/game/session/Session`, `RaceSetup`, `Gate`, `CopsAndRobbers`, `Types`), the in-race HUD (`src/game/session/Hud`), the race configuration and strings @@ -95,18 +100,18 @@ Behaviour is documented in `docs/gamemodes.md`. Tests: | `rule` | `mmWaypoints` types 1-5 | verified | | | `resetRace` | `mmGame::Reset`, `mmGameSingle::Reset`, the modes' `Reset` | fixed | the minimum-speed state and the race-over flag are reset only here (not per exam event); the timer warning sound is stopped; the engine is un-silenced | | `beginEvent` | `mmSingleStunt::InitNewEvent`, `InitHUD`, the race modes' `InitGameObjects` | verified | clocks per lesson type | -| `resetWaypoints` | `mmWaypoints::Reset` | verified | target 1, count 1; the finish of a checkpoint race hidden | +| `WaypointTracker::reset` (was `resetWaypoints`) | `mmWaypoints::Reset` | verified | target 1, count 1; the finish of a checkpoint race hidden | | `start` | `mmGame::Reset`, `mmSingleRoam`, `mmMultiRoam::UpdateGame` state 1 | fixed | mmMultiRoam's "Go!" plays Startracehigh; `mmGame::Reset` asks the announcer for the pre-race line | | `restart` | `mmGame::Reset` | verified | | | `playerHold`, `playerHeld` | `vehCar::SetDrivable`, mmPlayer +0x2258 | fixed | was "held" for every post-race phase; now per ending: undrivable before "Go!", in wreck penalties, after a race/Blitz/jump/course wreck and any multiplayer ending; +0x2258 after the others; nothing after the water and the Clean and minimum-speed wrecks | | `updateCountdown` | the modes' `UpdateGame` states 0-2, `mmSingleStunt::Update*` | fixed | circuits and checkpoint races wait for the pre-race camera (mmPlayer +0xE5A); multiplayer waits for the host's start; a later exam event enables its cars in state 0 and says "Go" one update later; sounds | | `go` | the modes' state 2 → 3 | fixed | starts mmHUD's second timer too; Evade's later-event line (652) | -| `setTarget` | `mmWaypoints::SetCurrentGoals` | verified | | -| `closestTarget` | `mmWaypoints::GetClosestWaypoint` | verified | nearest shown, uncleared, 3D | +| `WaypointTracker::setTarget` | `mmWaypoints::SetCurrentGoals` | verified | | +| `WaypointTracker::closestTarget` | `mmWaypoints::GetClosestWaypoint` | verified | nearest shown, uncleared, 3D | | `cycleTarget` | `mmSingleRace` / `mmMultiRace::UpdateGameInput` | fixed | only for waypoint type 2 (OpenMM2 also cycled Blitz and lesson targets) | -| `cycleCurrent` | `mmWaypoints::CycleCurrentWaypoint`, `GetNextWaypoint`, `GetLastWaypoint` | verified | OpenMM2 also stops below three waypoints (MM2 would index past the list) | -| `displayCleared` | `mmWaypoints::DisplayHUDMessage`, `mmHUD::ShowSplitTime` | fixed | every cleared checkpoint shows the split time for 1 s (circuits: since the lap started), not in the crash course; Waypoint / Lastwaypoint sounds | -| `updateWaypoints` | `mmWaypoints::Update`, `ClearWaypoint` | verified | per type; the rules see the waypoints as the previous frame left them (MM2 updates them after `UpdateGame`) | +| `WaypointTracker::cycleCurrent` | `mmWaypoints::CycleCurrentWaypoint`, `GetNextWaypoint`, `GetLastWaypoint` | verified | OpenMM2 also stops below three waypoints (MM2 would index past the list) | +| `displayCleared`, `WaypointTracker::markCleared` | `mmWaypoints::DisplayHUDMessage`, `mmHUD::ShowSplitTime` | fixed | every cleared checkpoint shows the split time for 1 s (circuits: since the lap started), not in the crash course; Waypoint / Lastwaypoint sounds | +| `updateWaypoints`, `WaypointTracker::detect`, `apply`, `showWaypointSteps`, `lapCompleted` | `mmWaypoints::Update`, `ClearWaypoint` | verified | per type (the tracker reports what a hit did in Update's order and the session shows it); the rules see the waypoints as the previous frame left them (MM2 updates them after `UpdateGame`) | | `updateOpponents` | `mmSingleRace` / `mmSingleCircuit::UpdateOpponentStatus`, `FinishMessage` | fixed | finish times from mmHUD's +0xA24 timer (the player's finish does not stop it); also runs in the frame the race ends; Messagenote in both modes | | `updateRank` | `UpdateScore` | verified | | | `updateHazards` | `mmGame::Update` | verified | below y = −50; 5 s in the water; per-city lines | @@ -285,6 +290,19 @@ Behaviour is documented in `docs/gamemodes.md`. Tests: | `Hud::drawCrObjects`, `drawCrReadouts`, arrow interest | `mmPowerupInstance::Draw`, `mmBillInstance::Draw`, `mmArrow::SetInterest`, `mmCRHUD::Init` | fixed | new: the spinning gold, the bases' billboards, the team totals ("COPS" / "ROBBERS" or "BLUE" / "RED"); the roster of names and the gold icon are not drawn, and the totals' corner is inferred | | Cops and Robbers speech | `mmSpeechContainer::InitCNR` | verified | loaded; nothing in build 3393 calls `mmCNRSpeech::Play` | +## Host authority (2026-10-09) + +| OpenMM2 | MM2 | Verdict | Notes | +| --- | --- | --- | --- | +| `RaceReferee::sample` | `mmWaypoints::Update` on each machine's car; `mmMultiRace` / `mmMultiCircuit` / `mmMultiBlitz::UpdateGame` state 3 (the finish, a Blitz clock, nothing checked while a wreck penalty holds the car) | deviation | the host runs every player's car after every physics sample (MM2: each machine its own, once a frame); the finish time is the host's samples from the car's own release, where MM2's was each machine's timer | +| `RaceReferee::decideFinish`, `results` | `mmGameMulti::SortResults`, `SendFinishAck`, `mmMultiRace::SetTimeoutOn` | verified | listed once, by time, a later equal time after; the first finish arms 60 s (race) or 120 s (circuit) | +| `RaceReferee::update`, `timeOutAll` | the timeout of `mmMultiRace` / `mmMultiCircuit::UpdateGame`, `GameMessage` 0x1fe, 0x206, 0x211; `mmMultiBlitz` 0x1fe at the host's time-up | deviation | the host decides everyone's did-not-finish at once (MM2: each machine sent its own after 0x1fe); counted against the players still in the race (the existing deviation) | +| `RaceReferee` standings, `iconPlace` | `mmGameMulti::UpdateScore` | verified | each player's place and each car's icon number as that player's machine computes them; a player's target is the host's view of it (the Next / Prev. Checkpoint keys stay on its machine) | +| `Session::updateNetRace` (icon numbers) | `mmGameMulti::UpdateScore` | fixed | the player's own car against another car level with it: both measured to the player's own target, not to that car's next waypoint | +| `Session::netFinished` (own line) | `mmMultiRace` / `Circuit` / `Blitz::GameMessage` 0x1f7 | fixed | a client's own "finished in" line is its line (149 / 106 / 95), shown with the host's time when the host's word arrives; it showed the host's line (148 / 105 / 93) at its own detection | +| `Session::applyNetProgress`, `setNetStanding`, `netTimedOut`, `netAllCounted`; `CopsAndRobbers::updatePredicted`, `applyHost`, `adopt` | `GameMessage` 0x1f7, 0x1fe, 0x211; `mmMultiCR::UpdateGold` (a client's request), `GameMessage` 0x25a, 0x259, 600, 0x261 | openmm2 | a client predicts its checkpoints and a pickup and takes the host's word on them; everything else is the host's | +| `CopsAndRobbers::updateHost` | `mmMultiCR::ImpactCallback`, `UpdateGame` state 4, `HitWaterHandler`, `DropThruCityHandler`, `UpdateGold`, `UpdateBank`, `UpdateHideout`, `UpdateLimit`, `GameMessage` 0x25e, `SystemMessage` 0x2d | deviation | the host runs each car's rules as its own machine did; two cars at the gold in one frame are decided in car order (MM2: the first request to arrive) | + ## Missing | MM2 | What it does | Status | diff --git a/docs/review/multiplayer-desync-cars.md b/docs/review/multiplayer-desync-cars.md index 3c6f81c..473355f 100644 --- a/docs/review/multiplayer-desync-cars.md +++ b/docs/review/multiplayer-desync-cars.md @@ -296,8 +296,8 @@ into each other on screen. The default stays as the maintainer chose. | C5 | visible | `CarPrediction::acknowledge` (new) | Replays against the other cars' newest places made shunting corrections swing by up to 4 m. | fixed (19e8271) | | O1 | visible | the prediction | Corrections of up to 2-3 m while shunting another player's car, which the client sees in the past. At worst (one rear run of six) a correction puts the client's car where the host has it, right behind the other car, which is inside that car's past image: the car is pushed out every sample and shakes from side to side by up to 1.5 m for under a second. | open: inherent to drawing the others in the past and predicting one's own car; the experiment above trades it for the other cars jumping. Narrower ways out: let a corrected car pass through another player's image until they part, or limit the push a car takes from an image in a sample | | O2 | visible | props | Each machine runs its own props, so a car knocking them is corrected (the props work makes them the host's). | for the props agent | -| O3 | minor | rules | Checkpoints, laps, finishes and Cops and Robbers' gold are still decided on each player's own machine and relayed (a client could claim them); only the limits are the host's. | open: the host would run each car's gates (`game::session::playerGateHit` on its simulated cars, the laps and the finish) and Cops and Robbers' `CopsAndRobbers::update` over every car (it has every car and every collision now), and send the results; the clients would show them | -| O4 | minor | commands | The host checks a client's resets only against the city's box and four a second, not against the race's checkpoints and the water. | open (needs the rules on the host) | +| O3 | minor | rules | Checkpoints, laps, finishes and Cops and Robbers' gold were decided on each player's own machine and relayed (a client could claim them); only the limits were the host's. | fixed (protocol 8, docs/review/multiplayer-desync-rules.md): the host runs every car's waypoints after every sample (`game::session::RaceReferee`), the finish exchange, timeout and standings, and Cops and Robbers' rules for every car (`CopsAndRobbers::updateHost`), and tells each player (`game::NetRules`); a client predicts its checkpoints and a pickup, which the host confirms or corrects, and the host refuses a player's own rule events | +| O4 | minor | commands | The host checks a client's resets only against the city's box and four a second, not against the race's checkpoints and the water. | partly fixed (docs/review/multiplayer-desync-rules.md): in a race the host takes a respawn only at the start or a checkpoint its referee counted for that car (`RaceReferee::mayRespawnAt`); whether the car was in the water is still the client's word | | O5 | minor | `CarStatesMsg` | The own car's state is 270 bytes at 20 Hz to every client (78 KB/s for the host with eight players). | open: it could go only when it changed beyond the tolerance | | O6 | minor | MSVC against GCC | Not testable here; the noise test suggests corrections in crashes, eased away. | open | | O7 | minor | replays | The shared traffic's cars still on their rails are held where the frame placed them while a client runs its samples again. | open: `TrafficClient::poseAt` could put each where its sample met it | From 957374c3f43c8718ec3c2aa6d91b78afd2e793fb Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Fri, 9 Oct 2026 19:13:47 -0700 Subject: [PATCH 081/129] Wrap the host's rules' long lines The lines this branch added over 110 columns, wrapped as clang-format would; no change in behaviour. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/app/RaceScreen.cpp | 19 ++++++++++--------- src/game/net/Autopilot.cpp | 13 +++++++------ src/game/net/RulesTrace.cpp | 4 ++-- src/game/session/CopsAndRobbers.h | 3 ++- src/game/session/Session.cpp | 7 ++++--- tools/netprobe/RulesReport.cpp | 28 ++++++++++++++++------------ tools/netprobe/main.cpp | 3 ++- 7 files changed, 43 insertions(+), 34 deletions(-) diff --git a/src/app/RaceScreen.cpp b/src/app/RaceScreen.cpp index d987733..91ad5d5 100644 --- a/src/app/RaceScreen.cpp +++ b/src/app/RaceScreen.cpp @@ -3101,11 +3101,11 @@ class RaceScreen final : public Screen { const float speed = parts.size() > 1 ? static_cast(str::parseDouble(parts[1]).value_or(0.0)) : 0.0f; std::string error; - m_autopilot = s.session ? game::NetAutopilot::create(*m_city, ctx.game->vfs, m_session->setup(), - m_player->sim(), line, speed, m_world.get(), - &error) - : game::NetAutopilot::createFree(*m_city, ctx.game->vfs, m_session->setup(), - speed, &error); + const auto& setup = m_session->setup(); + const auto& files = ctx.game->vfs; + m_autopilot = s.session ? game::NetAutopilot::create(*m_city, files, setup, m_player->sim(), line, + speed, m_world.get(), &error) + : game::NetAutopilot::createFree(*m_city, files, setup, speed, &error); if (m_autopilot) log::info("race: the autopilot drives opponent line {}", line); else @@ -5661,10 +5661,11 @@ class RaceScreen final : public Screen { // The rules' messages (game/net/NetRules): sent by the host, taken by a client. if (verbose && m_netRules.active()) { const auto& r = m_netRules.stats(); - log::info("netrules: {} messages sent ({:.0f} B/s), {} taken, {} decisions, {} refused, {} malformed, " - "{} stale, {} decisions missed", - r.sent - m_netRulesSent.sent, static_cast(r.bytes - m_netRulesSent.bytes) / seconds, - r.received, r.decisions, r.refused, r.malformed, r.stale, r.gaps); + log::info("netrules: {} messages sent ({:.0f} B/s), {} taken, {} decisions, {} refused, " + "{} malformed, {} stale, {} decisions missed", + r.sent - m_netRulesSent.sent, + static_cast(r.bytes - m_netRulesSent.bytes) / seconds, r.received, r.decisions, + r.refused, r.malformed, r.stale, r.gaps); m_netRulesSent = r; } } diff --git a/src/game/net/Autopilot.cpp b/src/game/net/Autopilot.cpp index daa0ed9..e6f12ea 100644 --- a/src/game/net/Autopilot.cpp +++ b/src/game/net/Autopilot.cpp @@ -16,8 +16,8 @@ NetAutopilot::~NetAutopilot() = default; std::unique_ptr NetAutopilot::create(const city::CityData& city, const vfs::Vfs& vfs, const session::RaceSetup& setup, phys::CarSim& car, - int line, float speedLimit, const phys::GroundQuery* ground, - std::string* error) { + int line, float speedLimit, + const phys::GroundQuery* ground, std::string* error) { auto fail = [&](std::string text) -> std::unique_ptr { if (error) *error = std::move(text); @@ -48,8 +48,8 @@ std::unique_ptr NetAutopilot::create(const city::CityData& city, c // The AI takes the car's impact callback for itself; the player's car // keeps its own. const auto impacts = car.onImpactCallback; - pilot->m_driver = ai::Opponent::create(pilot->m_ai->map(), car, *path, o.params, laps, 1, 0, error, ground, - setup.config.vehicle); + pilot->m_driver = ai::Opponent::create(pilot->m_ai->map(), car, *path, o.params, laps, 1, 0, error, + ground, setup.config.vehicle); car.onImpactCallback = impacts; if (!pilot->m_driver) return nullptr; @@ -84,8 +84,9 @@ void NetAutopilot::setTarget(phys::CarSim& car, const Vec3& target, const phys:: if (m_target && m_target->dist2(target) < 25.0f * 25.0f && m_driver) return; m_target = target; - log::info("autopilot: to ({:.0f}, {:.0f}, {:.0f}) from ({:.0f}, {:.0f}, {:.0f})", target.x, target.y, target.z, - car.body.ics.matrix.m3.x, car.body.ics.matrix.m3.y, car.body.ics.matrix.m3.z); + const Vec3& from = car.body.ics.matrix.m3; + log::info("autopilot: to ({:.0f}, {:.0f}, {:.0f}) from ({:.0f}, {:.0f}, {:.0f})", target.x, target.y, + target.z, from.x, from.y, from.z); std::vector path(2); path[0].position = car.body.ics.matrix.m3; path[1].position = target; diff --git a/src/game/net/RulesTrace.cpp b/src/game/net/RulesTrace.cpp index e7364e2..33d17e3 100644 --- a/src/game/net/RulesTrace.cpp +++ b/src/game/net/RulesTrace.cpp @@ -42,8 +42,8 @@ void traceRuleStanding(std::FILE* f, double frame, int place, int racers) { void traceCopsEvent(std::FILE* f, double frame, int type, int car, int value, const Vec3& gold) { if (f) - std::fprintf(f, "CG %.3f %.3f %d %d %d %.2f %.2f %.2f\n", machineMs(), frame, type, car, value, gold.x, - gold.y, gold.z); + std::fprintf(f, "CG %.3f %.3f %d %d %d %.2f %.2f %.2f\n", machineMs(), frame, type, car, value, + gold.x, gold.y, gold.z); } void traceCopsScores(std::FILE* f, double frame, const std::vector>& scores) { diff --git a/src/game/session/CopsAndRobbers.h b/src/game/session/CopsAndRobbers.h index 68125fb..424c7f5 100644 --- a/src/game/session/CopsAndRobbers.h +++ b/src/game/session/CopsAndRobbers.h @@ -159,7 +159,8 @@ class CopsAndRobbers { // GetNewSet after a delivery), and tells everyone. `cars` are every // player's car in the game, `impacts` the damaging hits between them. // Returns the decisions, in order. - std::vector updateHost(float dt, const std::vector& cars, const std::vector& impacts); + std::vector updateHost(float dt, const std::vector& cars, + const std::vector& impacts); // A machine that is not the host: the clock and its warnings, the gold // riding above its carrier among `cars` (as this machine has them), and // its own car `me` taking free gold within reach (UpdateGold's test, the diff --git a/src/game/session/Session.cpp b/src/game/session/Session.cpp index ef145ac..05b4a15 100644 --- a/src/game/session/Session.cpp +++ b/src/game/session/Session.cpp @@ -1157,7 +1157,8 @@ void Session::updateNetRace(float dt, const PlayerState& player) { // runs the finish timeout (netTimedOut). m_netPlaces.assign(m_netRacers.size(), 0); for (std::size_t i = 0; i < m_netRacers.size(); ++i) - m_netPlaces[i] = m_netRacers[i].present && !m_netRacers[i].finished ? m_netRacers[i].hostPlace : 0; + m_netPlaces[i] = + m_netRacers[i].present && !m_netRacers[i].finished ? m_netRacers[i].hostPlace : 0; return; } // mmGameMulti::UpdateScore: the place among the other players, while @@ -1813,9 +1814,9 @@ void Session::updateRules(float dt, const PlayerState& player, std::spanclock > endClock - 1000.0 ? pendingAtEnd : shownNotCounted) += 1; continue; } - const double dt = finalShown[i]->clock - hostHits[static_cast(it - hostKeys.begin())]->clock; + const auto at = static_cast(it - hostKeys.begin()); + const double dt = finalShown[i]->clock - hostHits[at]->clock; (finalShown[i]->kind ? lagHostWord : leadPredicted).push_back(dt); } for (const auto& k : hostKeys) @@ -233,11 +234,12 @@ void checkpoints(const Machine& host, const Machine& m, double endClock) { std::println(" player {} ({}): host counted {} checkpoints ({} at gate 0); its HUD showed {} ({} as " "predicted, {} on the host's word), took {} back", m.self, m.path, counted.size(), hostLaps, m.shown.size(), predicted, fromHost, takenBack); - std::println(" shown and never counted by the host: {} (+{} still on their way at the end); counted and " - "not shown: {}; gate-0 passes shown {} against {}", + std::println(" shown and never counted by the host: {} (+{} still on their way at the end); " + "counted and not shown: {}; gate-0 passes shown {} against {}", shownNotCounted, pendingAtEnd, countedNotShown, shownLaps, hostLaps); if (!leadPredicted.empty()) - std::println(" predicted ones shown {:.0f} ms before the host counted them (median; 1% {:.0f}, 99% {:.0f})", + std::println(" predicted ones shown {:.0f} ms before the host counted them " + "(median; 1% {:.0f}, 99% {:.0f})", -percentile(leadPredicted, 0.5), -percentile(leadPredicted, 0.99), -percentile(leadPredicted, 0.01)); if (!lagHostWord.empty()) @@ -267,7 +269,8 @@ void finishes(const std::vector& machines) { if (const auto r = referee.find(id); r != referee.end()) { line += std::format(" (the host's measure {})", time(r->second.ms)); for (const auto& m : machines) - if (const auto it = m.finishes.find(id); it != m.finishes.end() && it->second.ms != r->second.ms) + if (const auto it = m.finishes.find(id); + it != m.finishes.end() && it->second.ms != r->second.ms) ++offReferee; } ++total; @@ -277,8 +280,8 @@ void finishes(const std::vector& machines) { agree += all ? 1 : 0; std::println("{}", line); } - std::println(" {} of {} finishes the same on every machine; {} results differ from the host's measure", agree, - total, offReferee); + std::println(" {} of {} finishes the same on every machine; {} results differ from the host's measure", + agree, total, offReferee); auto order = [](const Machine& m) { std::vector> v; for (const auto& [id, f] : m.finishes) @@ -319,8 +322,8 @@ void backing(const std::vector& machines) { const double d = p ? std::hypot(p->x - g.x, p->y - g.y, p->z - g.z) : 1e9; if (d > 7.0) { ++farPickups; - std::println(" player {}'s pickup at {:.0f}: its car {:.1f} m from the gold on the host", g.car, g.clock, - d); + std::println(" player {}'s pickup at {:.0f}: its car {:.1f} m from the gold on the host", + g.car, g.clock, d); } } // Each knock once: the host's own record of it (every car's since @@ -343,11 +346,12 @@ void backing(const std::vector& machines) { }); if (!backed) { ++unbacked; - std::println(" player {}'s gold knocked loose at {:.0f}: no such hit on the host", g->car, g->clock); + std::println(" player {}'s gold knocked loose at {:.0f}: no such hit on the host", g->car, + g->clock); } } - std::println(" the host's simulation: {} of {} pickups with the car more than 7 m from the gold, {} of {} " - "knocks without a hit of 250 there", + std::println(" the host's simulation: {} of {} pickups with the car more than 7 m from the gold, " + "{} of {} knocks without a hit of 250 there", farPickups, pickups, unbacked, knocks); } diff --git a/tools/netprobe/main.cpp b/tools/netprobe/main.cpp index 0059e72..423a100 100644 --- a/tools/netprobe/main.cpp +++ b/tools/netprobe/main.cpp @@ -384,7 +384,8 @@ int cmdRelay(const Args& a) { } 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" From f2bdb04bf1c6020a7cf90556406b7f5ddda78534 Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Fri, 9 Oct 2026 19:13:58 -0700 Subject: [PATCH 082/129] Record the review of the host's rules docs/review/multiplayer-desync-rules.md: MM2's rules in a network race (mmWaypoints, mmGameMulti::UpdateScore, mmMultiRace / Circuit / Blitz, mmMultiCR), what was decided where before, what changed, how it was measured (netprobe relay at 60 and 150 ms, both cars on MM2's racer AI, Cops and Robbers ramming through the gold) and the results before, after and merged: checkpoints and laps agreed throughout, but a client's finish was its own timer's on a car the host never saw cross the line; now every result is the host's measure on every machine. The place a second finisher's machine showed, the leaver, the late loader, the bandwidth, the deviations from MM2 and what is left open. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/review/multiplayer-desync-rules.md | 326 ++++++++++++++++++++++++ 1 file changed, 326 insertions(+) create mode 100644 docs/review/multiplayer-desync-rules.md diff --git a/docs/review/multiplayer-desync-rules.md b/docs/review/multiplayer-desync-rules.md new file mode 100644 index 0000000..cb91451 --- /dev/null +++ b/docs/review/multiplayer-desync-rules.md @@ -0,0 +1,326 @@ +# Multiplayer desync: the rules + +Reviewed and reworked on 2026-10-09 on top of integration (cae6989, the +host simulating every player's car; then 14dc206, the host-run props; then +04112e1, the players' cars' second round). + +This is open item O3 of docs/review/multiplayer-desync-cars.md: with every +player's car simulated by the host, each player's machine still decided its +own checkpoints, laps, finish and time, and in Cops and Robbers its own +pickups, drops and deliveries, and told the others; a client could claim +any of them. The maintainer's decision ("I think the server needs to be the +authority of everything") makes the host decide them from its simulated +cars and tell the clients. MM2's rules themselves are unchanged; only who +decides is. The design is in docs/multiplayer.md, "Rules". + +## MM2's rules in a network race + +Read from MM2Recomp (the decompile and the asm of `mmMultiRace`, +`mmMultiCircuit`, `mmMultiBlitz`, `mmGameMulti`, `mmMultiCR`): + +* **Checkpoints** (`mmWaypoints::Update`) run on each machine for its own + car, once a frame, after `UpdateGame`. The count (mmPlayer +0x2254) goes + in every position packet (`mmGameMulti::SendPosition`) and is all the + others know of a player's progress. +* **Standings** (`mmGameMulti::UpdateScore`): each machine ranks its own car + among the others' counts and positions (more waypoints, finished, or level + and nearer its own target) and numbers the other cars' icons (the same, + measured to that car's next waypoint, with the machine's own car measured + to its own target). It stops ranking its own car once its waypoints are + done. +* **The finish** (`UpdateGame` state 3): the machine's own timer. The host + shows its own line ("finished in", 148 / 105 / 93), sends + `SendFinishAck` (0x1f7) and sorts it in (`SortResults`); a client sends + `SendFinishReq` (0x206) and shows its line (149 / 106 / 95) only when the + host's 0x1f7 about it comes back with the time. The host plays the + Messagenote and shows another player's line (152 / 110 / 99) on 0x206, a + client on that player's 0x1f7 (150 / 107 / 96). +* **The timeout** (`mmMultiRace::SetTimeoutOn`): the first finish arms 60 s + in a checkpoint race, 120 s in a circuit. Only the host acts when it runs + out: 0x1fe makes every machine still racing brake, show "Race over" (143 on + the host, 153 / 111 on a client) and send a did-not-finish (86400); the + host ends the race once it has counted as many results as the session has + players (0x211, state 5: the results). A Blitz ends on each machine's own + clock (a did-not-finish), and the host's own clock running out sends 0x1fe + (97 on a client, with the net alert). +* **Cops and Robbers** (`mmMultiCR`): `ImpactCallback` (a damaging impact of + 250 or more from another player's car knocks the carrier's gold loose, 2 s + lockout), `UpdateGame` state 4 (a wreck: 5 s out, the gold dropped), + `HitWaterHandler` / `DropThruCityHandler` (the gold back to its place), + `UpdateGold` (free gold within 5 m in the car's room: the host takes it at + once with another player in the game, a client asks with 0x25e and hides + the gold until 0x25a), `UpdateBank` / `UpdateHideout` (delivery within + 12 m: repair, 100 points, 600 to the others, and the host's `GetNewSet`, + 0x261) all run on each machine for its own car; the host grants pickups + while nobody carries the gold and alone checks the limits (`UpdateLimit`, + `SendLimitReached`). +* **Cruise** (`mmMultiRoam`) has no rules beyond the wreck penalty + (0x210 / 0x1fd) and the water's reset to the start. + +## What was decided where before (integration at cae6989 / 14dc206) + +OpenMM2 followed MM2's peers, with every machine also running the finish +timeout itself and counting the results against the players still in the +race. Since protocol 5 the host simulates every player's car, so each +client's car exists twice: the client's prediction, on which its own rules +ran, and the host's simulation, on which nothing did. Where they part (a +collision corrected a sample later, a frame skipping a sample, a car braking +for a finish the host's car has not reached) the client's word and the +host's simulation disagree, and the client's word won. Nothing checked it: +a modified client could send any checkpoint, finish time or gold event and +every machine took it. + +## What changed + +1. **The tracker** (51c9f89): mmWaypoints' rules move out of `Session` into + `game::session::WaypointTracker`, unchanged; the session shows what it + reports. Every single-player and race test passes unchanged. +2. **The referee, the client's corrections, Cops and Robbers on the host, + the message** (6731eea): `game::session::RaceReferee`, `Session`'s + net-rules mode, `CopsAndRobbers::updateHost` / `updatePredicted` / + `applyHost` / `adopt`, `net/RulesState.h`, `game::NetRules`. +3. **Wired in** (9ab7293; protocol 8 on this branch, 10 once merged above + the players' cars' 9): the race screen feeds the referee every car after + every sample, sends each player its message and applies the host's word; + the host's event filter refuses a player's own rule events. +4. **Tests** (8185a2e); the report tool, the autopilot and the trace lines, + development aids (968a428, 760c8b0, 657a493, baf989a); the autopilot's + impact callback (619ce6f). +5. **A respawn only where the car has been** (4128b00): O4 of the players' + cars review, for races; since the merge of 04112e1 it is the race's + respawn points of `game::ResetRules`. +6. **Found by the runs** (1493217, dd41ea6, 4ac627b): a client still + counting down when the host times the race out ends it; a race this + machine already ended stays stopped through the host's word; a client + does not predict a pickup another car is at too. +7. **Merged with the players' cars' second round** (b80cce3, 04112e1): + protocol 10; `game::ResetRules` takes the race's respawn points from the + referee. + +## How it was measured + +* Host and one client on one machine (UDP port 2410, own configuration and + data directories, `[Display] VSync=off`, `FrameCap=120`), the client + through `netprobe relay` (60 ± 20 ms each way, 2 % loss, reordering; and + 150 ± 20 ms, 5 % loss), release builds. +* **Races**: San Francisco's checkpoint race 0 (6 checkpoints and the + finish) and circuit 0 (2 laps of 9 gates), both cars driven by MM2's racer + AI along two of the race's opponent lines (`OPENMM2_DEBUG_AUTOPILOT`, added: + `ai::Opponent` run on the car and the car put back, only its throttle, + brakes and wheel used as the car's input), so they race each other and + collide; 230 s and 220 s. +* **Cops and Robbers** (Free-For-All): both cars start 12 m either side of + the first gold facing each other (`OPENMM2_DEBUG_START_GOLD`, added; the + places drawn from one seed, `OPENMM2_DEBUG_CR_SEED`) and ram each other + through it again and again (`OPENMM2_DEBUG_INPUT`, the players' cars + review's "ram" inputs); 150 s. +* `OPENMM2_NET_TRACE` with the rules lines (added, `game/net/RulesTrace.h`) + on both machines and `netprobe rulesreport` (added) on the traces: each + player's checkpoints as the host's referee counted them against what that + player's HUD showed, the results on each machine against the host's + measure, and each gold event against the host's simulation (the taker's + car within 5 m of the gold where the host draws it, 7 m allowed; a knock + backed by a collision of 250 or more of the carrier's own on the host). +* **Before** is integration (14dc206) with only the trace lines, the + autopilot, `mp:race` and a shadow referee on the host (the same + `RaceReferee`, deciding nothing, traced as the host's measure); **after** + is this branch before the merge of 04112e1 (protocol 8), and **merged** + the branch with it (protocol 10). The before build's tree is + integration's plus `patch_before.py` (kept with the scratch files, see + "Reproducing"). +* The before and after times are not comparable with each other: the + before build's autopilot ignored the AI's reverse gear (it gave the car + throttle), the after build's brakes then (`AUTO REVERSE`), and both cars + pull away from the grid more slowly after (1.7 against 6.9 m/s half a + second after Go). The before build with the after build's pedals + (`race-before-rev-60ms`) pulls away as slowly and times the checkpoint + race's cars 0:28.683 and 0:35.308, against after's 0:28.667 and 0:35.300, + with the client's finish again never crossed on the host. What is + compared is each run's machines against each other and against the + host's measure. + +## Results + +Player 0 is the host, player 1 the client. "Shown, never counted" is a +checkpoint the client's HUD showed that the host's referee did not count; +"lead" is how long before the host counted it the client showed a predicted +checkpoint (median, 1st-99th percentile). + +### Checkpoint race (6 checkpoints and the finish) + +| Run | Link | Checkpoints counted / shown (client) | Shown, never counted | Taken back | Lead | Host's time (each machine / host's measure) | Client's time (each machine / host's measure) | +| --- | --- | --- | --- | --- | --- | --- | --- | +| before | 60 ms | 6 / 6 | 0 | 0 | 142 ms | 0:25.941 / 0:25.950 | 0:33.516 / never crossed | +| after | 60 ms | 6 / 6 | 0 | 0 | 152 (119-252) ms | 0:28.667 / 0:28.667 | 0:35.300 / 0:35.300 | +| merged | 60 ms | 6 / 6 | 0 | 0 | 138 (130-146) ms | 0:28.333 / 0:28.333 | 0:36.167 / 0:36.167 | +| before | 150 ms | 6 / 6 | 0 | 0 | 233 ms | 0:25.975 / 0:25.983 | 0:33.449 / never crossed | +| after | 150 ms | 6 / 6 | 0 | 0 | 238 (229-238) ms | 0:28.367 / 0:28.367 | 0:36.033 / 0:36.033 | + +Before, the client's result was its own timer's and the host's simulation +of its car **never crossed the line**: the client braked at its predicted +finish and told the host, the host ended the race on it (every player +counted), and its simulation of the car stopped 1.8 m (60 ms) and 2.5 m +(150 ms) short of where the client's own car stopped. Every machine showed +the client's word. The host's own time was 8 ms off the host's measure +(its frame timer against its samples). After, every result is the host's +measure, the same on both machines. + +### Circuit (2 laps of 9 gates) + +| Run | Link | Checkpoints counted / shown (client) | Shown, never counted | Taken back | Lead | Host's time (each machine / measure) | Client's time (each machine / measure) | +| --- | --- | --- | --- | --- | --- | --- | --- | +| before | 60 ms | 18 / 18 | 0 | 0 | 144 ms | 2:22.217 / 2:22.217 | 2:06.908 / 2:06.933 | +| after | 60 ms | 18 / 18 | 0 | 0 | 141 (132-141) ms | 2:24.350 / 2:24.350 | 2:09.933 / 2:09.933 | +| before | 150 ms | 18 / 18 | 0 | 0 | 238 ms | 2:22.483 / 2:22.483 | 2:06.958 / 2:06.983 | +| after | 150 ms | 18 / 18 | 0 | 0 | 221 (213-238) ms | 2:24.367 / 2:24.367 | 2:09.917 / 2:09.917 | +| merged | 150 ms | 18 / 18 | 0 | 0 | 236 (226-253) ms | 2:24.833 / 2:24.833 | 2:09.600 / 2:09.600 | + +Before, the client's time was its own timer's, 25 ms off the host's measure +of the same car (its frame timer against the host's samples); after, 0. In +the 150 ms after run the client's car was corrected 73 times and no +checkpoint parted. + +### A client that simulates differently + +`OPENMM2_DEBUG_NETCARS_NOISE=0.002` on the client nudges its car's momentum +every sample, as a build from another compiler might: about 470 corrections +a run, the client's prediction parting from the host's simulation all the +time (60 ms link). + +| Run | Checkpoints counted / shown (client) | Shown, never counted | Host's time / measure | Client's time / measure | +| --- | --- | --- | --- | --- | +| circuit before | 18 / 18 | 0 | 2:22.508 / 2:22.517 | 2:07.066 / 2:07.067 | +| circuit after | 18 / 18 | 0 | 2:24.767 / 2:24.767 | 2:09.867 / 2:09.867 | +| race before | 6 / 6 | 0 | 0:25.966 / 0:25.967 | 0:33.875 / never crossed | +| race after | 6 / 6 | 0 | 0:28.317 / 0:28.317 | 0:35.800 / 0:35.800 | + +The checkpoints agree in every run, before and after: the gates are wide +and a correction moves the car by centimetres. What parted were the +finishes, and the place. + +### The place + +The machine of the player who finished second showed "Place 1/2" from its +finish on, before: its own finish set the place to its count of finishers, +which the other players' finishes never raised (the circuit's host and the +checkpoint race's client, every run). After, the place is the host's and +stops at the finish, as `UpdateScore`'s does (2/2). + +### Cops and Robbers (Free-For-All, ram inputs, 150 s) + +| Run | Link | Gold events (host / client, the same) | Scores (host / client) | Pickups predicted and undone | Pickups with the car over 7 m from the gold (host's simulation) | Knocks without a hit of 250 (host's simulation) | +| --- | --- | --- | --- | --- | --- | --- | +| before | 60 ms | 7 / 7, yes | 0=75 1=25 / the same | 0 | 0 of 4 | 0 of 3 | +| after | 60 ms | 3 / 3, yes | 0=50 1=0 / the same | 1 | 0 of 2 | 0 of 1 | +| merged | 60 ms | 11 / 11, yes | 0=100 1=50 / the same | 1 | 0 of 6 | 0 of 5 | +| before | 150 ms | 11 / 11, yes | 0=100 1=50 / the same | 0 | 0 of 6 | 0 of 5 | +| after | 150 ms | 3 / 3, yes | 0=50 1=0 / the same | 1 | 0 of 2 | 0 of 1 | + +The ram inputs run open loop, so the runs are not the same sequence of +events (the cars end up elsewhere after the first knock); what is compared +is the machines' agreement and the host's simulation backing each event. +With honest players the two machines agreed before as well: MM2's pickup +already went through the host. The undone pickups are where both cars +reached the gold within a few samples of each other: the client showed +"You have the Gold!" and the host gave it to the host's car, which the +client had not yet seen at the gold. Before 4ac627b (no prediction when +another car is within 5 m of the gold as the client has it) the same runs +undid 1 and 2 pickups. + +### Players who leave or join late (circuit, 60 ms) + +* **Leaving**: the client quits the race after gate 5. The host stops + waiting for it, finishes alone (2:24.567) and is back in the lobby with + the results 3 s later. +* **Late**: the client's loading is held back 70 s + (`OPENMM2_DEBUG_LOAD_DELAY_MS`). The host starts without it after 60 s + ("starts without Client: still loading 60008 ms"), the client gets its own + Go 9.8 s after the host's, and its time (2:13.767, from its own release) + is the same on both machines and the host's measure; 18 checkpoints + counted and shown. + +### A client's own word + +`TheHostRefusesEveryPlayersWordOnTheRules` and +`APlayersOwnWordOnTheRulesReachesNobody` (three live `NetGame`s on +loopback): a client's forged `CheckpointReached`, `LapCompleted`, +`RaceFinished`, gold events, old Cops and Robbers messages and a rules +message of its own reach neither the host's game nor the other client; +`TheHostsMessageReachesAClientWhateverIsLostOrForged` drops, repeats, +reorders and forges the host's messages to a client, which shows every +decision once and ends with the host's state. `RulesState`'s fuzz test +decodes random and mutated payloads without a fault. In every run above: +0 messages refused, malformed or stale, 0 decisions missed. + +### Bandwidth + +The rules message is about 4 a second to each client (at once on a +decision or a new hit, every 250 ms otherwise): 88-118 B/s of payload to +each client in a race, 284-293 B/s in Cops and Robbers (the gold's place, +the bases and the scores), plus ENet's and UDP's headers. With eight +players a race's message is about 85 bytes (340 B/s to each client, +2.4 KB/s from the host). Before, each machine sent a checkpoint and a +finish event per event (a few bytes). The players' cars' states are +6.2 KB/s to each client for comparison. + +## Deviations from MM2 + +* The host decides every machine's rules (MM2: each machine its own car's, + the host the finishes' order, the timeout and the end). +* The rules run after every physics sample (MM2: once a frame; the same at + 60 frames a second, and no frame can skip a gate). +* A finish time is the host's measure: its samples from the car's own + release to the sample it crossed the line in (MM2: each machine's timer + from its own Go). +* The host decides everyone's did-not-finish at the timeout at once (MM2: + each machine sent its own after 0x1fe). +* Two cars reaching the gold in the same frame: the first in the host's + order (MM2: the first request to reach the host; the host's own car first). +* A client shows its own pickup at once (MM2's client asked and waited for + 0x25a), unless another car is at the gold too, and its own checkpoints + before the host has counted them; the host's word takes back what it did + not count. +* The place and the icon numbers use the host's view of each car (its + target is the nearest one at its last hit; the Next / Prev. Checkpoint + keys stay on that player's machine). + +## Findings + +| # | Severity | Location (integration) | Finding | Status | +| --- | --- | --- | --- | --- | +| R1 | minor (security) | `RaceScreen::handleSessionEvents`, `updateNetRace`, `updateCopsAndRobbers` | Each player's machine decided its own checkpoints, laps, finish and time and its gold events, and every other machine took its word: a client could claim them. | fixed: the host decides them from its simulation and refuses a player's own rule events (protocol 10) | +| R2 | visible | the same | A client's finish came from its predicted car, which braked at it: the host's simulation of that car stopped short of the line (1.8-2.5 m in every checkpoint race), and its time was the client's timer's (up to 25 ms off the host's measure in the circuits). | fixed: the host's simulation decides and times every finish | +| R3 | minor | `Session` (net race) | Every machine ran the finish timeout from the first finish it heard of, so the machines ended the race at different moments. | fixed: the host's alone (as MM2's) | +| R4 | minor (parity) | `Session::updateNetRace` | The icon number measured this machine's own car against another car level with it to that car's next waypoint; `UpdateScore` measures both to this machine's own target. | fixed | +| R5 | minor (parity) | `Session::updateRace` | A client's own finish line was the host's (148 / 105 / 93) and showed at once; MM2's client shows its own (149 / 106 / 95) when the host's 0x1f7 arrives. | fixed: shown with the host's time when the host's decision arrives | +| R6 | minor | `RaceScreen::netCommandAllowed` | A client's water respawn could put its car on any checkpoint (players' cars review O4). | fixed for races: only at the start or a checkpoint the host counted for it (`game::ResetRules`' respawn points) | +| R7 | crash (development aid only) | `game::NetAutopilot` (new) | The autopilot's AI took the player's car's impact callback and left it pointing at a destroyed driver. | fixed before measuring (619ce6f) | +| R8 | visible | `Session::playerFinished` | A machine whose player finished second or later showed "Place 1/N" from the finish on: the place was its own count of finishers, which network finishes never raised. | fixed: the host's place, kept from the finish on | +| O1 | minor | the host's frame | The host's own car is decided with no latency; a client's a trip later. Its finish time does not depend on it (measured in its own samples), but a client's checkpoints are confirmed a trip later (they show at once, predicted). | open: inherent | +| O2 | minor | Cops and Robbers | Whether a car was in the water (its reset) is still its player's word, checked by `game::ResetRules` (five seconds in the water on the host's simulation) before the host applies it. | open: the host could run the water handler itself on the cars it simulates | +| O3 | minor | Cops and Robbers | A predicted pickup is undone when the host gave the gold to a car the client had not yet seen at the gold (the other car's state a trip old): one in each run. | open: inherent to predicting; MM2's client waited for 0x25a | +| O4 | minor | `game::ResetRules` | Its Cops and Robbers repair exception (any repair allowed) is now only needed for a client's own command; the host repairs a deliverer's car itself. | open: could be narrowed | + +## Reproducing + +The scripts are kept with the scratch files of this review +(`desync-rules/`); the runs need the retail ISO. + +``` +batch.sh # before and after, both links: run.sh (races, autopilot), run_cr.sh (Cops and Robbers) +batch2.sh # the leaver (OPENMM2_POPUP_SCRIPT quits the race), noise runs +batch3.sh # noise, Cops and Robbers after 4ac627b, the late loader (run_late.sh) +batch4.sh # the merged build: one race, one circuit, one Cops and Robbers +report.sh [run]... # netprobe rulesreport , and the bandwidth lines +finishpos.py # where each machine had a car around its finish +``` + +`run.sh ` +starts the relay on 2411 in front of a host on 2410, the host +(`profile:Host;city:sf;mp:host;mp:mode:;mp:race::;wait:1500;mp:start`) +and the client (`profile:Client;mp:join:127.0.0.1:2411;wait:100;mp:ready`), +each with `OPENMM2_NET_TRACE`, `OPENMM2_DEBUG_NETCARS` and +`OPENMM2_DEBUG_AUTOPILOT=<0|1>`; `run_cr.sh` gives the cars +`OPENMM2_DEBUG_START_GOLD=12`, `OPENMM2_DEBUG_CR_SEED=12527` and the ram +inputs instead; `run_extra.sh` takes a `CLIENT_ENV`; `run_late.sh` runs at +`FrameCap=100` so that 300 s fit in `--frames`. From f7b76fae0d8134ff93222e505836d6f06fb07f96 Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Fri, 9 Oct 2026 19:17:01 -0700 Subject: [PATCH 083/129] List the host-decided rules' files in the parity manifest Waypoints is mmWaypoints::Update moved out of Session (P), RaceReferee the host's side of MM2's race rules over OpenMM2's networking (M); the rules message, its replication, the trace and the development autopilot are OpenMM2's own. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/parity/README.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/docs/parity/README.md b/docs/parity/README.md index a6d7aaa..8861f73 100644 --- a/docs/parity/README.md +++ b/docs/parity/README.md @@ -412,12 +412,16 @@ audited in [openmm2-only.md](openmm2-only.md). | `src/game/fx/VehicleEffects.h` | 102 | P | rendering-fx | | `src/game/fx/Weather.cpp` | 37 | P | rendering-fx | | `src/game/fx/Weather.h` | 44 | P | rendering-fx | +| `src/game/net/Autopilot.cpp` | 133 | O | - | +| `src/game/net/Autopilot.h` | 69 | O | - | | `src/game/net/DamageSync.cpp` | 503 | O | - | | `src/game/net/DamageSync.h` | 283 | O | - | | `src/game/net/NetGame.cpp` | 659 | M | session | | `src/game/net/NetGame.h` | 229 | M | session | | `src/game/net/NetProps.cpp` | 230 | O | - | | `src/game/net/NetProps.h` | 120 | O | - | +| `src/game/net/NetRules.cpp` | 389 | O | - | +| `src/game/net/NetRules.h` | 164 | O | - | | `src/game/net/NetTrafficCars.cpp` | 101 | O | - | | `src/game/net/NetTrafficCars.h` | 106 | O | - | | `src/game/net/PlayerCars.cpp` | 483 | O | - | @@ -426,6 +430,8 @@ audited in [openmm2-only.md](openmm2-only.md). | `src/game/net/PropSync.h` | 263 | O | - | | `src/game/net/RaceStart.cpp` | 108 | M | session | | `src/game/net/RaceStart.h` | 106 | M | session | +| `src/game/net/RulesTrace.cpp` | 58 | O | - | +| `src/game/net/RulesTrace.h` | 50 | O | - | | `src/game/net/TrafficPrediction.cpp` | 164 | O | - | | `src/game/net/TrafficPrediction.h` | 112 | O | - | | `src/game/net/TrafficProxies.cpp` | 101 | O | - | @@ -440,11 +446,15 @@ audited in [openmm2-only.md](openmm2-only.md). | `src/game/session/Gate.h` | 44 | P | session | | `src/game/session/Hud.cpp` | 916 | P | session | | `src/game/session/Hud.h` | 244 | P | session | +| `src/game/session/RaceReferee.cpp` | 252 | M | session | +| `src/game/session/RaceReferee.h` | 139 | M | session | | `src/game/session/RaceSetup.cpp` | 296 | P | session | | `src/game/session/RaceSetup.h` | 105 | P | session | | `src/game/session/Session.cpp` | 1321 | P | session | | `src/game/session/Session.h` | 269 | P | session | | `src/game/session/Types.h` | 115 | P | session | +| `src/game/session/Waypoints.cpp` | 227 | P | session | +| `src/game/session/Waypoints.h` | 88 | P | session | | `src/game/world/CableCars.cpp` | 830 | P | mm2-world-objects | | `src/game/world/CableCars.h` | 164 | P | mm2-world-objects | | `src/game/world/Gizmos.cpp` | 885 | P | mm2-world-objects | @@ -471,6 +481,7 @@ audited in [openmm2-only.md](openmm2-only.md). | `src/net/PropState.h` | 215 | O | - | | `src/net/Protocol.cpp` | 31 | O | - | | `src/net/Protocol.h` | 587 | O | - | +| `src/net/RulesState.h` | 212 | O | - | | `src/net/Session.cpp` | 942 | O | - | | `src/net/Session.h` | 253 | O | - | | `src/net/Sha256.cpp` | 98 | O | - | From 782f4698c495f645a03212aba880da131453334e Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Fri, 9 Oct 2026 19:24:30 -0700 Subject: [PATCH 084/129] Give the race start tests their own port block test_netgame_start.cpp took ports from 29000-31998, inside test_damage_sync.cpp's 29000-32999, so the two could bind the same port when ctest ran them in parallel (NetGameStart.PlayerLeavingTheRaceReleases- TheStart failed once that way). It now uses 17000-19999, and TestData.h lists every network test file's block. Co-Authored-By: Claude Opus 5.5 (1M context) --- tests/TestData.h | 9 +++++++++ tests/game/test_netgame_start.cpp | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/tests/TestData.h b/tests/TestData.h index c141edb..a0cf091 100644 --- a/tests/TestData.h +++ b/tests/TestData.h @@ -4,6 +4,15 @@ #include +// Network tests that host a session pick their ports from their own block, +// all below 49152 (Windows' dynamic range): two files sharing a block can bind +// the same port when ctest runs them in parallel. Taken blocks: +// 17000-19999 game/test_netgame_start 21000-24999 net/test_player_cars +// 25000-28999 game/test_net_race_rules 29000-32999 game/test_damage_sync +// 33000-36999 game/test_netgame_lobby 37000-40999 game/test_netgame_sync +// 41000-44999 game/test_netgame 45000-48999 game/test_traffic_net +// Other network tests bind port 0. + namespace mm2::test { // Retail game files mounted from $OPENMM2_GAME_DATA (disc image, mounted diff --git a/tests/game/test_netgame_start.cpp b/tests/game/test_netgame_start.cpp index 3e85251..a8a1a30 100644 --- a/tests/game/test_netgame_start.cpp +++ b/tests/game/test_netgame_start.cpp @@ -28,7 +28,7 @@ constexpr float kFrame = 1.0f / 60.0f; // distinct ports per process for parallel ctest runs. std::uint16_t basePort() { static const std::uint16_t port = static_cast( - 29000 + (Clock::now().time_since_epoch().count() / 1000) % 1500 * 2); + 17000 + (Clock::now().time_since_epoch().count() / 1000) % 1500 * 2); return port; } From 0c961027be80dd30921237f576543a4ad6975152 Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Fri, 9 Oct 2026 19:49:32 -0700 Subject: [PATCH 085/129] Trace when a client learns of its checkpoints and when the water resets a car Development aids for measuring the rules' rough edges: * OPENMM2_NET_TRACE's RC line: a client learnt how many waypoints the host counted for its car; RW: a car put back by the water or a fall (the host for every car it carries the reset out on, a client for its own), with the sample and the place. * netprobe rulesreport: how long after a predicted checkpoint showed the host's word on it arrived, and each machine's water resets against the host's (the same sample, the same place). * mm2tool psdlinfo : where the room's perimeter runs. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/app/RaceScreen.cpp | 8 +++ src/game/net/NetRules.cpp | 4 ++ src/game/net/NetRules.h | 1 + src/game/net/RulesTrace.cpp | 11 ++++ src/game/net/RulesTrace.h | 9 ++++ tools/mm2tool/cmd_city.cpp | 15 ++++++ tools/netprobe/RulesReport.cpp | 98 ++++++++++++++++++++++++++++++++-- 7 files changed, 143 insertions(+), 3 deletions(-) diff --git a/src/app/RaceScreen.cpp b/src/app/RaceScreen.cpp index 91ad5d5..07f5b97 100644 --- a/src/app/RaceScreen.cpp +++ b/src/app/RaceScreen.cpp @@ -2169,12 +2169,17 @@ class RaceScreen final : public Screen { // (back to the reset position); with them mmSingleCircuit / // mmGameMulti::HitWaterHandler reset the car at the last // checkpoint and put the reset position back. + const std::uint32_t sample = + m_traceNet && m_traceNet->isHost() ? m_netOwnSample + 1 : m_prediction.nextSeq(); if (m_session->setup().checkpoints.empty()) { netCommand({0, net::CarCommandKind::Reset, {}, 0.0f}); } else { const Mat34 at = m_session->respawnTransform(); netCommand({0, net::CarCommandKind::RespawnAt, at.m3, phys::resetRotationOf(at)}); } + if (m_traceNet) + game::traceWaterReset(m_traceNet->traceFile(), m_traceNet->frameTime(), + m_traceNet->localId(), sample, m_player->sim().body.ics.matrix.m3); m_crWaterHandled = m_cr != nullptr; // mmMultiCR::HitWaterHandler drops the gold if (m_vehicleFx) m_vehicleFx->reset(); // vehCar::Reset @@ -5179,6 +5184,9 @@ class RaceScreen final : public Screen { if (c.kind == net::CarCommandKind::Reset || c.kind == net::CarCommandKind::RespawnAt) m_crResets.insert(id); game::NetCarDriver::command(*rv.sim, c); + if (c.kind == net::CarCommandKind::Reset || c.kind == net::CarCommandKind::RespawnAt) + game::traceWaterReset(m_traceNet->traceFile(), m_traceNet->frameTime(), id, c.seq, + rv.sim->sim().body.ics.matrix.m3); if (c.kind != net::CarCommandKind::ClearDamage) rv.lastMoveSeq = c.seq; ++rv.resets; diff --git a/src/game/net/NetRules.cpp b/src/game/net/NetRules.cpp index efdecd3..0374826 100644 --- a/src/game/net/NetRules.cpp +++ b/src/game/net/NetRules.cpp @@ -367,6 +367,10 @@ void NetRules::applyRaceState(const net::RulesMsg& m, const Vec3& carPosition) { if (!s) return; s->applyNetProgress({m.evaluated, m.firstHit, m.hits}, carPosition); + if (const std::size_t n = m.firstHit + m.hits.size(); n > m_confirmedHits) { + m_confirmedHits = n; + traceRuleConfirmed(m_trace, m_frameTime, m_setup.self, static_cast(n)); + } s->setNetStanding(m.place, m.racers); m_icons.clear(); for (const auto& [id, place] : m.icons) diff --git a/src/game/net/NetRules.h b/src/game/net/NetRules.h index 57bcf39..a8c0fbd 100644 --- a/src/game/net/NetRules.h +++ b/src/game/net/NetRules.h @@ -158,6 +158,7 @@ class NetRules { std::FILE* m_trace = nullptr; double m_frameTime = 0.0; std::map m_tracedHits; + std::size_t m_confirmedHits = 0; // client: the host's count of its car's hits (the trace's RC) Stats m_stats; }; diff --git a/src/game/net/RulesTrace.cpp b/src/game/net/RulesTrace.cpp index 33d17e3..4f3e765 100644 --- a/src/game/net/RulesTrace.cpp +++ b/src/game/net/RulesTrace.cpp @@ -40,6 +40,17 @@ void traceRuleStanding(std::FILE* f, double frame, int place, int racers) { std::fprintf(f, "RE %.3f %.3f %d %d\n", machineMs(), frame, place, racers); } +void traceRuleConfirmed(std::FILE* f, double frame, int player, int count) { + if (f) + std::fprintf(f, "RC %.3f %.3f %d %d\n", machineMs(), frame, player, count); +} + +void traceWaterReset(std::FILE* f, double frame, int player, std::uint32_t sample, const Vec3& at) { + if (f) + std::fprintf(f, "RW %.3f %.3f %d %u %.3f %.3f %.3f\n", machineMs(), frame, player, sample, at.x, at.y, + at.z); +} + void traceCopsEvent(std::FILE* f, double frame, int type, int car, int value, const Vec3& gold) { if (f) std::fprintf(f, "CG %.3f %.3f %d %d %d %.2f %.2f %.2f\n", machineMs(), frame, type, car, value, diff --git a/src/game/net/RulesTrace.h b/src/game/net/RulesTrace.h index 39f6440..bb52c50 100644 --- a/src/game/net/RulesTrace.h +++ b/src/game/net/RulesTrace.h @@ -22,6 +22,13 @@ // this machine's results took player's finish (ms 86400000: did not) // RE clock frame place racers // this machine's "Place: n/N" changed +// RC clock frame player count +// a client: the host's word counted `count` waypoints for its car +// (it rose) +// RW clock frame player sample x y z +// a car put back by the water or a fall (the HitWaterHandlers, the +// DropThruCityHandlers) at the start of its sample `sample`, to +// (x, y, z): the host for every car it simulates, a client for its own // CG clock frame type car value x y z // Cops and Robbers: this machine's HUD showed a gold event // (CopsAndRobbers::EventType; value: the points, or 1 for a hit), the @@ -44,6 +51,8 @@ void traceRuleHostHit(std::FILE* f, double frame, int player, std::uint32_t samp int lap, bool shown); void traceRuleFinish(std::FILE* f, double frame, int player, std::uint32_t ms); void traceRuleStanding(std::FILE* f, double frame, int place, int racers); +void traceRuleConfirmed(std::FILE* f, double frame, int player, int count); +void traceWaterReset(std::FILE* f, double frame, int player, std::uint32_t sample, const Vec3& at); void traceCopsEvent(std::FILE* f, double frame, int type, int car, int value, const Vec3& gold); void traceCopsScores(std::FILE* f, double frame, const std::vector>& scores); diff --git a/tools/mm2tool/cmd_city.cpp b/tools/mm2tool/cmd_city.cpp index 0c2c549..ffc63f1 100644 --- a/tools/mm2tool/cmd_city.cpp +++ b/tools/mm2tool/cmd_city.cpp @@ -72,6 +72,21 @@ int cmdPsdlInfo(std::span args) { for (const auto& pt : rm.perimeter) std::print(" {}->{}", pt.vertex, pt.neighbor); std::println(""); + // Where the perimeter runs (x z, and the centre of its points). + Vec3 centre; + int counted = 0; + std::print(" at:"); + for (const auto& pt : rm.perimeter) + if (pt.vertex < p->vertices.size()) { + const Vec3& v = p->vertices[pt.vertex]; + std::print(" ({:.1f} {:.1f})", v.x, v.z); + centre = centre + v; + ++counted; + } + if (counted > 0) + std::print(" centre ({:.1f} {:.1f} {:.1f})", centre.x / static_cast(counted), + centre.y / static_cast(counted), centre.z / static_cast(counted)); + std::println(""); for (const auto& a : rm.attributes) { std::print(" {}{} sub {}:", city::psdlAttrTypeName(a.type), a.last ? "*" : "", a.subtype); for (auto w : a.args) diff --git a/tools/netprobe/RulesReport.cpp b/tools/netprobe/RulesReport.cpp index 3a8471f..87b2c60 100644 --- a/tools/netprobe/RulesReport.cpp +++ b/tools/netprobe/RulesReport.cpp @@ -8,6 +8,9 @@ // players still decided their own: the host's measure of it) // RF a finish in a machine's results // RE a machine's "Place: n/N" +// RC a client learning how many waypoints the host counted for it +// RW a car put back by the water or a fall (the host: every car it +// simulates; a client: its own) // CG / CS Cops and Robbers' gold events and scores as each machine shows // them // @@ -62,6 +65,12 @@ struct Hit { int a = 0, b = 0; double strength = 0; }; +struct Water { + double clock = 0; + int player = 0; + unsigned sample = 0; + double x = 0, y = 0, z = 0; +}; struct Machine { std::string path; @@ -74,6 +83,8 @@ struct Machine { std::map scores; std::map> drawn; // every player's car as this machine draws it std::vector hits; // collisions between players' cars here + std::vector> confirmed; // (clock, the host's count of its car's waypoints) + std::vector water; double firstClock = 0, lastClock = 0; }; @@ -138,6 +149,14 @@ bool load(const std::string& path, Machine& m) { continue; auto& map = tag == "RF" ? m.finishes : m.refereeFinishes; map.try_emplace(player, Finish{clock, ms}); + } else if (tag == "RC") { + int player = 0, count = 0; + if (s >> player >> count) + m.confirmed.emplace_back(clock, count); + } else if (tag == "RW") { + Water w{clock}; + if (s >> w.player >> w.sample >> w.x >> w.y >> w.z) + m.water.push_back(w); } else if (tag == "RE") { int place = 0, racers = 0; if (s >> place >> racers) @@ -194,12 +213,19 @@ void checkpoints(const Machine& host, const Machine& m, double endClock) { } std::vector counted; std::vector hostHits; + std::vector ordinals; // each one's place among all the car's hits (RC counts them all) // (A checkpoint race's finish clears no waypoint the HUD shows.) - for (const auto& h : host.hostHits) - if (h.player == m.self && h.shown) { + int ordinal = 0; + for (const auto& h : host.hostHits) { + if (h.player != m.self) + continue; + if (h.shown) { counted.push_back(h.index); hostHits.push_back(&h); + ordinals.push_back(ordinal); } + ++ordinal; + } std::vector finalSeq; std::vector finalShown; int predicted = 0, fromHost = 0, takenBack = 0; @@ -215,7 +241,7 @@ void checkpoints(const Machine& host, const Machine& m, double endClock) { const auto hostKeys = keys(counted); const auto shownKeys = keys(finalSeq); int shownNotCounted = 0, pendingAtEnd = 0, countedNotShown = 0; - std::vector leadPredicted, lagHostWord; + std::vector leadPredicted, lagHostWord, confirmedAfter; for (std::size_t i = 0; i < shownKeys.size(); ++i) { const auto it = std::ranges::find(hostKeys, shownKeys[i]); if (it == hostKeys.end()) { @@ -225,6 +251,13 @@ void checkpoints(const Machine& host, const Machine& m, double endClock) { const auto at = static_cast(it - hostKeys.begin()); const double dt = finalShown[i]->clock - hostHits[at]->clock; (finalShown[i]->kind ? lagHostWord : leadPredicted).push_back(dt); + // When this machine learnt that the host had counted it (RC). + if (finalShown[i]->kind == 0) + for (const auto& [clock, count] : m.confirmed) + if (count > ordinals[at]) { + confirmedAfter.push_back(clock - finalShown[i]->clock); + break; + } } for (const auto& k : hostKeys) if (std::ranges::find(shownKeys, k) == shownKeys.end()) @@ -245,6 +278,61 @@ void checkpoints(const Machine& host, const Machine& m, double endClock) { if (!lagHostWord.empty()) std::println(" the host's own shown {:.0f} ms after it counted them (median; 99% {:.0f})", percentile(lagHostWord, 0.5), percentile(lagHostWord, 0.99)); + if (!confirmedAfter.empty()) + std::println(" the host's word on predicted ones arrived {:.0f} ms after they were shown " + "(median; 1% {:.0f}, 99% {:.0f}, most {:.0f})", + percentile(confirmedAfter, 0.5), percentile(confirmedAfter, 0.01), + percentile(confirmedAfter, 0.99), percentile(confirmedAfter, 1.0)); +} + +// Each machine's own car put back by the water or a fall against the +// host's doing it to that car: the same samples, the same places. +void water(const std::vector& machines) { + const Machine& host = machines.front(); + for (std::size_t i = 0; i < machines.size(); ++i) { + const Machine& m = machines[i]; + if (m.self < 0) + continue; + std::vector own, hosts; + for (const auto& w : m.water) + if (w.player == m.self) + own.push_back(&w); + for (const auto& w : host.water) + if (w.player == m.self) + hosts.push_back(&w); + if (i == 0) { + std::println(" player {} (the host): {} times", m.self, own.size()); + continue; + } + // Each of this machine's matched with the host's nearest in samples + // (within 2 s), each of the host's used once. + const auto apart = [](const Water* a, const Water* b) { + return std::abs(static_cast(a->sample) - static_cast(b->sample)); + }; + std::vector used(hosts.size(), false); + int matched = 0, sameSample = 0; + double worstSamples = 0, worstMetres = 0; + for (const Water* w : own) { + std::size_t best = hosts.size(); + for (std::size_t k = 0; k < hosts.size(); ++k) + if (!used[k] && apart(hosts[k], w) <= 120.0 && + (best == hosts.size() || apart(hosts[k], w) < apart(hosts[best], w))) + best = k; + if (best == hosts.size()) + continue; + used[best] = true; + ++matched; + const double ds = apart(hosts[best], w); + sameSample += ds == 0.0 ? 1 : 0; + worstSamples = std::max(worstSamples, ds); + worstMetres = std::max( + worstMetres, std::hypot(hosts[best]->x - w->x, hosts[best]->y - w->y, hosts[best]->z - w->z)); + } + std::println(" player {} ({}): {} times here, {} on the host; {} matched ({} at the same sample, " + "{:.0f} samples and {:.2f} m apart at most), {} here only, {} on the host only", + m.self, m.path, own.size(), hosts.size(), matched, sameSample, worstSamples, worstMetres, + static_cast(own.size()) - matched, static_cast(hosts.size()) - matched); + } } void finishes(const std::vector& machines) { @@ -432,6 +520,10 @@ int rulesReport(const RulesReportOptions& options) { std::println(" {}: \"Place\" changed {} times, last {}/{}", m.path, m.standings.size(), m.standings.back().first, m.standings.back().second); } + if (std::ranges::any_of(machines, [](const Machine& m) { return !m.water.empty(); })) { + std::println("the water and falls (each machine's own car against the host's):"); + water(machines); + } if (cops) { std::println("Cops and Robbers:"); gold(machines); From 557ade87dced6fbd76c9d259287275e932899b93 Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Fri, 9 Oct 2026 20:19:57 -0700 Subject: [PATCH 086/129] Send the players' cars' states every sample, with the inputs ahead The host now sends each client its CarStates after every frame that ran a sample (up to 60 a second; OPENMM2_NET_STATE_HZ for fewer), the three nearest other cars within 60 m as the near set, as many of them in full as keep the message in one ENet packet (in turn; ENet would send a longer unsequenced packet as reliable fragments), and for another client's car the inputs the host already holds for its next samples, which the receiving client runs it on (protocol 11). A client draws a near car at its player's present (where its trail stood a lead back, little predicted) unless the two may meet within half a second, and at its own car's time then, sliding between over a quarter second (OPENMM2_NET_NEAR_DRAW=ahead keeps the old drawing); it runs the near cars again only when the host's state or inputs differ from what it ran them on. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/app/RaceScreen.cpp | 234 +++++++++++++++++++++++++++++--- src/game/net/PlayerCars.cpp | 38 +++++- src/game/net/PlayerCars.h | 22 ++- src/net/PlayerCars.h | 31 ++++- src/net/Protocol.h | 5 +- src/net/Session.cpp | 12 +- src/net/Session.h | 5 +- tests/game/test_player_cars.cpp | 35 ++++- tests/net/test_player_cars.cpp | 41 +++++- 9 files changed, 371 insertions(+), 52 deletions(-) diff --git a/src/app/RaceScreen.cpp b/src/app/RaceScreen.cpp index 91ad5d5..8e1aaac 100644 --- a/src/app/RaceScreen.cpp +++ b/src/app/RaceScreen.cpp @@ -790,6 +790,7 @@ class RaceScreen final : public Screen { for (auto& [id, rv] : m_remotes) { if (rv.simulated || !rv.sim) continue; + updateDrawAhead(rv); rv.blend.update(m_frameDt); if (const auto base = remoteDrawnBase(id, rv)) m_remoteDrawn[id] = rv.blend.apply(*base); @@ -4861,8 +4862,8 @@ class RaceScreen final : public Screen { // OPENMM2_NET_OTHERS set, the experiments), switching each between that // and its interpolated states; the ones simulated, as the // reconciliation's companions. - void predictNearCars(const std::vector& near, - std::vector& companions) { + void predictNearCars(const std::vector& nearIds, const std::vector& near, + std::uint32_t ack, std::vector& companions) { static const bool others = [] { const char* v = std::getenv("OPENMM2_NET_OTHERS"); return v && *v; @@ -4872,9 +4873,14 @@ class RaceScreen final : public Screen { continue; const auto it = std::ranges::find(near, id, &net::NearCarState::id); const auto rc = std::ranges::find(m_remoteCars, id, &game::NetRemoteCar::id); - const bool wanted = !others && it != near.end() && !rv.sim->trailer() && - rc != m_remoteCars.end() && rc->hasState && !m_netLeft.contains(id); + // A near car joins with a state of its own; one in the near set + // whose state waits for a later message runs on meanwhile. + const bool listed = std::ranges::find(nearIds, id) != nearIds.end() && + (it != near.end() || rv.predicted); + const bool wanted = !others && listed && !rv.sim->trailer() && rc != m_remoteCars.end() && + rc->hasState && !m_netLeft.contains(id); auto& sim = rv.sim->sim(); + const bool justPredicted = wanted && !rv.predicted; if (wanted && !rv.predicted) { // As the host simulates it (loadRemoteCar's `simulated`), but // its damage stays the host's (updateNetCarDamage). @@ -4885,6 +4891,8 @@ class RaceScreen final : public Screen { sim.ownRandom = true; rv.driver.attach(*rv.sim); rv.predicted = true; + rv.trail.clear(); + rv.drawAhead = 0.0f; } else if (!wanted && rv.predicted) { sim.body.kinematic = true; sim.body.resetCollider(); @@ -4894,10 +4902,17 @@ class RaceScreen final : public Screen { if (rc != m_remoteCars.end() && rc->hasState) placeNetCar(rv, *rc); } - if (rv.predicted) { + if (rv.predicted && it != near.end()) { rv.input = it->input; - companions.push_back( - {rv.sim.get(), &rv.driver, &it->state, it->input, id < m_traceNet->localId()}); + game::CarPrediction::Companion c; + c.car = rv.sim.get(); + c.driver = &rv.driver; + c.state = &it->state; + c.input = it->input; + c.upcoming = it->upcoming; + c.first = id < m_traceNet->localId(); + c.differs = !justPredicted && nearCarDiffers(rv, ack, *it); + companions.push_back(c); } } } @@ -4922,6 +4937,64 @@ class RaceScreen final : public Screen { car->addTo(*m_world); } } + // Client: a simulated car is drawn at this machine's car's time once the + // two may meet within half a second (8 m apart, or closing on the 5 m + // that leaves at more than that rate), at its player's present once they + // are 12 m apart and a second from meeting, sliding between over a + // quarter of a second (OPENMM2_NET_NEAR_DRAW=ahead draws it at this + // machine's car's time always, the comparison). + void updateDrawAhead(RemoteVehicle& rv) const { + static const bool always = [] { + const char* v = std::getenv("OPENMM2_NET_NEAR_DRAW"); + return v && std::string_view(v) == "ahead"; + }(); + if (!rv.predicted || !m_player) { + rv.drawAhead = 0.0f; + return; + } + const auto& own = m_player->sim().body.ics; + const auto& other = rv.sim->sim().body.ics; + const Vec3 apart = other.matrix.m3 - own.matrix.m3; + const float d = apart.mag(); + const float closing = d > 0.01f ? -(other.linearVelocity - own.linearVelocity).dot(apart) / d : 0.0f; + const float toMeet = closing > 0.01f ? std::max(0.0f, d - 5.0f) / closing : 1e9f; + float& w = rv.drawAhead; + if (always || d < 8.0f || toMeet < 0.5f) + w = std::min(1.0f, w + m_frameDt * 4.0f); + else if (d > 12.0f && toMeet > 1.0f) + w = std::max(0.0f, w - m_frameDt * 4.0f); + } + // Client: whether the host's state of a car it simulates, at its sample + // `ack`, or the inputs the host sent for the samples after, differ from + // what it ran the car on (as its own car's: 3 mm, 3 cm/s, 0.0015 in the + // matrix; any input). + static bool nearCarDiffers(const RemoteVehicle& rv, std::uint32_t ack, const net::NearCarState& n) { + const auto at = std::ranges::find(rv.trail, ack, &RemoteVehicle::TrailPoint::seq); + if (at == rv.trail.end()) + return true; + const Mat34& a = at->ics; + const Mat34& b = n.state.matrix; + if (a.m3.dist(b.m3) > 0.003f || at->velocity.dist(n.state.linearVelocity) > 0.03f) + return true; + for (const auto& [x, y] : {std::pair{a.m0, b.m0}, std::pair{a.m1, b.m1}, std::pair{a.m2, b.m2}}) + if (std::max({std::abs(x.x - y.x), std::abs(x.y - y.y), std::abs(x.z - y.z)}) > 0.0015f) + return true; + const auto same = [](net::CarInputFrame x, net::CarInputFrame y) { + x.events = y.events = 0; + return x == y; + }; + if (!same(at->input, n.input)) + return true; + auto next = std::next(at); + for (std::size_t k = 0; k < n.upcoming.size() && next != rv.trail.end(); ++k, ++next) + if (next->seq != ack + 1 + k || !(next->input == n.upcoming[k])) + return true; + // Ran past what the host sent on its last input: so would the host. + for (; next != rv.trail.end(); ++next) + if (!same(next->input, n.upcoming.empty() ? n.input : n.upcoming.back())) + return true; + return false; + } bool predictedBody(const phys::Body* b) const { for (const auto& [id, rv] : m_remotes) if (rv.predicted && rv.sim && &rv.sim->sim().body == b) @@ -5197,8 +5270,14 @@ class RaceScreen final : public Screen { // A client: the other players' cars it simulates take the input the // host last applied to them. for (auto& [id, rv] : m_remotes) - if (rv.predicted && rv.sim) - rv.driver.apply(*rv.sim, rv.input); + if (rv.predicted && rv.sim) { + rv.lastInput = rv.input; + if (!rv.inputsAhead.empty()) { + rv.lastInput = rv.inputsAhead.front(); + rv.inputsAhead.pop_front(); + } + rv.driver.apply(*rv.sim, rv.lastInput); + } // Where the bodies around its car (the other players' cars, the // police, knocked traffic cars and props) stood for this sample: the // samples run again meet them there. @@ -5237,9 +5316,41 @@ class RaceScreen final : public Screen { ics.linearVelocity = ics.linearMomentum * ics.invMass; } m_prediction.endSample(*m_player, m_netDriver); + recordNearCars(m_prediction.nextSeq() - 1); } std::uint32_t m_noiseState = 1; + // Client: where the cars it simulates stand after its sample `seq`. + void recordNearCars(std::uint32_t seq) { + for (auto& [id, rv] : m_remotes) { + if (!rv.predicted || !rv.sim) + continue; + while (!rv.trail.empty() && rv.trail.back().seq >= seq) + rv.trail.pop_back(); // run again: the newer place + const auto& sim = rv.sim->sim(); + rv.trail.push_back( + {seq, sim.modelMatrix(), sim.body.ics.matrix, sim.body.ics.linearVelocity, rv.lastInput}); + while (rv.trail.size() > 180) + rv.trail.pop_front(); + } + } + // Client: a simulated car's place at (fractional) sample `t`, from its + // trail. + static std::optional trailAt(const RemoteVehicle& rv, double t) { + if (rv.trail.size() < 2 || t < static_cast(rv.trail.front().seq)) + return std::nullopt; + for (std::size_t i = 1; i < rv.trail.size(); ++i) { + const auto& a = rv.trail[i - 1]; + const auto& b = rv.trail[i]; + if (static_cast(b.seq) < t) + continue; + if (b.seq != a.seq + 1) + return std::nullopt; + return game::blendTransform(a.model, b.model, static_cast(t - static_cast(a.seq))); + } + return std::nullopt; + } + // Host: a simulated player's car was reset or its damage cleared // (vehCar::ClearDamage): its damage starts again on every machine. void hostCarReset(std::uint8_t id, RemoteVehicle& rv) { @@ -5307,20 +5418,22 @@ class RaceScreen final : public Screen { const net::Session::OwnCarUpdate* newest = nullptr; std::uint32_t ack = 0; for (const auto& u : updates) { - m_netWaiting.push_back(u.waiting); + m_netWaiting.emplace_back(u.arrival, u.waiting); if (u.hasOwn && u.ack > ack) { newest = &u; ack = u.ack; } updateNetCarLead(u.ack, u.time); } - while (m_netWaiting.size() > 20) // a second of reports - m_netWaiting.pop_front(); + while (m_netWaiting.size() > 1 && m_netWaiting.back().first - m_netWaiting.front().first > 1000.0) + m_netWaiting.pop_front(); // a second of reports // The host keeps a sample of this car's inputs in hand at the least // (HostInputQueue): below that the samples run 3% faster (the inputs // reach it sooner), above three 2% slower. if (!m_netWaiting.empty()) { - const std::int32_t least = *std::ranges::min_element(m_netWaiting); + const std::int32_t least = std::ranges::min_element(m_netWaiting, {}, [](const auto& w) { + return w.second; + })->second; const float target = least < 1 ? 1.03f : (least > 3 ? 0.98f : 1.0f); m_netDilation += (target - m_netDilation) * std::min(1.0f, m_frameDt * 2.0f); } @@ -5350,7 +5463,7 @@ class RaceScreen final : public Screen { predictedBefore.insert(id); } std::vector companions; - predictNearCars(newest->near, companions); + predictNearCars(newest->nearIds, newest->near, ack, companions); // The shared traffic's cars on their rails around this car: the // samples run again meet each where that sample met it (or, the // experiment, where the host had it at that sample's time); after, @@ -5417,10 +5530,31 @@ class RaceScreen final : public Screen { then.push_back({r.id, p->transform}); m_trafficBodies->placeRailCars(then); }, - companions); + companions, [this](std::uint32_t seq) { recordNearCars(seq); }); placeBodies(now); if (!railsNow.empty()) m_trafficBodies->placeRailCars(railsNow); + // The near cars' inputs for the samples to come: the ones the host + // held beyond those just run again, then the last again. + if (c.rebased) + for (const auto& k : companions) + for (auto& [id, rv] : m_remotes) + if (rv.sim.get() == k.car) { + for (auto& t : rv.trail) + if (t.seq > ack && t.seq <= ack + static_cast(c.replayed)) { + const std::size_t i = t.seq - ack - 1; + t.input = i < k.upcoming.size() ? k.upcoming[i] + : k.upcoming.empty() ? k.input + : k.upcoming.back(); + } else if (t.seq == ack) { + t.input = k.input; + } + rv.inputsAhead.clear(); + for (std::size_t i = static_cast(c.replayed); i < k.upcoming.size(); ++i) + rv.inputsAhead.push_back(k.upcoming[i]); + if (!k.upcoming.empty()) + rv.input = k.upcoming.back(); + } m_netReplaying = false; // The other players' cars: what the host's states moved the ones // simulated here by, and a switch between simulating one and placing @@ -5513,7 +5647,7 @@ class RaceScreen final : public Screen { const std::uint64_t now = net::monotonicMs(); if (ctx.netGame->isHost()) { collectHostCars(ctx); - if (now - m_netCarsSentAt >= kNetTrafficIntervalMs) + if (m_frameSteps > 0 && now - m_netCarsSentAt >= netCarStatesIntervalMs()) sendNetCars(ctx, now); } else if (m_frameSteps > 0) { if (const auto msg = m_prediction.message()) { @@ -5559,11 +5693,23 @@ class RaceScreen final : public Screen { } } + // Host: how often each client gets its CarStates: after every frame that + // ran a sample, up to 60 a second (OPENMM2_NET_STATE_HZ= for fewer, + // the comparison; protocol 9 sent 20). + static std::uint64_t netCarStatesIntervalMs() { + static const std::uint64_t ms = [] { + const char* v = std::getenv("OPENMM2_NET_STATE_HZ"); + const double hz = v ? std::clamp(str::parseDouble(v).value_or(60.0), 1.0, 60.0) : 60.0; + return hz >= 60.0 ? std::uint64_t{0} : static_cast(1000.0 / hz); + }(); + return ms; + } + // Host: the other players' cars sent to a client in full // (net::NearCarState): those within kNearEnter metres of its car, kept // until kNearLeave, the nearest net::kMaxNearCars; not one towing a // trailer (its trailer's state does not travel). - static constexpr float kNearEnter = 40.0f, kNearLeave = 50.0f; + static constexpr float kNearEnter = 60.0f, kNearLeave = 70.0f; // Host: each client's CarStates: the last input applied to its car and // the car's state after it, every other player's car, and the ones near @@ -5576,15 +5722,16 @@ class RaceScreen final : public Screen { std::uint8_t id = 0; const game::SimVehicle* car = nullptr; net::CarInputFrame input; + std::vector upcoming; }; std::vector full; if (!m_player->trailer()) - full.push_back({ctx.netGame->localId(), m_player.get(), m_netInput}); + full.push_back({ctx.netGame->localId(), m_player.get(), m_netInput, {}}); for (const auto& [id, rv] : m_remotes) if (rv.simulated && rv.placed && rv.sim) { cars.emplace_back(id, game::carSnapshot(*rv.sim, rv.input)); if (!rv.sim->trailer()) - full.push_back({id, rv.sim.get(), rv.input}); + full.push_back({id, rv.sim.get(), rv.input, rv.inputs.upcoming(net::kMaxUpcomingInputs)}); } for (auto& [id, rv] : m_remotes) { if (!rv.simulated || !rv.sim) @@ -5615,13 +5762,26 @@ class RaceScreen final : public Screen { rv.nearIds.clear(); for (const auto& [d, f] : near) { rv.nearIds.insert(f->id); + msg.nearIds.push_back(f->id); + } + // In full as many as fit one packet, starting with the next + // in turn (all of them while they fit). + for (std::size_t k = 0; k < near.size(); ++k) { + const Full* f = near[(rv.nearTurn + k) % near.size()].second; net::NearCarState n; n.id = f->id; n.state = game::ownCarState(*f->car, 0); n.input = f->input; n.input.events = 0; + n.upcoming = f->upcoming; msg.near.push_back(std::move(n)); + if (net::encodeMessage(msg).size() > net::kMaxCarStatesBytes) { + msg.near.pop_back(); + break; + } } + if (!near.empty()) + rv.nearTurn = (rv.nearTurn + std::max(1, msg.near.size())) % near.size(); } m_netStatesBytes += ctx.netGame->sendCarStates(id, msg); ++m_netStatesSent; @@ -5835,8 +5995,21 @@ class RaceScreen final : public Screen { // Client: another player's car as drawn before the blend: simulated // here, between its last two samples; else at its interpolated state. std::optional remoteDrawnBase(std::uint8_t id, const RemoteVehicle& rv) const { - if (rv.predicted && rv.sim) - return drawnPose(game::Drawn::RemoteCar, id, *rv.sim).body; + if (rv.predicted && rv.sim) { + // Simulated here: at this machine's car's time while the two may + // meet (they meet where the host's do), else at its player's + // present (the car's place a lead back on its trail: what the + // host's newest states put there, little predicted), blended. + const Mat34 now = drawnPose(game::Drawn::RemoteCar, id, *rv.sim).body; + if (rv.drawAhead >= 1.0f || !m_world || !m_netCarLead) + return now; + const double step = static_cast(phys::kFixedSampleStep) * 1000.0; + const double last = static_cast(m_prediction.nextSeq()) - 1.0; + const double alpha = static_cast(m_world->interpolationAlpha()); + const double t = last - 1.0 + alpha - *m_netCarLead / step; + const auto present = trailAt(rv, t); + return present ? game::blendTransform(*present, now, rv.drawAhead) : now; + } const auto it = m_remoteInterp.find(id); return it != m_remoteInterp.end() ? std::optional(it->second) : std::nullopt; } @@ -6676,6 +6849,7 @@ class RaceScreen final : public Screen { std::uint32_t resets = 0; // the commands carried out std::uint32_t lastMoveSeq = 0; // the sample of the last command that moved it std::set nearIds; // the other cars sent to its player in full + std::size_t nearTurn = 0; // which of them goes in full first in the next state int waterSamples = 0; // the samples it has been in the water (netCommandAllowed) std::uint64_t refused = 0; // its commands the host refused // Client (OpenMM2): a car near this machine's that the host sends in @@ -6686,6 +6860,20 @@ class RaceScreen final : public Screen { // the two ways of placing it, is blended away in the drawing. bool predicted = false; game::CorrectionBlend blend; + std::deque inputsAhead; // the host's inputs for its next samples + // Where it stood after each recent sample of this machine's car (its + // model matrix by sample number), and how far its drawing is from its + // player's present (0) toward this machine's car's time (1): + // remoteDrawnBase. + struct TrailPoint { + std::uint32_t seq = 0; + Mat34 model, ics; + Vec3 velocity; + net::CarInputFrame input; // what it ran this sample on + }; + std::deque trail; + net::CarInputFrame lastInput; // the input of the sample running (beforeNetSample) + float drawAhead = 0.0f; }; std::map m_remotes; // OpenMM2: the network cars' damage (game/net/DamageSync): this player's @@ -6732,7 +6920,9 @@ class RaceScreen final : public Screen { std::vector rails; // the shared traffic's cars on their rails }; std::deque m_bodyHistory; // client: the bodies around its car at each recent sample - std::deque m_netWaiting; // client: the host's latest counts of its inputs in hand + // Client: the host's latest counts of its inputs in hand, with their + // arrival (session ms). + std::deque> m_netWaiting; std::uint64_t m_netCarsSentAt = 0; // host: the last CarStates std::uint64_t m_netStatsAt = 0; // the last log of the statistics std::uint64_t m_netStatesBytes = 0, m_netStatesSent = 0; // host: sent since then diff --git a/src/game/net/PlayerCars.cpp b/src/game/net/PlayerCars.cpp index 155c22d..5fb0f76 100644 --- a/src/game/net/PlayerCars.cpp +++ b/src/game/net/PlayerCars.cpp @@ -130,6 +130,19 @@ void HostInputQueue::receive(const net::PlayerInputMsg& msg) { m_commands[c.seq] = c; } +std::vector HostInputQueue::upcoming(std::size_t most) const { + std::vector out; + if (m_next == 0) + return out; + for (std::uint32_t seq = m_next; out.size() < most; ++seq) { + const auto it = m_frames.find(seq); + if (it == m_frames.end()) + break; + out.push_back(it->second); + } + return out; +} + std::optional HostInputQueue::next() { if (m_next == 0) return std::nullopt; @@ -421,7 +434,8 @@ CarPrediction::Correction CarPrediction::acknowledge(SimVehicle& car, NetCarDriv const net::OwnCarState& host, const std::function& beforeLast, const std::function& beforeEach, - std::span companions) { + std::span companions, + const std::function& companionsAt) { Correction out; if (ack <= m_acked) return out; // an older or repeated state @@ -450,7 +464,7 @@ CarPrediction::Correction CarPrediction::acknowledge(SimVehicle& car, NetCarDriv out.velocityError > m_options.velocityTolerance || rotation > m_options.rotationTolerance || out.damage || out.held || out.gear; const std::size_t later = m_history.size() - 1 - index; - const bool replay = differs || !companions.empty(); + const bool replay = differs || std::ranges::any_of(companions, &Companion::differs); if (replay && later > m_options.maxReplay) { ++m_stats.unreplayable; m_history.erase(m_history.begin(), m_history.begin() + static_cast(index)); @@ -462,7 +476,7 @@ CarPrediction::Correction CarPrediction::acknowledge(SimVehicle& car, NetCarDriv // the car held where each sample had it: the car's own prediction is // then not run again against bodies held still (a prop it pushed in // those samples meets it as a wall when they run again). - bool alone = !differs && !companions.empty(); + bool alone = !differs && replay; const float span = static_cast(later + 1) * phys::kFixedSampleStep; const Vec3 carNow = car.sim().body.ics.matrix.m3; for (const Companion& c : companions) { @@ -479,17 +493,21 @@ CarPrediction::Correction CarPrediction::acknowledge(SimVehicle& car, NetCarDriv bodies.push_back(&c.car->sim().body); } // (In the world's order: the companions come in player order.) + if (companionsAt) + companionsAt(ack); world.beginReplay(world.time() - static_cast(later) * static_cast(phys::kFixedSampleStep)); for (std::size_t k = index + 1; k < m_history.size(); ++k) { restore(m_history[k - 1], car, driver); // the car as the sample met it for (const Companion& c : companions) - c.driver->apply(*c.car, c.input); + c.driver->apply(*c.car, companionInput(c, k - index - 1)); if (beforeEach) beforeEach(m_history[k].seq); if (k + 1 == m_history.size() && beforeLast) beforeLast(); world.replaySample(bodies, phys::kFixedSampleStep); + if (companionsAt) + companionsAt(m_history[k].seq); ++out.replayed; } restore(m_history.back(), car, driver); @@ -518,6 +536,8 @@ CarPrediction::Correction CarPrediction::acknowledge(SimVehicle& car, NetCarDriv bodies.push_back(&c.car->sim().body); for (const Companion& c : companions) applyOwnCarState(*c.car, *c.state); + if (companionsAt && !companions.empty()) + companionsAt(ack); // OpenMM2: the props the replay meets (World::collideHeld), the samples // having run for real one after another up to the world's time now. world.beginReplay(world.time() - @@ -528,13 +548,15 @@ CarPrediction::Correction CarPrediction::acknowledge(SimVehicle& car, NetCarDriv NetCarDriver::command(car, c); driver.apply(car, e.input); for (const Companion& c : companions) - c.driver->apply(*c.car, c.input); + c.driver->apply(*c.car, companionInput(c, k - index - 1)); if (beforeEach) beforeEach(e.seq); if (k + 1 == m_history.size() && beforeLast) beforeLast(); world.replaySample(bodies, phys::kFixedSampleStep); save(e, car, driver); + if (companionsAt && !companions.empty()) + companionsAt(e.seq); ++out.replayed; } // The resets asked for the next sample stand again. @@ -551,6 +573,12 @@ CarPrediction::Correction CarPrediction::acknowledge(SimVehicle& car, NetCarDriv return out; } +const net::CarInputFrame& CarPrediction::companionInput(const Companion& c, std::size_t k) { + if (k < c.upcoming.size()) + return c.upcoming[k]; + return c.upcoming.empty() ? c.input : c.upcoming.back(); +} + // --- CorrectionBlend ----------------------------------------------------------------- namespace { diff --git a/src/game/net/PlayerCars.h b/src/game/net/PlayerCars.h index 6847764..57bbd60 100644 --- a/src/game/net/PlayerCars.h +++ b/src/game/net/PlayerCars.h @@ -115,6 +115,9 @@ class HostInputQueue { // The fewest inputs waiting behind the applied one since the last call // (negative: samples the queue ran short); for CarStatesMsg::waiting. std::int32_t takeLeastWaiting(); + // The inputs in hand for the next samples, in order up to the first + // missing one, at most `most` (net::NearCarState::upcoming). + std::vector upcoming(std::size_t most) const; // Inputs applied that the client did not send in time (repeated or // coasted), and inputs dropped to catch up (kMaxSlack), since the start. std::uint64_t missed() const { return m_missed; } @@ -234,10 +237,17 @@ class CarPrediction { NetCarDriver* driver = nullptr; const net::OwnCarState* state = nullptr; net::CarInputFrame input; + // Its inputs for the samples after the acknowledged one, as far as + // the host had them (then `input`, or the last of these, again). + std::span upcoming; // It comes before the car in the world's movers (a lower player // number: every machine keeps the players' cars in that order), and // collides first when they run again, as on the host. bool first = false; + // Its state or inputs differ from what this machine ran it on: the + // samples run again for it (otherwise only when the car's own state + // differed, and then with it). + bool differs = true; }; // The host's state after sample `ack`. Forgets what it acknowledged; when // the state differs from the prediction for that sample, puts the car @@ -246,13 +256,15 @@ class CarPrediction { // number before it (the other players' cars put back where they stood // when it first ran) and `beforeLast` before the last one (the drawing // keeps the car's pose before its last sample). With `companions` the - // samples are run again whether the car's state differed or not, every - // companion with it from the host's state (the car's own state at `ack` - // is the host's only when it differed). + // samples are run again when the car's state or a companion's differed, + // every companion with it from the host's state (the car's own state at `ack` + // is the host's only when it differed); `companionsAt` is called with each + // sample's number once the companions stand as after it (`ack` first). Correction acknowledge(SimVehicle& car, NetCarDriver& driver, phys::World& world, std::uint32_t ack, const net::OwnCarState& host, const std::function& beforeLast = {}, const std::function& beforeEach = {}, - std::span companions = {}); + std::span companions = {}, + const std::function& companionsAt = {}); // Statistics since the start. struct Stats { @@ -273,6 +285,8 @@ class CarPrediction { NetCarDriver driver; }; void save(Entry& e, const SimVehicle& car, const NetCarDriver& driver) const; + // A companion's input for the `k`th sample after the acknowledged one. + static const net::CarInputFrame& companionInput(const Companion& c, std::size_t k); static void restore(const Entry& e, SimVehicle& car, NetCarDriver& driver); Options m_options; diff --git a/src/net/PlayerCars.h b/src/net/PlayerCars.h index dc6ce1f..e5cac89 100644 --- a/src/net/PlayerCars.h +++ b/src/net/PlayerCars.h @@ -326,8 +326,17 @@ struct NearCarState { std::uint8_t id = 0; OwnCarState state; CarInputFrame input; // the last applied (its keys, applied once, left out) + // A client's car: the inputs the host holds for its next samples, in + // order (protocol 11; the host's own car has none), so that the + // receiving client runs it on them instead of on `input` repeated. + std::vector upcoming; }; -inline constexpr std::size_t kMaxNearCars = 2; +inline constexpr std::size_t kMaxNearCars = 3; +// A CarStates message stays within one ENet packet (its default MTU, 1400 +// bytes, less its header and a fragment's: ENet would send a longer +// unsequenced packet as reliable fragments). +inline constexpr std::size_t kMaxCarStatesBytes = 1372; +inline constexpr std::size_t kMaxUpcomingInputs = 16; struct CarStatesMsg { static constexpr MsgType kType = MsgType::CarStates; @@ -341,7 +350,10 @@ struct CarStatesMsg { OwnCarState own; // Every other player's car. std::vector> cars; - // The ones near the client's car (at most kMaxNearCars), in full. + // The ones near the client's car (at most kMaxNearCars, nearest first), + // and of those the ones in full in this message: as many as keep it in + // one packet (kMaxCarStatesBytes), taken in turn. + std::vector nearIds; std::vector near; }; inline constexpr std::int32_t kMaxReportedWaiting = 63; @@ -365,6 +377,12 @@ bool serialize(S& s, CarStatesMsg& m) { s.u8(id); serialize(s, state); } + auto nearIds = static_cast(std::min(m.nearIds.size(), kMaxNearCars)); + s.ranged(nearIds, 0, static_cast(kMaxNearCars)); + if constexpr (S::kReading) + m.nearIds.resize(static_cast(nearIds)); + for (std::int32_t i = 0; i < nearIds; ++i) + s.u8(m.nearIds[static_cast(i)]); auto nearCount = static_cast(std::min(m.near.size(), kMaxNearCars)); s.ranged(nearCount, 0, static_cast(kMaxNearCars)); if constexpr (S::kReading) @@ -376,6 +394,15 @@ bool serialize(S& s, CarStatesMsg& m) { c.input.events = 0; // keys are applied once: never repeated serialize(s, c.input, CarInputFrame{}); c.input.events = 0; + auto upcoming = static_cast(std::min(c.upcoming.size(), kMaxUpcomingInputs)); + s.ranged(upcoming, 0, static_cast(kMaxUpcomingInputs)); + if constexpr (S::kReading) + c.upcoming.resize(static_cast(upcoming)); + CarInputFrame previous = c.input; + for (std::int32_t k = 0; k < upcoming; ++k) { + serialize(s, c.upcoming[static_cast(k)], previous); + previous = c.upcoming[static_cast(k)]; + } } return s.ok(); } diff --git a/src/net/Protocol.h b/src/net/Protocol.h index 83f0148..b8fdb8a 100644 --- a/src/net/Protocol.h +++ b/src/net/Protocol.h @@ -42,7 +42,10 @@ inline constexpr std::uint16_t kProtocolMagic = 0x4D32; // "M2" // 10: the host's rules (net/RulesState.h: the rules message; a player's own // word on a checkpoint, a finish or the gold is refused). (8 was its number // on its branch.) -inline constexpr std::uint16_t kProtocolVersion = 10; +// 11: CarStates after every host frame that ran a sample (up to 60 a second), +// three near cars within 60 m, and a near client car's inputs the host holds +// for its next samples (NearCarState::upcoming). +inline constexpr std::uint16_t kProtocolVersion = 11; inline constexpr std::uint32_t kConnectData = (std::uint32_t{kProtocolMagic} << 16) | kProtocolVersion; // ENet channels. diff --git a/src/net/Session.cpp b/src/net/Session.cpp index 99f48f1..84c0b3a 100644 --- a/src/net/Session.cpp +++ b/src/net/Session.cpp @@ -1114,12 +1114,18 @@ void Session::clientHandle(MsgType type, std::span data) { } if (m_ownCarStates.size() >= kMaxQueuedOwnCarStates) m_ownCarStates.erase(m_ownCarStates.begin()); - // Untrusted: this machine's own car or a car given twice is refused. + // Untrusted: this machine's own car or a car given twice is refused, + // and a car in full that is not among the near ones. + std::vector nearIds; + for (const auto id : m.nearIds) + if (id != m_localId && std::ranges::count(m.nearIds, id) == 1) + nearIds.push_back(id); std::vector near; for (const auto& c : m.near) - if (c.id != m_localId && std::ranges::count(m.near, c.id, &NearCarState::id) == 1) + if (std::ranges::count(nearIds, c.id) == 1 && std::ranges::count(m.near, c.id, &NearCarState::id) == 1) near.push_back(c); - m_ownCarStates.push_back({m.time, m.ack, m.waiting, m.hasOwn, m.own, timeMs(), std::move(near)}); + m_ownCarStates.push_back( + {m.time, m.ack, m.waiting, m.hasOwn, m.own, timeMs(), std::move(nearIds), std::move(near)}); return; } case MsgType::GameEvent: { diff --git a/src/net/Session.h b/src/net/Session.h index 691c58f..7249840 100644 --- a/src/net/Session.h +++ b/src/net/Session.h @@ -275,8 +275,9 @@ class Session { bool hasOwn = false; OwnCarState own; double arrival = 0.0; // session ms - // The other players' cars near this one, in full (none is this - // machine's own, none twice). + // The other players' cars near this one (none is this machine's own, + // none twice), and those of them in full in this state. + std::vector nearIds; std::vector near; }; std::vector takeOwnCarStates() { return std::exchange(m_ownCarStates, {}); } diff --git a/tests/game/test_player_cars.cpp b/tests/game/test_player_cars.cpp index d6367c1..054ab81 100644 --- a/tests/game/test_player_cars.cpp +++ b/tests/game/test_player_cars.cpp @@ -185,7 +185,8 @@ struct ShuntOutcome { // from the host's full states (companion) when `companion`, and otherwise // meets it held where the host's newest state put it (the host's car not // simulated: a body that does not give way). -ShuntOutcome shunt(const vfs::Vfs& vfs, bool companion, float throttle, std::uint32_t brakeAt) { +ShuntOutcome shunt(const vfs::Vfs& vfs, bool companion, float throttle, std::uint32_t brakeAt, + std::uint32_t ahead = 0) { TwoCars host(vfs), client(vfs); const Mat34 front = Mat34::translation({0.0f, 0.6f, 0.0f}); const Mat34 back = Mat34::translation({0.0f, 0.6f, 9.0f}); // facing -Z: behind @@ -212,10 +213,12 @@ ShuntOutcome shunt(const vfs::Vfs& vfs, bool companion, float throttle, std::uin const float dt = phys::kFixedSampleStep; const std::uint32_t latency = 6; net::CarInputFrame leader = leaderInput(0, throttle, brakeAt); + std::deque leaderAhead; // the host's inputs beyond those run again for (std::uint32_t t = 1; t <= 300; ++t) { while (!down.empty() && down.front().due <= t) { const Down& d = down.front().msg; - CarPrediction::Companion c{client.a.get(), &client.da, &d.near.state, d.near.input, true}; + CarPrediction::Companion c{client.a.get(), &client.da, &d.near.state, d.near.input, + d.near.upcoming, true}; const auto r = companion ? prediction.acknowledge(*client.b, client.db, client.world, d.ack, d.own, {}, {}, std::span(&c, 1)) @@ -223,20 +226,29 @@ ShuntOutcome shunt(const vfs::Vfs& vfs, bool companion, float throttle, std::uin if (!companion) { // The host's car where its newest state says, held there. applyOwnCarState(*client.a, d.near.state); - } else { - leader = d.near.input; + } else if (r.rebased) { + leader = d.near.upcoming.empty() ? d.near.input : d.near.upcoming.back(); + leaderAhead.assign(d.near.upcoming.begin() + std::min(r.replayed, + std::ssize(d.near.upcoming)), + d.near.upcoming.end()); } if (r.corrected) { ++run.corrections; run.largest = std::max(run.largest, r.moved.mag()); + if (std::getenv("SHUNT_DEBUG")) + std::printf("t %u ack %u pos %.4f vel %.4f dmg %d moved %.4f\n", t, d.ack, r.positionError, + r.velocityError, r.damage, r.moved.mag()); } down.pop_front(); } if (prediction.nextSeq() == 1) prediction.command(*client.b, {0, net::CarCommandKind::ResetTo, client.b->sim().resetPos(), client.b->sim().resetRotation}); - if (companion) - client.da.apply(*client.a, leader); + if (companion) { + client.da.apply(*client.a, leaderAhead.empty() ? leader : leaderAhead.front()); + if (!leaderAhead.empty()) + leaderAhead.pop_front(); + } phys::PedalInput full; full.accelerator = 1.0f; net::CarInputFrame mine = inputFrame(full); @@ -267,6 +279,9 @@ ShuntOutcome shunt(const vfs::Vfs& vfs, bool companion, float throttle, std::uin d.near.id = 0; d.near.state = ownCarState(*host.a, 0); d.near.input = leaderInput(t, throttle, brakeAt); + // A client's car: the inputs the host already holds for it. + for (std::uint32_t k = 1; k <= ahead; ++k) + d.near.upcoming.push_back(leaderInput(t + k, throttle, brakeAt)); down.push_back({t + latency, d}); } } @@ -305,6 +320,14 @@ TEST(PlayerCars, AShuntIsCorrectedOnlyWhereTheOtherPlayerChangedItsInput) { run.corrections, static_cast(run.largest)); EXPECT_GE(run.corrections, 1); EXPECT_LT(run.largest, 1.0f); + // Another client's car: the host holds that player's inputs for its next + // samples (here six) and sends them on: the brake is known sooner, and + // the correction it caused goes. + const ShuntOutcome relayed = shunt(*test::gameData(), true, 0.3f, 200, 6); + std::printf("[ measure ] the same with six inputs ahead: %d corrections, largest %.3f m\n", + relayed.corrections, static_cast(relayed.largest)); + EXPECT_LT(relayed.corrections, run.corrections); + EXPECT_LE(relayed.largest, run.largest + 1e-4f); } TEST(PlayerCars, TheHostResetsAClientsCarOnlyWhereTheRulesDo) { diff --git a/tests/net/test_player_cars.cpp b/tests/net/test_player_cars.cpp index afa2d7e..c4d0fda 100644 --- a/tests/net/test_player_cars.cpp +++ b/tests/net/test_player_cars.cpp @@ -118,6 +118,9 @@ CarStatesMsg sampleStates(std::size_t cars, std::size_t near = 0) { n.state = sampleOwn(); n.state.matrix.m3.x += 3.0f * static_cast(i); n.input = sampleInput().frames[9]; + const auto frames = sampleInput().frames; + n.upcoming.assign(frames.begin(), frames.begin() + 6); + m.nearIds.push_back(n.id); m.near.push_back(n); } return m; @@ -352,16 +355,14 @@ TEST(PlayerCars, StatesRoundTripTheOwnCarBitForBit) { } TEST(PlayerCars, NearCarsTravelInFullWithoutTheirKeys) { - // Eight players: seven other cars, two of them near, in full, in a - // contact. + // Eight players: seven other cars, three of them near, in full with the + // inputs the host holds for them, in a contact. CarStatesMsg m = sampleStates(kMaxPlayers / 2 - 1, kMaxNearCars); m.near[1].input.events = kInputShiftUp; // a key: applied once on the host, never sent on const auto bytes = encodeMessage(m); - // In one ENet packet: its default MTU (1400 bytes) less its header and - // a fragment's (ENet splits anything longer). - EXPECT_LE(bytes.size(), 1372u); CarStatesMsg out; ASSERT_TRUE(decodeMessage(bytes, out)); + ASSERT_EQ(out.nearIds, m.nearIds); ASSERT_EQ(out.near.size(), kMaxNearCars); for (std::size_t i = 0; i < kMaxNearCars; ++i) { EXPECT_EQ(out.near[i].id, m.near[i].id); @@ -369,11 +370,21 @@ TEST(PlayerCars, NearCarsTravelInFullWithoutTheirKeys) { CarInputFrame expected = m.near[i].input; expected.events = 0; EXPECT_EQ(out.near[i].input, expected); + EXPECT_EQ(out.near[i].upcoming, m.near[i].upcoming); // keys and all: each applied once, in turn } + // The host puts in full as many as keep it in one ENet packet + // (kMaxCarStatesBytes), here two of them without their coming inputs. + CarStatesMsg two = m; + two.near.pop_back(); + for (auto& n : two.near) + n.upcoming.clear(); + EXPECT_LE(encodeMessage(two).size(), kMaxCarStatesBytes); // More than kMaxNearCars: the first ones only. m.near.push_back(m.near[0]); + m.nearIds.push_back(7); ASSERT_TRUE(decodeMessage(encodeMessage(m), out)); EXPECT_EQ(out.near.size(), kMaxNearCars); + EXPECT_EQ(out.nearIds.size(), kMaxNearCars); // A near car out of range refuses the message. m = sampleStates(1, 2); m.near[1].state.matrix.m3.y = -1.0e7f; @@ -475,6 +486,7 @@ TEST(PlayerCars, AClientTakesNoNearStateOfItsOwnCarOrOfACarTwice) { p.race(); CarStatesMsg states = sampleStates(0, 2); states.time = p.host.time(); + states.nearIds = {kHostPlayerId, 5}; states.near[0].id = kHostPlayerId; states.near[1].id = kHostPlayerId; // twice: both refused p.host.sendCarStates(p.client.localId(), states); @@ -485,7 +497,9 @@ TEST(PlayerCars, AClientTakesNoNearStateOfItsOwnCarOrOfACarTwice) { return !own.empty(); })); EXPECT_TRUE(own[0].near.empty()); - states.near[0].id = p.client.localId(); // its own car: refused; the host's once: taken + EXPECT_EQ(own[0].nearIds, (std::vector{kHostPlayerId, 5})); + states.nearIds = {p.client.localId(), kHostPlayerId}; // its own car: refused + states.near[0].id = p.client.localId(); // so is its state; the host's once: taken p.host.sendCarStates(p.client.localId(), states); own.clear(); ASSERT_TRUE(pumpUntil({&p.host, &p.client}, [&] { @@ -495,6 +509,19 @@ TEST(PlayerCars, AClientTakesNoNearStateOfItsOwnCarOrOfACarTwice) { })); ASSERT_EQ(own[0].near.size(), 1u); EXPECT_EQ(own[0].near[0].id, kHostPlayerId); + EXPECT_EQ(own[0].nearIds, (std::vector{kHostPlayerId})); + // A state in full of a car not among the near ones: refused. + states.nearIds = {5}; + states.near.resize(1); + states.near[0].id = kHostPlayerId; + p.host.sendCarStates(p.client.localId(), states); + own.clear(); + ASSERT_TRUE(pumpUntil({&p.host, &p.client}, [&] { + for (auto& u : p.client.takeOwnCarStates()) + own.push_back(u); + return !own.empty(); + })); + EXPECT_TRUE(own[0].near.empty()); } TEST(PlayerCars, AFloodOfInputsKeepsToTheBudget) { @@ -534,7 +561,7 @@ TEST(PlayerCars, MutatedTrafficLeavesBothSidesWithinLimits) { checkInput(r.msg); for (const auto& u : p.client.takeOwnCarStates()) if (u.hasOwn) - checkStates({u.time, u.ack, u.waiting, true, u.own, {}, u.near}); + checkStates({u.time, u.ack, u.waiting, true, u.own, {}, u.nearIds, u.near}); } } EXPECT_EQ(p.host.state(), Session::State::Active); From 943f98bb64d1f169f6a2bbd066bade164a789822 Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Fri, 9 Oct 2026 20:20:23 -0700 Subject: [PATCH 087/129] Wrap three long lines Co-Authored-By: Claude Opus 5.5 (1M context) --- src/net/Session.cpp | 3 ++- tests/game/test_player_cars.cpp | 9 ++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/net/Session.cpp b/src/net/Session.cpp index 84c0b3a..3333cf7 100644 --- a/src/net/Session.cpp +++ b/src/net/Session.cpp @@ -1122,7 +1122,8 @@ void Session::clientHandle(MsgType type, std::span data) { nearIds.push_back(id); std::vector near; for (const auto& c : m.near) - if (std::ranges::count(nearIds, c.id) == 1 && std::ranges::count(m.near, c.id, &NearCarState::id) == 1) + if (std::ranges::count(nearIds, c.id) == 1 && + std::ranges::count(m.near, c.id, &NearCarState::id) == 1) near.push_back(c); m_ownCarStates.push_back( {m.time, m.ack, m.waiting, m.hasOwn, m.own, timeMs(), std::move(nearIds), std::move(near)}); diff --git a/tests/game/test_player_cars.cpp b/tests/game/test_player_cars.cpp index 054ab81..06433da 100644 --- a/tests/game/test_player_cars.cpp +++ b/tests/game/test_player_cars.cpp @@ -228,16 +228,15 @@ ShuntOutcome shunt(const vfs::Vfs& vfs, bool companion, float throttle, std::uin applyOwnCarState(*client.a, d.near.state); } else if (r.rebased) { leader = d.near.upcoming.empty() ? d.near.input : d.near.upcoming.back(); - leaderAhead.assign(d.near.upcoming.begin() + std::min(r.replayed, - std::ssize(d.near.upcoming)), - d.near.upcoming.end()); + const auto from = std::min(r.replayed, std::ssize(d.near.upcoming)); + leaderAhead.assign(d.near.upcoming.begin() + from, d.near.upcoming.end()); } if (r.corrected) { ++run.corrections; run.largest = std::max(run.largest, r.moved.mag()); if (std::getenv("SHUNT_DEBUG")) - std::printf("t %u ack %u pos %.4f vel %.4f dmg %d moved %.4f\n", t, d.ack, r.positionError, - r.velocityError, r.damage, r.moved.mag()); + std::printf("t %u ack %u pos %.4f vel %.4f dmg %d moved %.4f\n", t, d.ack, + r.positionError, r.velocityError, r.damage, r.moved.mag()); } down.pop_front(); } From e168a58598e55f7eb6d69c50cb0d221e4f4be22a Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Fri, 9 Oct 2026 20:22:57 -0700 Subject: [PATCH 088/129] Let the host decide the water, and tighten the rules' rough edges (protocol 14) The water and the fall: mmGame::Update's checks (below -50 m the mode's DropThruCityHandler, which a network game turns into the water's; five seconds with the car's vehSplash latched, HitWaterHandler) now run on a car's samples in game::NetCarDriver, on the host for every car it simulates and on a client for its own as a prediction. The handler is mmGameMulti::HitWaterHandler's respawn at the last checkpoint the car cleared in a race (the host's referee's, RaceReferee::lastCleared; a client's session's), else mmGame's / mmMultiCR's mmPlayer::Reset. Every car's full state carries its splash, buoyancy and level and the handler's time, so a client resumes the host's water exactly. A client's Reset and RespawnAt are refused (only the debug respawn passes), and ResetRules no longer lets any repair through in Cops and Robbers: only the wreck penalty's (a delivery's is the host's since protocol 10). The rules state: in a race the host also sends each player its state alone (no decisions, no results; the newest 16 waypoints) on the unreliable State channel (RulesState), 20 times a second and at once on a hit, numbered with the reliable messages; whichever arrives first stands, and a reliable message older than the newest state still brings its decisions. Idle reliable messages go once a second. Cops and Robbers: a client predicts its own pickup only when no other car is within the gold's 5 m and 1 m more, or on its way there in the next quarter of a second, as the client has it where the host will run its sample (the near cars it simulates, the others run on from their drawing). Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/multiplayer.md | 110 ++++++++---- src/app/RaceScreen.cpp | 171 ++++++++++++------ src/game/net/NetGame.cpp | 8 + src/game/net/NetGame.h | 6 + src/game/net/NetRules.cpp | 99 ++++++++--- src/game/net/NetRules.h | 69 +++++--- src/game/net/PlayerCars.cpp | 55 ++++-- src/game/net/PlayerCars.h | 75 +++++--- src/game/session/CopsAndRobbers.cpp | 18 +- src/game/session/CopsAndRobbers.h | 17 +- src/game/session/RaceReferee.cpp | 22 +-- src/game/session/RaceReferee.h | 12 +- src/game/session/Session.cpp | 18 +- src/game/session/Session.h | 12 ++ src/net/PlayerCars.h | 26 ++- src/net/Protocol.h | 10 +- src/net/RulesState.h | 19 ++ src/net/Session.cpp | 21 +++ src/net/Session.h | 12 ++ tests/game/test_net_race_rules.cpp | 118 ++++++++++++- tests/game/test_net_water.cpp | 265 ++++++++++++++++++++++++++++ tests/game/test_player_cars.cpp | 39 +--- tests/net/test_rules_state.cpp | 42 +++++ tests/net/test_water_state.cpp | 69 ++++++++ 24 files changed, 1046 insertions(+), 267 deletions(-) create mode 100644 tests/game/test_net_water.cpp create mode 100644 tests/net/test_water_state.cpp diff --git a/docs/multiplayer.md b/docs/multiplayer.md index 1625bb9..ad22748 100644 --- a/docs/multiplayer.md +++ b/docs/multiplayer.md @@ -215,7 +215,10 @@ cars a message and no traffic hit reports, version 7 the host's props client's in full in `CarStates` and, in every car's full state, what a sample hands the next, version 10 the host's rules (the rules event; a player's own checkpoint, lap, finish and gold events refused; 8 on its own -branch). +branch), version 14 the water and the fall decided by the host (every car's +full state carries its `vehSplash` and the water handler's time; a client's +reset commands only for debugging) and a race's rules state also unreliable +(`RulesState`) between the reliable messages. ### Handshake @@ -596,23 +599,38 @@ every player's car on the host: * **Commands.** What the client's rules decide about its car travels with the inputs, numbered with the sample it applies at, until the host acknowledges it: the first sample's placement (where that machine - started the car), mmPlayer::Reset (the water, a fall, the debug respawn), - the `HitWaterHandler`s' respawn at a checkpoint and `vehCar::ClearDamage` - (a wreck's repair, Cops and Robbers' repairs). The client applies it at - once and the host at the same sample, so a reset is no correction. The - host carries one out only where the game's rules would - (`game::ResetRules`, on its own simulation of the car): the placement only - first and in the city; a reset or a respawn only after nearly five seconds - in the water (the car's `vehSplash` latched; `mmGameMulti:: - HitWaterHandler`) or below -50 m (`DropThruCityHandler`, which - multiplayer treats as the water), a respawn only at the race's start or at - a checkpoint the host's referee counted for that car, facing its heading - (see "Rules"), and not more than four a second; a repair only for a car - past its maximum damage (the wreck penalty's) or in Cops and Robbers (a - delivery's repair is the host's own since the rules run there). A refused - command leaves the car where the host has it, and the client's is - corrected back (`OPENMM2_DEBUG_RESPAWN_MS` on the host lets any reset in - the city pass, for development). + started the car), `vehCar::ClearDamage` (the wreck penalty's repair) and + the debug respawn. The client applies it at once and the host at the same + sample, so a command is no correction. The host carries one out only + where the game's rules would (`game::ResetRules`, on its own simulation + of the car): the placement only first and in the city, a repair only for + a car past its maximum damage. Since protocol 14 a client's reset or + respawn is refused: **the water and the fall are the host's own** (see + "The water and the fall"), and Cops and Robbers' repair at a delivery is + the host's since protocol 10. A refused command leaves the car where the + host has it, and the client's is corrected back + (`OPENMM2_DEBUG_RESPAWN_MS` on the host lets any reset in the city pass, + not more than four a second, for development). +* **The water and the fall** (protocol 14). mmGame::Update checks the + player's car after each frame: below -50 m it calls the mode's + `DropThruCityHandler`, which a network game turns into the water's; + otherwise, the car's `vehSplash` latched (its model origin went under a + water room's level), it counts the seconds and past five calls + `HitWaterHandler`: `mmGameMulti::HitWaterHandler` puts the car back at + the last checkpoint it cleared, facing its heading (the reset position + kept), in a race; `mmGame`'s (the cruise) and `mmMultiCR`'s (with the + gold dropped back at its place) reset it to its reset position. In + OpenMM2 the checks run on the car's samples (`game::NetCarDriver:: + setWaterHandler`, before each sample on the car as the last left it; the + time counts samples), on the host for every car it simulates, with the + respawn point from its referee (`NetRules::respawnIndex`); a client runs + them on its own car with its session's last checkpoint as a prediction, + which the host's states confirm (they carry the splash, its buoyancy and + level, and the handler's time). A client that disagrees (its car went in + later, or never) is corrected to the host's car; a client cannot put its + own car back. The session shows the water's message as before and the + race screen the reset (`presentOwnWaterReset`: the car's effects, its + dents and the camera). * **The host** (`game::HostInputQueue` per client) starts a client's car once three of its inputs are in hand, applies one per sample, repeats the last one (without its keys) for a quarter of a second when the next is @@ -1087,8 +1105,9 @@ for everything) decides them on the host from the cars it simulates (see (`CopsAndRobbers::updateHost`): a carrier's own damaging impact of 250 or more from another player's car knocks the gold loose where it is and locks the carrier out for 2 s; a wrecked car sits out 5 s and drops the gold; a - water handler (a reset that player's input asked for) sends the gold back - to its place; free gold within 5 m in the car's room goes to the first car + water handler (the host's own, see "Players' cars", "The water and the + fall") sends the gold back to its place; free gold within 5 m in the car's + room goes to the first car in order, with another player in the game; a carrier within 12 m of its base delivers and the host draws the next places; then the limits. The gold's mass, throttle cap and regeneration on the cars the host simulates @@ -1103,10 +1122,16 @@ for everything) decides them on the host from the cars it simulates (see that car's waypoints hit (the newest 64), its place and the racers, the other cars' icon numbers, the results so far, the timeout and the end; in Cops and Robbers the gold's carrier and place, the set, every score and - the end. It goes at once when something happened for that player and four - times a second otherwise. A client shows each decision once, in order, and - a missed one (a late loader whose queue of events overflowed) is made good - by the state, silently. + the end. It goes at once when something happened for that player and once + a second otherwise. In a race the state alone (the newest 16 waypoints, the + samples seen, the place and the icons; no decisions, no results) also goes + unreliably (`RulesState` on the State channel, protocol 14) at once on a + new hit and 20 times a second, numbered with the messages: whichever + arrives first stands, so a lost packet no longer holds a checkpoint's + confirmation (or its taking back) until ENet sends it again. A client + shows each decision once, in order (a reliable message older than the + newest state still brings its decisions), and a missed one (a late loader + whose queue of events overflowed) is made good by the state, silently. * **A client predicts** only what the HUD must show at once, and the host's word confirms or corrects it: * a checkpoint its own car clears (`Session::applyNetProgress`): shown at @@ -1120,8 +1145,12 @@ for everything) decides them on the host from the cars it simulates (see the same rules, so nothing is shown twice. * a gold pickup (`CopsAndRobbers::updatePredicted`): "You have the Gold!" at once (MM2's client asked the host and waited for 0x25a), unless - another car is at the gold too, when it waits for the host's word as - MM2's did. The host's `GoldTaken` for it confirms it; one for another + another car is at the gold too or about to be (within the gold's 5 m and + 1 m more, or on its way there in the next quarter of a second, as this + machine has it where the host will run its sample: the cars it + simulates with its own as they are, the others run on from their + drawing), when it waits for the host's word as MM2's did. The car's + water and fall are predicted the same way (see "Players' cars"). The host's `GoldTaken` for it confirms it; one for another car undoes it and shows that car's; the state undoes it once the host has seen the car 30 samples past the pickup without granting it. * nothing else: the finishes, the results, the standings, drops, @@ -1137,11 +1166,9 @@ for everything) decides them on the host from the cars it simulates (see Robbers' old messages 0x8001-0x8003 and the rules message from any player but itself, before relaying them, so nobody sees them; a client takes the rules message from the host only, for its own race, and newer than the - last. In a race the host also takes a client's water respawn - (`mmGameMulti::HitWaterHandler`: at the last checkpoint the car cleared) - only at the start or at a checkpoint it counted for that car - (`RaceReferee::respawnCheckpoints`, which `game::ResetRules` takes as the - race's respawn points). + last. A client's own reset of its car is refused (`game::ResetRules`): the + water's handler is the host's, at the last checkpoint its referee counted + the car clearing (`NetRules::respawnIndex`). * **Players who join late or leave**: every player in the session when the race is ordered is waited for. A player still loading has no car (it counts in the standings only once it has a finish) and its time starts at its own @@ -1149,16 +1176,19 @@ for everything) decides them on the host from the cars it simulates (see for, and a finish it had stays in the results (the deviation under "Race start"). In Cops and Robbers the host drops a leaver's gold where it is (`SystemMessage` 0x2d). -* **Cruise** has no rules to decide: its wreck penalty follows the host's - damage of the car, and its water reset is a command the host applies (see - "Players' cars"). +* **Cruise** has no rules to decide but the water's: its wreck penalty + follows the host's damage of the car, and the water's reset is the host's + (see "Players' cars", "The water and the fall"). **Deviations** (docs/review/multiplayer-desync-rules.md): the host decides for every machine; the rules run after every physics sample rather than once a frame; the finish time is the host's measure in samples, not each machine's own timer; in Cops and Robbers two cars reaching the gold in the same frame are decided in car order (MM2: whichever request reached the host -first); a client shows its own pickup before the host's answer; the place +first); a client shows its own pickup before the host's answer; the water's +and the fall's checks run on the host for every car, before each sample +rather than after each frame, the water's five seconds counted in samples; +the place uses the host's view of each player's target (the Next / Prev. Checkpoint keys stay on that player's machine) and positions. @@ -1555,10 +1585,12 @@ each: machine's HUD showed for its own car (as predicted, or on the host's word), `RX` one the host's word took back, `RH` the host's referee counting a car's checkpoint (with the car's sample), `RF` a finish in this - machine's results, `RE` its "Place: n/N", `CG` and `CS` Cops and Robbers' - gold events and scores as it shows them; `netprobe rulesreport ...` compares them (docs/review/ - multiplayer-desync-rules.md); + machine's results, `RE` its "Place: n/N", `RC` a client learning how many + waypoints the host counted for its car, `RW` a car the water or a fall + put back (the host: every car it simulates; a client: its own, as + predicted), `CG` and `CS` Cops and Robbers' gold events and scores as it + shows them; `netprobe rulesreport ...` compares + them (docs/review/multiplayer-desync-rules.md); * the props' lines (lowercase tags, `game::PropTrace`): every placed prop once (`p`), every prop that broke loose here and what broke it (`k`), a prediction undone (`x`), every 250 ms of session time (`t`) the props down diff --git a/src/app/RaceScreen.cpp b/src/app/RaceScreen.cpp index 07f5b97..bcc3bf2 100644 --- a/src/app/RaceScreen.cpp +++ b/src/app/RaceScreen.cpp @@ -2071,6 +2071,7 @@ class RaceScreen final : public Screen { // any chase (of anyone) and a wrecked, out-of-action cop count. st.pursuing = c.driver->mode() != ai::PoliceCar::Mode::Parked; } + presentOwnWaterReset(); const auto phaseBefore = m_session->phase(); m_session->setPreRaceCamera(m_cams.preRace()); // The multiplayer countdown (2.5 s) starts with the host's start @@ -2169,18 +2170,14 @@ class RaceScreen final : public Screen { // (back to the reset position); with them mmSingleCircuit / // mmGameMulti::HitWaterHandler reset the car at the last // checkpoint and put the reset position back. - const std::uint32_t sample = - m_traceNet && m_traceNet->isHost() ? m_netOwnSample + 1 : m_prediction.nextSeq(); + // (A network race's water and fall run on the car's samples: + // NetCarDriver::setWaterHandler, presentOwnWaterReset.) if (m_session->setup().checkpoints.empty()) { netCommand({0, net::CarCommandKind::Reset, {}, 0.0f}); } else { const Mat34 at = m_session->respawnTransform(); netCommand({0, net::CarCommandKind::RespawnAt, at.m3, phys::resetRotationOf(at)}); } - if (m_traceNet) - game::traceWaterReset(m_traceNet->traceFile(), m_traceNet->frameTime(), - m_traceNet->localId(), sample, m_player->sim().body.ics.matrix.m3); - m_crWaterHandled = m_cr != nullptr; // mmMultiCR::HitWaterHandler drops the gold if (m_vehicleFx) m_vehicleFx->reset(); // vehCar::Reset clearVehicleDamage(); @@ -2657,12 +2654,12 @@ class RaceScreen final : public Screen { // water handler fires (5 s in the water, or a fall out of the // city): this car's, or a reset another player's car took. std::vector cars; - cars.push_back( - {m_crSelf, m_crMyTeam, own, m_playerState.wrecked, std::exchange(m_crWaterHandled, false)}); + const bool ownWater = std::exchange(m_crWaterHandled, false); + cars.push_back({m_crSelf, m_crMyTeam, own, m_playerState.wrecked, ownWater, {}}); for (const auto& [id, rv] : m_remotes) if (rv.simulated && rv.placed && rv.sim && !m_netLeft.contains(id)) cars.push_back({id, m_cr->teamOf(id), rv.sim->sim().body.ics.matrix.m3, - rv.sim->sim().damage.wrecked(), m_crResets.contains(id)}); + rv.sim->sim().damage.wrecked(), m_crResets.contains(id), {}}); m_crResets.clear(); auto decisions = m_cr->updateHost(ruleDt, cars, m_crImpacts); for (const auto id : left) { @@ -2677,12 +2674,28 @@ class RaceScreen final : public Screen { // The host's word came with the frame's events // (receiveNetRules); this machine predicts only its own car // taking free gold, which the host confirms or undoes. + // The other cars where the host will have them when it runs + // this machine's sample: the ones simulated here with its car + // (predictNearCars) as they are, the others run on from where + // they are drawn (a playout delay and this car's lead behind). std::vector cars; - cars.push_back({m_crSelf, m_crMyTeam, own, m_playerState.wrecked, false}); - for (const auto& rc : m_remoteCars) - if (rc.hasState) - cars.push_back({rc.id, m_cr->teamOf(rc.id), rc.transform.m3, - (rc.flags & net::kVehicleWrecked) != 0, false}); + cars.push_back({m_crSelf, m_crMyTeam, own, m_playerState.wrecked, false, {}}); + const double hostMs = ctx.netGame->frameTime() + m_netCarLead.value_or(0.0); + for (const auto& rc : m_remoteCars) { + if (!rc.hasState) + continue; + const bool wrecked = (rc.flags & net::kVehicleWrecked) != 0; + if (const auto it = m_remotes.find(rc.id); + it != m_remotes.end() && it->second.predicted && it->second.sim) { + const auto& ics = it->second.sim->sim().body.ics; + cars.push_back({rc.id, m_cr->teamOf(rc.id), ics.matrix.m3, wrecked, false, + ics.linearVelocity}); + continue; + } + const float ahead = static_cast(std::clamp((hostMs - rc.time) * 0.001, 0.0, 0.5)); + cars.push_back({rc.id, m_cr->teamOf(rc.id), rc.transform.m3 + rc.velocity * ahead, wrecked, + false, rc.velocity}); + } int players = 0; for (const auto& p : ctx.netGame->players()) players += m_netLeft.contains(p.id) ? 0 : 1; @@ -3144,8 +3157,10 @@ class RaceScreen final : public Screen { for (const auto& p : ctx.netGame->players()) m_netRules.setName(p.id, p.name); m_netRules.setTrace(ctx.netGame->traceFile(), ctx.netGame->frameTime()); - if (!ctx.netGame->isHost() && m_player) + if (!ctx.netGame->isHost() && m_player) { m_netRules.receive(m_netEvents, m_player->sim().body.ics.matrix.m3); + m_netRules.receiveStates(ctx.netGame->takeRulesStates(), m_player->sim().body.ics.matrix.m3); + } } // Host: its referee's frame, its word on its own player, each player's message. @@ -3158,6 +3173,9 @@ class RaceScreen final : public Screen { m_netRules.hostUpdate(dt, net::monotonicMs(), m_player->sim().body.ics.matrix.m3, [&ctx](std::uint8_t player, std::vector payload) { ctx.netGame->sendEvent(net::kRulesEvent, std::move(payload), player); + }, + [&ctx](std::uint8_t player, const net::RulesStateMsg& state) { + ctx.netGame->sendRulesState(player, state); }); } @@ -5051,7 +5069,7 @@ class RaceScreen final : public Screen { // Its player's first command puts it where that machine // started it. const auto first = rv.inputs.ready() ? rv.inputs.placement() : std::nullopt; - if (!first || !netCommandAllowed(p.id, rv, *first)) + if (!first || !netCommandAllowed(rv, *first)) continue; game::NetCarDriver::command(*rv.sim, *first); rv.lastMoveSeq = first->seq; @@ -5073,36 +5091,64 @@ class RaceScreen final : public Screen { // Host: whether a client's command may reset its car (game::ResetRules, // on this machine's simulation of it). - bool netCommandAllowed(std::uint8_t id, const RemoteVehicle& rv, const net::CarCommand& c) { + bool netCommandAllowed(const RemoteVehicle& rv, const net::CarCommand& c) const { static const bool debug = std::getenv("OPENMM2_DEBUG_RESPAWN_MS") != nullptr; - if (m_netRespawnPoints.empty() && m_session) - for (const auto& cp : m_session->setup().checkpoints) - m_netRespawnPoints.push_back(game::session::spawnAt(cp)); - // In a race only the start and the checkpoints the host's referee - // counted for that car (game/net/NetRules: mmGameMulti:: - // HitWaterHandler puts it back at the last one it cleared). - std::vector counted; - if (const auto indices = m_netRules.respawnCheckpoints(id)) { - for (const int i : *indices) - if (i >= 0 && static_cast(i) < m_netRespawnPoints.size()) - counted.push_back(m_netRespawnPoints[static_cast(i)]); - } game::ResetRules r; r.placed = rv.placed && rv.sim; r.lastMoveSeq = rv.lastMoveSeq; - r.waterSamples = rv.waterSamples; - if (rv.sim) { - r.height = rv.sim->sim().body.ics.matrix.m3.y; - r.wrecked = rv.sim->sim().damage.maxDamaged(); - } - r.copsAndRobbers = m_result.config.mode == game::GameMode::CopsAndRobbers; + r.wrecked = rv.sim && rv.sim->sim().damage.maxDamaged(); r.debug = debug; r.city = m_city->psdl.bounds; - r.respawnPoints = m_netRules.respawnCheckpoints(id) ? std::span(counted) - : std::span(m_netRespawnPoints); return r.allows(c); } - std::vector m_netRespawnPoints; // host: the race's checkpoints' spawns + + // What the water's handler does to a car (NetCarDriver::setWaterHandler): + // in a race RespawnAt the last checkpoint it cleared + // (mmGameMulti::HitWaterHandler; `checkpoint` its index), elsewhere + // Reset at its reset position (mmGame::HitWaterHandler, mmMultiCR's). + net::CarCommand waterHandler(std::optional checkpoint) const { + if (!checkpoint || !m_session || m_session->setup().checkpoints.empty()) + return {0, net::CarCommandKind::Reset, {}, 0.0f}; + const auto& cps = m_session->setup().checkpoints; + const int last = static_cast(cps.size()) - 1; + const auto k = static_cast(std::clamp(*checkpoint, 0, last)); + const Mat34 at = game::session::spawnAt(cps[k]); + return {0, net::CarCommandKind::RespawnAt, at.m3, phys::resetRotationOf(at)}; + } + // This machine's car's handler: where its session puts it back. + net::CarCommand ownWaterHandler() const { + if (!m_session) + return {0, net::CarCommandKind::Reset, {}, 0.0f}; + const std::optional at = m_session->netRespawnPoint(); + if (!at) + return {0, net::CarCommandKind::Reset, {}, 0.0f}; + return {0, net::CarCommandKind::RespawnAt, at->m3, phys::resetRotationOf(*at)}; + } + // This machine's car was put back by the water or a fall at the start of + // its sample `sample` (the host's decision for its own car, a client's + // prediction of the host's): the trace, Cops and Robbers' handler on the + // host, and the rest once the frame's samples are done. + void ownWaterReset(std::uint32_t sample) { + if (m_traceNet) + game::traceWaterReset(m_traceNet->traceFile(), m_traceNet->frameTime(), m_traceNet->localId(), + sample, m_player->sim().body.ics.matrix.m3); + if (m_traceNet && m_traceNet->isHost()) + m_crWaterHandled = m_cr != nullptr; // mmMultiCR::HitWaterHandler drops the gold + m_ownWaterReset = true; + } + // mmPlayer::Reset's presentation: the car's effects and dents, the + // camera; the session's water message may show again. + void presentOwnWaterReset() { + if (!std::exchange(m_ownWaterReset, false)) + return; + if (m_vehicleFx) + m_vehicleFx->reset(); // vehCar::Reset + clearVehicleDamage(); + m_cams.reset(cameraTarget()); + if (m_session) + m_session->netWaterReset(); + } + bool m_ownWaterReset = false; // Host: a simulated player's car's impact (vehCarDamage::ApplyImpact): // its sound, sparks and damage, which everyone is sent. @@ -5159,20 +5205,24 @@ class RaceScreen final : public Screen { net::CarInputFrame in = m_netInput; in.events = std::exchange(m_netKeys, 0); // the keys go with the first sample after them if (m_traceNet->isHost()) { + // OpenMM2: the water and the fall on every car the host + // simulates are its own decision (protocol 14). + m_netDriver.setWaterHandler(ownWaterHandler()); + const std::uint32_t ownResets = m_netDriver.waterResets(); if (m_netDriver.apply(*m_player, in)) clearVehicleDamage(); // mmPlayer::ResetDamage + if (m_netDriver.waterResets() != ownResets) + ownWaterReset(m_netOwnSample + 1); for (auto& [id, rv] : m_remotes) { if (!rv.simulated || !rv.placed || !rv.sim) continue; - // The samples it has been in the water (netCommandAllowed). - rv.waterSamples = rv.sim->sim().splash.active() ? rv.waterSamples + 1 : 0; auto next = rv.inputs.next(); if (!next) continue; for (const auto& c : next->commands) { if (c.kind == net::CarCommandKind::ResetTo && c.seq == rv.lastMoveSeq) continue; // the placement, carried out already - if (!netCommandAllowed(id, rv, c)) { + if (!netCommandAllowed(rv, c)) { static const bool verbose = std::getenv("OPENMM2_DEBUG_NETCARS") != nullptr; if (verbose) log::info("netcars: refused player {}'s {} at sample {}", id, @@ -5180,13 +5230,7 @@ class RaceScreen final : public Screen { ++rv.refused; continue; } - // mmMultiCR::HitWaterHandler / DropThruCityHandler on that machine. - if (c.kind == net::CarCommandKind::Reset || c.kind == net::CarCommandKind::RespawnAt) - m_crResets.insert(id); game::NetCarDriver::command(*rv.sim, c); - if (c.kind == net::CarCommandKind::Reset || c.kind == net::CarCommandKind::RespawnAt) - game::traceWaterReset(m_traceNet->traceFile(), m_traceNet->frameTime(), id, c.seq, - rv.sim->sim().body.ics.matrix.m3); if (c.kind != net::CarCommandKind::ClearDamage) rv.lastMoveSeq = c.seq; ++rv.resets; @@ -5196,8 +5240,19 @@ class RaceScreen final : public Screen { if (m_netHeld) next->frame.flags |= net::kInputHeld; hostGoldInput(id, next->frame); + // mmGameMulti::HitWaterHandler (a race: the last checkpoint + // the referee counted it clearing) or mmGame's / mmMultiCR's. + rv.driver.setWaterHandler(waterHandler(m_netRules.respawnIndex(id))); + const std::uint32_t waterResets = rv.driver.waterResets(); if (rv.driver.apply(*rv.sim, next->frame)) hostCarReset(id, rv); + if (rv.driver.waterResets() != waterResets) { + m_crResets.insert(id); // mmMultiCR::HitWaterHandler / DropThruCityHandler + ++rv.resets; + hostCarReset(id, rv); + game::traceWaterReset(m_traceNet->traceFile(), m_traceNet->frameTime(), id, next->seq, + rv.sim->sim().body.ics.matrix.m3); + } rv.input = next->frame; } return; @@ -5221,7 +5276,14 @@ class RaceScreen final : public Screen { if (m_prediction.nextSeq() == 1) m_prediction.command(*m_player, {0, net::CarCommandKind::ResetTo, m_player->sim().resetPos(), m_player->sim().resetRotation}); + // The water and the fall as the host will decide them (predicted; + // its states confirm or correct them). + m_netDriver.setWaterHandler(ownWaterHandler()); + const std::uint32_t waterResets = m_netDriver.waterResets(); + const std::uint32_t sample = m_prediction.nextSeq(); m_prediction.beginSample(*m_player, m_netDriver, in); + if (m_netDriver.waterResets() != waterResets) + ownWaterReset(sample); } void afterNetSample() { if (!m_player || !m_traceNet) @@ -5603,7 +5665,7 @@ class RaceScreen final : public Screen { msg.waiting = rv.inputs.takeLeastWaiting(); msg.hasOwn = rv.placed && msg.ack != 0; if (msg.hasOwn) - msg.own = game::ownCarState(*rv.sim, rv.resets); + msg.own = game::ownCarState(*rv.sim, rv.resets, &rv.driver); for (const auto& c : cars) if (c.first != id) msg.cars.push_back(c); @@ -5669,11 +5731,13 @@ class RaceScreen final : public Screen { // The rules' messages (game/net/NetRules): sent by the host, taken by a client. if (verbose && m_netRules.active()) { const auto& r = m_netRules.stats(); - log::info("netrules: {} messages sent ({:.0f} B/s), {} taken, {} decisions, {} refused, " - "{} malformed, {} stale, {} decisions missed", + log::info("netrules: {} messages sent ({:.0f} B/s) and {} states ({:.0f} B/s), {} taken and {} " + "states, {} decisions, {} refused, {} malformed, {} stale, {} decisions missed", r.sent - m_netRulesSent.sent, - static_cast(r.bytes - m_netRulesSent.bytes) / seconds, r.received, r.decisions, - r.refused, r.malformed, r.stale, r.gaps); + static_cast(r.bytes - m_netRulesSent.bytes) / seconds, + r.states - m_netRulesSent.states, + static_cast(r.stateBytes - m_netRulesSent.stateBytes) / seconds, r.received, + r.statesTaken, r.decisions, r.refused, r.malformed, r.stale, r.gaps); m_netRulesSent = r; } } @@ -6125,8 +6189,6 @@ class RaceScreen final : public Screen { pedals.brake = 1.0f; m_lastPedals = pedals; updateForceFeedback(ctx, dt, false); - if (m_player->sim().modelMatrix().m3.y < std::min(-50.0f, m_city->psdl.bounds.min.y) - 30.0f) - netCommand({0, net::CarCommandKind::Reset, {}, 0.0f}); return; } m_player->sim().raceFinished = over; @@ -6684,7 +6746,6 @@ class RaceScreen final : public Screen { std::uint32_t resets = 0; // the commands carried out std::uint32_t lastMoveSeq = 0; // the sample of the last command that moved it std::set nearIds; // the other cars sent to its player in full - int waterSamples = 0; // the samples it has been in the water (netCommandAllowed) std::uint64_t refused = 0; // its commands the host refused // Client (OpenMM2): a car near this machine's that the host sends in // full (net::NearCarState) is simulated here along with this diff --git a/src/game/net/NetGame.cpp b/src/game/net/NetGame.cpp index 14417c0..170bf1a 100644 --- a/src/game/net/NetGame.cpp +++ b/src/game/net/NetGame.cpp @@ -897,6 +897,14 @@ std::size_t NetGame::sendPropState(std::uint8_t playerId, const net::PropStateMs return m_impl->session ? m_impl->session->sendPropState(playerId, msg) : 0; } +std::size_t NetGame::sendRulesState(std::uint8_t playerId, const net::RulesStateMsg& msg) { + return m_impl->session ? m_impl->session->sendRulesState(playerId, msg) : 0; +} + +std::vector NetGame::takeRulesStates() { + return m_impl->session ? m_impl->session->takeRulesStates() : std::vector{}; +} + std::vector NetGame::takePropStates() { return m_impl->session ? m_impl->session->takePropStates() : std::vector{}; } diff --git a/src/game/net/NetGame.h b/src/game/net/NetGame.h index 20caf68..a05ba27 100644 --- a/src/game/net/NetGame.h +++ b/src/game/net/NetGame.h @@ -343,6 +343,12 @@ class NetGame { // Client: the host's messages received since the last call. std::vector takePropStates(); + // --- The host's rules state (OpenMM2, net/RulesState.h, game::NetRules) ------------- + // Host: sends one player its race's rules state (unreliable); returns the bytes sent. + std::size_t sendRulesState(std::uint8_t playerId, const net::RulesStateMsg& msg); + // Client: the host's states received since the last call. + std::vector takeRulesStates(); + // 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/NetRules.cpp b/src/game/net/NetRules.cpp index 0374826..63c4474 100644 --- a/src/game/net/NetRules.cpp +++ b/src/game/net/NetRules.cpp @@ -166,8 +166,9 @@ void NetRules::apply(const RuleDecisionMsg& d) { session::Session* s = m_setup.session; switch (d.type) { case RuleDecision::Finished: - if (m_finished.insert(d.player).second) - traceRuleFinish(m_trace, m_frameTime, d.player, d.ms); + if (!m_finished.insert(d.player).second) + return; // the results stood in for it already + traceRuleFinish(m_trace, m_frameTime, d.player, d.ms); if (s) { if (d.player == m_setup.self) s->netFinished(toSeconds(d.ms)); @@ -176,12 +177,14 @@ void NetRules::apply(const RuleDecisionMsg& d) { } return; case RuleDecision::TimedOut: - m_timedOut = true; + if (std::exchange(m_timedOut, true)) + return; if (s) s->netTimedOut(); return; case RuleDecision::AllCounted: - m_allCounted = true; + if (std::exchange(m_allCounted, true)) + return; if (s) s->netAllCounted(); return; @@ -193,14 +196,16 @@ void NetRules::apply(const RuleDecisionMsg& d) { } } -net::RulesMsg NetRules::message(std::uint8_t player, Client& c) const { +net::RulesMsg NetRules::message(std::uint8_t player, Client& c, bool decisions) const { net::RulesMsg m; m.race = m_setup.race; m.seq = ++c.seq; - std::size_t k = c.lastDecision; - for (; k < m_log.size() && m.decisions.size() < net::kMaxRuleDecisions; ++k) - m.decisions.push_back(m_log[k]); - c.lastDecision = static_cast(k); + if (decisions) { + std::size_t k = c.lastDecision; + for (; k < m_log.size() && m.decisions.size() < net::kMaxRuleDecisions; ++k) + m.decisions.push_back(m_log[k]); + c.lastDecision = static_cast(k); + } if (const auto it = m_carSamples.find(player); it != m_carSamples.end()) m.evaluated = it->second; if (const session::CopsAndRobbers* cops = m_setup.cops) { @@ -222,10 +227,11 @@ net::RulesMsg NetRules::message(std::uint8_t player, Client& c) const { return m; if (const auto* p = m_referee->player(player)) { m.evaluated = p->evaluated; - const std::size_t first = p->hits.size() > kHitWindow ? p->hits.size() - kHitWindow : 0; + const std::size_t window = decisions ? kHitWindow : kStateHitWindow; + const std::size_t first = p->hits.size() > window ? p->hits.size() - window : 0; m.firstHit = static_cast(first); m.hits.assign(p->hits.begin() + static_cast(first), p->hits.end()); - c.hits = p->hits.size(); + (decisions ? c.hits : c.stateHits) = p->hits.size(); m.place = static_cast(std::clamp(p->place, 1, static_cast(net::kMaxPlayers))); m.racers = static_cast(std::clamp(p->racers, 1, static_cast(net::kMaxPlayers))); } @@ -235,6 +241,8 @@ net::RulesMsg NetRules::message(std::uint8_t player, Client& c) const { const int icon = std::clamp(m_referee->iconPlace(player, id), 0, static_cast(net::kMaxPlayers)); m.icons.emplace_back(id, static_cast(icon)); } + if (!decisions) + return m; // a state alone: the results stay reliable for (const auto& r : m_referee->results()) if (m.results.size() < net::kMaxPlayers) m.results.emplace_back(r.player, toMs(r.seconds)); @@ -243,7 +251,8 @@ net::RulesMsg NetRules::message(std::uint8_t player, Client& c) const { return m; } -void NetRules::hostUpdate(float dt, std::uint64_t nowMs, const Vec3& ownPosition, const Send& send) { +void NetRules::hostUpdate(float dt, std::uint64_t nowMs, const Vec3& ownPosition, const Send& send, + const SendState& sendState) { if (!m_setup.host || !active()) return; if (m_referee) { @@ -304,6 +313,23 @@ void NetRules::hostUpdate(float dt, std::uint64_t nowMs, const Vec3& ownPosition m_stats.bytes += payload.size(); send(id, std::move(payload)); } + // A race's state alone, unreliably: at once on a new hit (with the + // reliable message: whichever arrives first stands), else 20 times a + // second. + if (!sendState || !m_referee) + return; + for (auto& [id, c] : m_clients) { + const auto* p = m_referee->player(id); + const bool hit = p && p->hits.size() != c.stateHits; + if (!hit && nowMs - c.stateSentAt < kFastStateIntervalMs) + continue; + net::RulesStateMsg st; + st.rules = message(id, c, false); + c.stateSentAt = nowMs; + ++m_stats.states; + m_stats.stateBytes += net::encodePayload(st.rules).size(); + sendState(id, st); + } } void NetRules::receive(const std::vector& events, const Vec3& carPosition) { @@ -327,7 +353,12 @@ void NetRules::receive(const std::vector& events, const Vec3& carP } m_lastMessage = m.seq; ++m_stats.received; - m_evaluated = std::max(m_evaluated, m.evaluated); + // Its state stands only if no newer one (an unreliable state) came + // first; its decisions always do. + const bool newest = m.seq > m_lastState; + m_lastState = std::max(m_lastState, m.seq); + if (newest) + m_evaluated = std::max(m_evaluated, m.evaluated); for (const auto& d : m.decisions) { if (d.seq <= m_lastDecision) continue; @@ -342,7 +373,7 @@ void NetRules::receive(const std::vector& events, const Vec3& carP } if (m.cops != (m_setup.cops != nullptr)) continue; - if (session::CopsAndRobbers* cops = m_setup.cops) { + if (session::CopsAndRobbers* cops = m_setup.cops; cops && newest) { session::CopsAndRobbers::State st; st.carrier = m.carrier == net::kInvalidPlayerId ? -1 : m.carrier; st.goldActive = m.goldActive; @@ -356,25 +387,43 @@ void NetRules::receive(const std::vector& events, const Vec3& carP cops->adopt(st, m_setup.self, confirmed); if (!cops->pickupPending()) m_pickupSample.reset(); - } else { - applyRaceState(m, carPosition); + } else if (!m.cops) { + applyRaceState(m, carPosition, newest); + } + } +} + +void NetRules::receiveStates(const std::vector& states, const Vec3& carPosition) { + if (m_setup.host || !active() || !m_setup.session) + return; + for (const auto& m : states) { + // (NetGame hands over the host's only.) + if (m.race != m_setup.race || m.cops || !m.decisions.empty() || m.seq <= m_lastState) { + ++m_stats.stale; + continue; } + m_lastState = m.seq; + ++m_stats.statesTaken; + m_evaluated = std::max(m_evaluated, m.evaluated); + applyRaceState(m, carPosition, true); } } -void NetRules::applyRaceState(const net::RulesMsg& m, const Vec3& carPosition) { +void NetRules::applyRaceState(const net::RulesMsg& m, const Vec3& carPosition, bool progress) { session::Session* s = m_setup.session; if (!s) return; - s->applyNetProgress({m.evaluated, m.firstHit, m.hits}, carPosition); - if (const std::size_t n = m.firstHit + m.hits.size(); n > m_confirmedHits) { - m_confirmedHits = n; - traceRuleConfirmed(m_trace, m_frameTime, m_setup.self, static_cast(n)); + if (progress) { + s->applyNetProgress({m.evaluated, m.firstHit, m.hits}, carPosition); + if (const std::size_t n = m.firstHit + m.hits.size(); n > m_confirmedHits) { + m_confirmedHits = n; + traceRuleConfirmed(m_trace, m_frameTime, m_setup.self, static_cast(n)); + } + s->setNetStanding(m.place, m.racers); + m_icons.clear(); + for (const auto& [id, place] : m.icons) + m_icons[id] = place; } - s->setNetStanding(m.place, m.racers); - m_icons.clear(); - for (const auto& [id, place] : m.icons) - m_icons[id] = place; // A finish whose decision this machine missed: the results stand in. for (const auto& [id, ms] : m.results) if (!m_finished.contains(id)) diff --git a/src/game/net/NetRules.h b/src/game/net/NetRules.h index a8c0fbd..2ffbb55 100644 --- a/src/game/net/NetRules.h +++ b/src/game/net/NetRules.h @@ -8,13 +8,17 @@ // the finish timeout, the end, the standings) or takes Cops and // Robbers' decisions (session::CopsAndRobbers::updateHost), shows // its own player's as a client would, and sends every other -// player, a few times a second and at once when something -// happened, what it decided since its last message and the state -// as it stands. +// player, once a second and at once when something happened, +// what it decided since its last message and the state as it +// stands (reliably); in a race the state alone also goes +// unreliably 20 times a second and at once on a new hit +// (protocol 14: a lost packet no longer holds the confirmation +// of a checkpoint back until it is sent again). // client takes the host's messages only: the decisions are shown once -// each (in order, by their numbers), the state corrects what the -// client predicted (the checkpoints its car hit, a gold pickup) -// and stands in for anything it missed. +// each (in order, by their numbers), the newest state (of +// either kind) corrects what the client predicted (the +// checkpoints its car hit, a gold pickup) and stands in for +// anything it missed. // // Nothing here touches the network: the race screen hands the messages to // NetGame and feeds the received game events in. @@ -45,12 +49,15 @@ bool hostAcceptsGameEvent(std::uint8_t from, std::uint16_t type); class NetRules { public: - // How often a player hears from the host when nothing happened (the - // standings, the samples its rules have seen). - static constexpr std::uint64_t kStateIntervalMs = 250; + // How often a player hears from the host reliably when nothing happened, + // and in a race unreliably (the standings, the samples its rules have + // seen; the CarStates rate). + static constexpr std::uint64_t kStateIntervalMs = 1000; + static constexpr std::uint64_t kFastStateIntervalMs = 50; // The waypoints a message lists at most (the newest; a client keeps the - // earlier ones as it has them). + // earlier ones as it has them), and an unreliable state. static constexpr std::size_t kHitWindow = 64; + static constexpr std::size_t kStateHitWindow = 16; struct Setup { bool host = false; @@ -77,18 +84,11 @@ class NetRules { // After each physics sample of a player's car (the host's own too): `seq` // the input's number, `held` whether it held the car. void hostSample(std::uint8_t id, std::uint32_t seq, const Mat34& car, const Vec3& inertiaBox, bool held); - // Whether a client's car may be put back at `position` (its water - // handler's RespawnAt; O4 of the players' cars review): in a race, at the - // start or a checkpoint the host counted for it. - bool respawnAllowed(std::uint8_t id, const Vec3& position) const { - return !m_referee || m_referee->mayRespawnAt(id, position); - } - // The race's checkpoints such a respawn may be at (the start and the - // ones counted for that car), or nothing outside a race's host. - std::optional> respawnCheckpoints(std::uint8_t id) const { - if (!m_referee) - return std::nullopt; - return m_referee->respawnCheckpoints(id); + // The checkpoint the water's handler puts a player's car back at in a + // race (the last one the referee counted it clearing, + // mmGameMulti::HitWaterHandler), or nothing outside a race's host. + std::optional respawnIndex(std::uint8_t id) const { + return m_referee ? m_referee->lastCleared(id) : std::nullopt; } // A player left the session or quit the race. void playerLeft(std::uint8_t id); @@ -98,13 +98,20 @@ class NetRules { // host's own player (`ownPosition` its car), and each player's message // when it is due (`send(player, payload)` as a game event of type // net::kRulesEvent to that player). + // `sendState(player, state)`: a race's state alone, unreliably + // (NetGame::sendRulesState). using Send = std::function payload)>; - void hostUpdate(float dt, std::uint64_t nowMs, const Vec3& ownPosition, const Send& send); + using SendState = std::function; + void hostUpdate(float dt, std::uint64_t nowMs, const Vec3& ownPosition, const Send& send, + const SendState& sendState = {}); // --- Client ------------------------------------------------------------------------- // The host's messages among the frame's game events (`carPosition`: this // machine's car, where a corrected target is measured from). void receive(const std::vector& events, const Vec3& carPosition); + // The host's unreliable states (NetGame::takeRulesStates), the newest + // taken. + void receiveStates(const std::vector& states, const Vec3& carPosition); // This machine predicted its car took the gold at its sample `seq`. void predictedPickup(std::uint32_t seq) { m_pickupSample = seq; } @@ -119,7 +126,9 @@ class NetRules { struct Stats { std::uint64_t sent = 0, bytes = 0; // host: messages and their payload bytes + std::uint64_t states = 0, stateBytes = 0; // host: unreliable states and their bytes std::uint64_t received = 0; // client: messages taken + std::uint64_t statesTaken = 0; // client: unreliable states taken (newer than any) std::uint64_t refused = 0; // client: rules messages not from the host std::uint64_t malformed = 0, stale = 0; // client: undecodable; another race's or older std::uint64_t decisions = 0, gaps = 0; // decisions applied; numbers missing (the state stood in) @@ -130,15 +139,18 @@ class NetRules { private: struct Client { - std::uint32_t seq = 0; // messages sent + std::uint32_t seq = 0; // messages and states sent (one numbering) std::uint32_t lastDecision = 0; // the newest decision sent std::size_t hits = 0; // its car's hits when last sent - std::uint64_t sentAt = 0; + std::size_t stateHits = 0; // ... when last sent unreliably + std::uint64_t sentAt = 0, stateSentAt = 0; }; void decide(net::RuleDecisionMsg d); void apply(const net::RuleDecisionMsg& d); - void applyRaceState(const net::RulesMsg& m, const Vec3& carPosition); - net::RulesMsg message(std::uint8_t player, Client& c) const; + // `progress`: the waypoints, place and icons too (a state newer than + // any taken), else only what stands in for missed decisions. + void applyRaceState(const net::RulesMsg& m, const Vec3& carPosition, bool progress); + net::RulesMsg message(std::uint8_t player, Client& c, bool decisions = true) const; std::string nameOf(std::uint8_t id) const; Setup m_setup; @@ -148,7 +160,8 @@ class NetRules { std::set m_left; std::vector m_log; std::uint32_t m_lastDecision = 0; // client: the newest applied - std::uint32_t m_lastMessage = 0; // client: the newest message + std::uint32_t m_lastMessage = 0; // client: the newest message (reliable) + std::uint32_t m_lastState = 0; // client: the newest state taken (either kind) std::uint32_t m_evaluated = 0; std::optional m_pickupSample; std::set m_finished; diff --git a/src/game/net/PlayerCars.cpp b/src/game/net/PlayerCars.cpp index 155c22d..3557bf5 100644 --- a/src/game/net/PlayerCars.cpp +++ b/src/game/net/PlayerCars.cpp @@ -43,7 +43,29 @@ void NetCarDriver::attach(const SimVehicle& car) { m_extraMass = 0; } +void NetCarDriver::checkWater(SimVehicle& car) { + if (!m_waterHandler) + return; + // mmGame::Update, after UpdateGame: the car's matrix below -50 m calls + // DropThruCityHandler; otherwise, its vehSplash active and the handler not + // run yet, the timer counts and past 5 s calls HitWaterHandler (in a + // network game both put the car back at once, and the timer and the + // handled flag start again). + const phys::CarSim& sim = car.sim(); + bool handler = sim.body.ics.matrix.m3.y < kDropHeight; + if (!handler && sim.splash.active()) { + m_waterTime += phys::kFixedSampleStep; + handler = m_waterTime > kWaterSeconds; + } + if (!handler) + return; + m_waterTime = 0.0f; + command(car, *m_waterHandler); + ++m_waterResets; +} + bool NetCarDriver::apply(SimVehicle& car, const net::CarInputFrame& in) { + checkWater(car); phys::CarSim& sim = car.sim(); // mmPlayer::UpdateRegen (Cops and Robbers, while the car carries no gold). const bool cleared = (in.flags & net::kInputRegen) && sim.regenerate(); @@ -194,7 +216,7 @@ std::int32_t HostInputQueue::takeLeastWaiting() { return w; } -net::OwnCarState ownCarState(const SimVehicle& car, std::uint32_t resets) { +net::OwnCarState ownCarState(const SimVehicle& car, std::uint32_t resets, const NetCarDriver* driver) { const phys::CarSim& sim = car.sim(); const phys::InertialCS& ics = sim.body.ics; net::OwnCarState s; @@ -228,6 +250,10 @@ net::OwnCarState ownCarState(const SimVehicle& car, std::uint32_t resets) { s.swapThrottle = car.controls().swapThrottle; s.held = car.held(); s.resets = resets; + s.splash = sim.splash.active(); + s.buoyancy = sim.splash.buoyancy; + s.waterLevel = s.splash ? sim.splash.waterLevel : 0.0f; + s.waterTime = driver ? driver->waterTime() : 0.0f; s.force = ics.linearForce; s.torque = ics.angularTorque; for (std::size_t i = 0; i < 4; ++i) @@ -289,6 +315,11 @@ void applyOwnCarState(SimVehicle& car, const net::OwnCarState& s) { sim.randomState = s.random; car.controls().swapThrottle = s.swapThrottle; car.setHeld(s.held); + if (s.splash) + sim.splash.activate(s.waterLevel); + else + sim.splash.deactivate(); + sim.splash.buoyancy = s.buoyancy; // The bound follows the body; the next sweep starts where it is (the // host's bound matrix does not travel: it differs from the body's only // by a push during a contact). @@ -304,23 +335,14 @@ bool ResetRules::allows(const net::CarCommand& c) const { }; switch (c.kind) { case net::CarCommandKind::ResetTo: return !placed && inCity(c.position); - case net::CarCommandKind::ClearDamage: return placed && (debug || wrecked || copsAndRobbers); + case net::CarCommandKind::ClearDamage: return placed && (debug || wrecked); case net::CarCommandKind::Reset: case net::CarCommandKind::RespawnAt: break; } - if (!placed || c.seq < lastMoveSeq + kMoveInterval) + // The water and the fall are the host's own (NetCarDriver::checkWater). + if (!debug || !placed || c.seq < lastMoveSeq + kMoveInterval) return false; - if (c.kind == net::CarCommandKind::RespawnAt) { - if (!inCity(c.position)) - return false; - const bool atCheckpoint = std::ranges::any_of(respawnPoints, [&](const Mat34& at) { - return at.m3.dist2(c.position) < 0.25f && - std::abs(std::remainder(phys::resetRotationOf(at) - c.rotation, 6.2831853f)) < 0.05f; - }); - if (!atCheckpoint && !debug) - return false; - } - return debug || waterSamples >= kWaterSamples || height < kDropHeight; + return c.kind != net::CarCommandKind::RespawnAt || inCity(c.position); } net::VehicleSnapshot carSnapshot(const SimVehicle& car, const net::CarInputFrame& input) { @@ -446,9 +468,11 @@ CarPrediction::Correction CarPrediction::acknowledge(SimVehicle& car, NetCarDriv out.damage = base.car.damage.currentDamage != host.damage; out.held = base.held != host.held; out.gear = base.car.trans.currentGear != host.gear; + out.water = base.car.splash.active() != host.splash || base.driver.waterTime() != host.waterTime; const bool differs = out.positionError > m_options.positionTolerance || out.velocityError > m_options.velocityTolerance || - rotation > m_options.rotationTolerance || out.damage || out.held || out.gear; + rotation > m_options.rotationTolerance || out.damage || out.held || out.gear || + out.water; const std::size_t later = m_history.size() - 1 - index; const bool replay = differs || !companions.empty(); if (replay && later > m_options.maxReplay) { @@ -502,6 +526,7 @@ CarPrediction::Correction CarPrediction::acknowledge(SimVehicle& car, NetCarDriv restore(base, car, driver); if (differs) { applyOwnCarState(car, host); + driver.setWaterTime(host.waterTime); save(base, car, driver); } // The bodies in the world's order (the players' cars by number), so diff --git a/src/game/net/PlayerCars.h b/src/game/net/PlayerCars.h index 6847764..b03c5e9 100644 --- a/src/game/net/PlayerCars.h +++ b/src/game/net/PlayerCars.h @@ -10,7 +10,8 @@ // players, with the traffic and with the props happen once, on the host. // // NetCarDriver what a machine does to a car each sample: the commands -// due (resets), then the input (mmGame:: +// due (resets), the water and fall handlers (mmGame:: +// Update's checks), then the input (mmGame:: // UpdateSteeringBrakes, the gearbox keys, the hold, the // gold's mass), the same on the host and on the car's // own machine @@ -61,15 +62,43 @@ class NetCarDriver { // With net::kInputRegen first mmPlayer::UpdateRegen (OpenMM2: once a // sample, MM2 once a frame); returns true when that cleared the car's // damage (mmPlayer::ResetDamage). + // Before all that, with a water handler set, mmGame::Update's water and + // fall checks on the car as the last sample left it (MM2 runs them after + // each frame's update, OpenMM2 before each sample, on every machine + // that simulates the car: the host decides, its player's machine + // predicts): below -50 m (mmGame::DropThruCityHandler, which mmGameMulti + // and mmMultiCR turn into the water's handler) or more than 5 s in the + // water (its vehSplash latched; the time counts the samples, MM2's the + // frames) calls the handler. bool apply(SimVehicle& car, const net::CarInputFrame& in); // A reset the client's rules asked for (net::CarCommand). static void command(SimVehicle& car, const net::CarCommand& c); std::uint16_t extraMass() const { return m_extraMass; } + // What the water's handler does to the car: Reset (mmGame:: + // HitWaterHandler, mmMultiCR's: mmPlayer::Reset at its reset position) + // or RespawnAt the last checkpoint it cleared (mmGameMulti:: + // HitWaterHandler in a race). Without one (the default) the car is + // never checked: a car this machine only follows. + void setWaterHandler(const std::optional& handler) { m_waterHandler = handler; } + // How long the car has been in the water (mmPlayer's timer). + float waterTime() const { return m_waterTime; } + void setWaterTime(float t) { m_waterTime = t; } + // The handler's resets since the start (wraps). + std::uint32_t waterResets() const { return m_waterResets; } + + static constexpr float kDropHeight = -50.0f; // mmGame::Update + static constexpr float kWaterSeconds = 5.0f; // mmGame::Update: the handler after that long + private: + void checkWater(SimVehicle& car); + float m_baseMass = 0.0f; std::uint16_t m_extraMass = 0; + std::optional m_waterHandler; + float m_waterTime = 0.0f; + std::uint32_t m_waterResets = 0; }; // --- The host ------------------------------------------------------------------------ @@ -134,46 +163,36 @@ class HostInputQueue { std::uint64_t m_missed = 0, m_skipped = 0; }; -// Whether the host carries out a client's reset of its car (OpenMM2: the -// game resets a network player's car only where its rules do, and the host -// checks them on its own simulation of the car; a hostile client would -// teleport, or repair its car at will): +// Whether the host carries out a client's command on its car (OpenMM2: the +// game resets a network player's car only where its rules do, and those +// run on the host; a hostile client would teleport, or repair its car at +// will): // * ResetTo puts the car at its start: only the first command, which // places it, in the city; -// * Reset and RespawnAt (mmPlayer::Reset from mmGameMulti::HitWaterHandler, -// and DropThruCityHandler, which multiplayer treats as the water): the car -// has been in the water (its vehSplash latched) for nearly -// HitWaterHandler's 5 s, or fell below mmGame::Update's -50 m (the client's -// rules see its own car a little ahead of the host's: a second's and 10 m's -// slack); RespawnAt (a race with checkpoints) only at one of the race's -// checkpoints, facing its heading (Session's respawn there); not more than -// four times a second; +// * Reset and RespawnAt: never (since protocol 14 the water and the fall +// are the host's own: NetCarDriver::setWaterHandler); // * ClearDamage (vehCar::ClearDamage): the wreck penalty's repair (the car is -// past its maximum damage), or Cops and Robbers' repair at a delivery of the -// gold (mmMultiCR's UpdateBank / UpdateHideout: allowed in that mode until -// its rules run on the host). +// past its maximum damage). Cops and Robbers' repair at a delivery is the +// host's own since protocol 10. // `debug` (OPENMM2_DEBUG_RESPAWN_MS on the host, a development aid) lets any -// reset in the city pass. +// reset in the city pass, not more than four times a second. struct ResetRules { bool placed = false; // the car is in the race (its first command carried out) std::uint32_t lastMoveSeq = 0; // the sample of the last command that moved it - int waterSamples = 0; // the samples it has been in the water - float height = 0.0f; // its centre of mass's y bool wrecked = false; // past its maximum damage (mmPlayer::IsMaxDamaged) - bool copsAndRobbers = false; bool debug = false; - Aabb city; // the city's bounds (200 m round them allowed) - std::span respawnPoints; // the race's checkpoints' spawns (game::session::spawnAt) + Aabb city; // the city's bounds (200 m round them allowed) static constexpr std::uint32_t kMoveInterval = 15; // samples - static constexpr int kWaterSamples = 4 * 60; // HitWaterHandler's 5 s less a second - static constexpr float kDropHeight = -50.0f + 10.0f; bool allows(const net::CarCommand& c) const; }; -// The car's state as the host sends it to its player, and the car put there -// (the rest of its state stays as it was). -net::OwnCarState ownCarState(const SimVehicle& car, std::uint32_t resets); +// The car's state as the host sends it to its player (`driver`: the one that +// runs it, for the water handler's time), and the car put there (the rest of +// its state stays as it was; the water handler's time is the driver's, +// NetCarDriver::setWaterTime). +net::OwnCarState ownCarState(const SimVehicle& car, std::uint32_t resets, + const NetCarDriver* driver = nullptr); void applyOwnCarState(SimVehicle& car, const net::OwnCarState& state); // A player's car as the others draw it. @@ -221,7 +240,7 @@ class CarPrediction { float positionError = 0.0f; // at the acknowledged sample float velocityError = 0.0f; float rotationError = 0.0f; // the largest difference of the matrices' entries - bool damage = false, held = false, gear = false; // which of these differed + bool damage = false, held = false, gear = false, water = false; // which of these differed Vec3 moved; // where the car is now against where it was predicted bool rebased = false; // other cars were put to the host's state with it (companions) }; diff --git a/src/game/session/CopsAndRobbers.cpp b/src/game/session/CopsAndRobbers.cpp index 773ad52..975ca0d 100644 --- a/src/game/session/CopsAndRobbers.cpp +++ b/src/game/session/CopsAndRobbers.cpp @@ -450,11 +450,21 @@ bool CopsAndRobbers::updatePredicted(float dt, const Car& me, const std::vector< m_goldPos = c.position + Vec3{0.0f, 2.0f, 0.0f}; if (m_over || m_pending || players < 2 || !canTake(me)) return false; - // Another car at the gold too: the host decides between them (its own - // car first), so this machine waits for its word as MM2's did. - for (const auto& c : cars) - if (c.id != me.id && c.position.dist2(m_goldPos) < kGoldRadius * kGoldRadius) + // Another car at the gold too, or on its way there: the host decides + // between them (its own car first), so this machine waits for its word + // as MM2's did. (The nearest point of the car's way in the next + // kContestSeconds.) + const float reach = kGoldRadius + kContestMargin; + for (const auto& c : cars) { + if (c.id == me.id) + continue; + const Vec3 way = c.velocity * kContestSeconds; + const float len2 = way.dot(way); + const float along = + len2 > 0.0f ? std::clamp((m_goldPos - c.position).dot(way) / len2, 0.0f, 1.0f) : 0.0f; + if ((c.position + way * along).dist2(m_goldPos) < reach * reach) return false; + } // UpdateGold on a machine that is not the host: MM2 asks the host // (0x25e) and shows the gold taken when the host's 0x25a arrives; // OpenMM2 shows it at once and the host's decision confirms it. diff --git a/src/game/session/CopsAndRobbers.h b/src/game/session/CopsAndRobbers.h index 424c7f5..72c8120 100644 --- a/src/game/session/CopsAndRobbers.h +++ b/src/game/session/CopsAndRobbers.h @@ -83,7 +83,14 @@ class CopsAndRobbers { Vec3 position; bool wrecked = false; bool inWater = false; // hit the water or fell out of the city this frame + Vec3 velocity; // a client's prediction: where the car is heading (m/s) }; + // A client's prediction of its own pickup waits for the host's word while + // another car is within the gold's radius and this margin, or reaches it + // within this time on its way (OpenMM2: the host decides between them; + // inferred margins, the other cars being a client's estimate). + static constexpr float kContestMargin = 1.0f; // m + static constexpr float kContestSeconds = 0.25f; // s struct Impact { int a = 0, b = 0; // car ids // vehDamageImpactInfo's total (the damage values summed while the @@ -166,9 +173,13 @@ class CopsAndRobbers { // its own car `me` taking free gold within reach (UpdateGold's test, the // car not locked out by a wreck or a lost gold): a prediction, shown at // once (GoldTaken), which the host's decision confirms or undoes - // (applyHost, adopt); not while another car is at the gold too (the host - // decides between them). `players` counts the players in the game. - // Returns true when it predicted a pickup. + // (applyHost, adopt); not while another car is at the gold too or about + // to be (kContestMargin, kContestSeconds: the host decides between + // them); `cars` should then be where the host will have them when it + // runs this machine's sample (the cars this machine simulates with its + // own as they are, the others run on from where they are drawn). + // `players` counts the players in the game. Returns true when it + // predicted a pickup. bool updatePredicted(float dt, const Car& me, const std::vector& cars, int players); // The host's decision (a Message from updateHost) on a machine that is // not the host: as receive(), except that a predicted pickup it confirms diff --git a/src/game/session/RaceReferee.cpp b/src/game/session/RaceReferee.cpp index 2eb4eff..92d6da7 100644 --- a/src/game/session/RaceReferee.cpp +++ b/src/game/session/RaceReferee.cpp @@ -187,27 +187,11 @@ void RaceReferee::update(float dt) { } } -bool RaceReferee::mayRespawnAt(std::uint8_t id, const Vec3& position) const { - const Player* p = player(id); - if (!p || m_config.checkpoints.empty()) - return false; - auto near = [&](int index) { - const auto k = static_cast(index); - return k < m_config.checkpoints.size() && spawnAt(m_config.checkpoints[k]).m3.dist2(position) < 0.25f; - }; - return near(0) || std::ranges::any_of(p->hits, [&](std::uint8_t h) { return near(h); }); -} - -std::vector RaceReferee::respawnCheckpoints(std::uint8_t id) const { - std::vector out; +std::optional RaceReferee::lastCleared(std::uint8_t id) const { const Player* p = player(id); if (!p) - return out; - out.push_back(0); - for (const std::uint8_t h : p->hits) - if (std::ranges::find(out, static_cast(h)) == out.end()) - out.push_back(h); - return out; + return std::nullopt; + return p->wp.lastCleared; } int RaceReferee::iconPlace(std::uint8_t viewer, std::uint8_t car) const { diff --git a/src/game/session/RaceReferee.h b/src/game/session/RaceReferee.h index 4776fd9..d8c60a0 100644 --- a/src/game/session/RaceReferee.h +++ b/src/game/session/RaceReferee.h @@ -105,14 +105,10 @@ class RaceReferee { const std::vector& results() const { return m_results; } bool timedOut() const { return m_timedOut; } bool allCounted() const { return m_allCounted; } - // Whether a player's car may be put back at `position` (a water - // handler's respawn, mmGameMulti::HitWaterHandler: at the last - // checkpoint it cleared): the start or a checkpoint the host counted for - // it, within half a metre. - bool mayRespawnAt(std::uint8_t id, const Vec3& position) const; - // The checkpoints such a respawn may be at: the start (0) and every one - // the host counted for the car (none for a player it does not know). - std::vector respawnCheckpoints(std::uint8_t id) const; + // The last checkpoint a player's car cleared (mmWaypoints' respawn + // point, where mmGameMulti::HitWaterHandler puts it back; 0 the start), + // or nothing for a player the referee does not know. + std::optional lastCleared(std::uint8_t id) const; // A car's icon number on `viewer`'s machine (mmGameMulti::UpdateScore's // IconIndex): 0 no icon (finished, or no car), else its place. int iconPlace(std::uint8_t viewer, std::uint8_t car) const; diff --git a/src/game/session/Session.cpp b/src/game/session/Session.cpp index 05b4a15..104645d 100644 --- a/src/game/session/Session.cpp +++ b/src/game/session/Session.cpp @@ -898,7 +898,9 @@ void Session::updateRank(const PlayerState& player, std::span kWaterHandler) { + if (m_waterTimer > kWaterHandler && !netRules()) { m_waterHandled = true; m_waterTimer = 0.0f; hitWater(); @@ -924,6 +926,18 @@ bool Session::updateHazards(float dt, const PlayerState& player) { return false; } +std::optional Session::netRespawnPoint() const { + if (rule() == WaypointRule::None || m_checkpoints.empty()) + return std::nullopt; + const int last = static_cast(m_checkpoints.size()) - 1; + return spawnAt(m_checkpoints[static_cast(std::clamp(m_wp.lastCleared, 0, last))]); +} + +void Session::netWaterReset() { + m_waterHandled = false; + m_waterTimer = 0.0f; +} + void Session::hitWater() { if (multiplayer()) { // mmGameMulti::HitWaterHandler: back to the last checkpoint (cruise: diff --git a/src/game/session/Session.h b/src/game/session/Session.h index ee0573f..ca745bf 100644 --- a/src/game/session/Session.h +++ b/src/game/session/Session.h @@ -306,6 +306,18 @@ class Session { // Where to put the player after Respawn. Mat34 respawnTransform() const { return m_respawn; } + // A network race (netRules): mmGame::Update's water and fall checks run + // on the car's samples (game::NetCarDriver, on the host for every car, + // here predicted for this machine's), not here; the session shows only + // the water's message (and HitWater). Where the water's handler puts + // the car: the last checkpoint it cleared in a race + // (mmGameMulti::HitWaterHandler), nothing elsewhere (back to its reset + // position: mmGame::HitWaterHandler, mmMultiCR's). + std::optional netRespawnPoint() const; + // The handler put this machine's car back: the water's message may + // show again. + void netWaterReset(); + private: struct Racer { int count = 1; // waypoints passed (the start counts) diff --git a/src/net/PlayerCars.h b/src/net/PlayerCars.h index dc6ce1f..2811a94 100644 --- a/src/net/PlayerCars.h +++ b/src/net/PlayerCars.h @@ -224,6 +224,13 @@ struct OwnCarState { bool swapThrottle = false; // the pedals swapped (automatic reverse) bool held = false; // held on the grid std::uint32_t resets = 0; // the commands the host has carried out (wraps) + // The water (protocol 14): vehSplash's latch, buoyancy and level, and how + // long the car has been in it as the water handler counts + // (game::NetCarDriver: the host decides when the water puts the car back). + bool splash = false; + float buoyancy = 0.7f; + float waterLevel = 0.0f; // with `splash` + float waterTime = 0.0f; // Handed to the next sample (protocol 9): phInertialCS's force and torque // so far, each wheel's rolling resistance, and (`contact`: not all zero) // the impulses and pushes of a contact. @@ -275,6 +282,21 @@ bool serialize(S& s, OwnCarState& o) { s.boolean(o.swapThrottle); s.boolean(o.held); s.u32(o.resets); + // (A car that never met the water: two bits.) + s.boolean(o.splash); + bool wet = o.buoyancy != 0.7f || o.waterTime != 0.0f; + s.boolean(wet); + if (wet) { + s.f32(o.buoyancy); + s.f32(o.waterTime); + } else if constexpr (S::kReading) { + o.buoyancy = 0.7f; + o.waterTime = 0.0f; + } + if (o.splash) + s.f32(o.waterLevel); + else if constexpr (S::kReading) + o.waterLevel = 0.0f; s.vec3(o.force); s.vec3(o.torque); for (float& r : o.tireResistance) @@ -310,8 +332,10 @@ bool serialize(S& s, OwnCarState& o) { w.tireDispLong}) good = good && std::abs(v) <= kOwnStateMaxValue; for (float v : {o.engineSpeed, o.gearChangeTime, o.prevGearRpm, o.timeInGear, o.damage, o.stuckTime, - o.drivetrainSpeed[0], o.drivetrainSpeed[1], o.drivetrainSpeed[2]}) + o.drivetrainSpeed[0], o.drivetrainSpeed[1], o.drivetrainSpeed[2], o.buoyancy, + o.waterTime}) good = good && std::abs(v) <= kOwnStateMaxValue; + good = good && std::abs(o.waterLevel) <= kOwnStateMaxCoordinate; if (!good) return s.fail(); } diff --git a/src/net/Protocol.h b/src/net/Protocol.h index 83f0148..ce9f732 100644 --- a/src/net/Protocol.h +++ b/src/net/Protocol.h @@ -42,7 +42,12 @@ inline constexpr std::uint16_t kProtocolMagic = 0x4D32; // "M2" // 10: the host's rules (net/RulesState.h: the rules message; a player's own // word on a checkpoint, a finish or the gold is refused). (8 was its number // on its branch.) -inline constexpr std::uint16_t kProtocolVersion = 10; +// 14: the water and the fall decided by the host (in every car's full state +// its vehSplash and the water handler's time; a client's Reset and +// RespawnAt only for debugging, its Cops and Robbers repair refused), and +// the race's rules state also unreliable (RulesState) between its +// reliable messages. +inline constexpr std::uint16_t kProtocolVersion = 14; inline constexpr std::uint32_t kConnectData = (std::uint32_t{kProtocolMagic} << 16) | kProtocolVersion; // ENet channels. @@ -108,7 +113,8 @@ enum class MsgType : std::uint8_t { PlayerInput, // net/PlayerCars.h CarStates, PropState, // net/PropState.h - Last = PropState, + RulesState, // net/RulesState.h (protocol 14) + Last = RulesState, }; // Sent as ENet disconnect data and in Reject/PlayerLeft messages. diff --git a/src/net/RulesState.h b/src/net/RulesState.h index c4abe2e..ab28551 100644 --- a/src/net/RulesState.h +++ b/src/net/RulesState.h @@ -209,4 +209,23 @@ bool serialize(S& s, RulesMsg& m) { return s.ok(); } +// A race's rules state for one player, sent again unreliably (the State +// channel) between the reliable messages (protocol 14): the waypoints its +// car hit (the newest few), the samples the rules saw, its place and the +// icons, numbered as the reliable messages are (the client takes the newest +// state of either). No decisions, no results: those stay reliable. +struct RulesStateMsg { + static constexpr MsgType kType = MsgType::RulesState; + RulesMsg rules; +}; + +template +bool serialize(S& s, RulesStateMsg& m) { + if (!serialize(s, m.rules)) + return false; + if (m.rules.cops || !m.rules.decisions.empty() || !m.rules.results.empty()) + return s.fail(); + return s.ok(); +} + } // namespace mm2::net diff --git a/src/net/Session.cpp b/src/net/Session.cpp index 99f48f1..58e92a5 100644 --- a/src/net/Session.cpp +++ b/src/net/Session.cpp @@ -1157,6 +1157,16 @@ void Session::clientHandle(MsgType type, std::span data) { m_propStates.push_back(std::move(m)); return; } + case MsgType::RulesState: { + // The host's only (a client hears no one else), during a race. + RulesStateMsg m; + if (m_role != Role::Client || m_phase == SessionPhase::Lobby || !decodeMessage(data, m)) + return; + if (m_rulesStates.size() >= kMaxQueuedRulesStates) + m_rulesStates.erase(m_rulesStates.begin()); + m_rulesStates.push_back(std::move(m.rules)); + return; + } default: return; } } @@ -1385,6 +1395,17 @@ std::size_t Session::sendPropState(std::uint8_t playerId, const PropStateMsg& ms return m_transport->send(r->peer, Channel::State, packet) ? packet.size() : 0; } +std::size_t Session::sendRulesState(std::uint8_t playerId, const RulesStateMsg& msg) { + if (m_role != Role::Host || !m_transport || m_state != State::Active || m_phase == SessionPhase::Lobby || + 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::State, 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 691c58f..758f9d0 100644 --- a/src/net/Session.h +++ b/src/net/Session.h @@ -19,6 +19,7 @@ #include "net/Discovery.h" #include "net/PlayerCars.h" #include "net/PropState.h" +#include "net/RulesState.h" #include "net/Protocol.h" #include "net/Snapshot.h" #include "net/Transport.h" @@ -300,6 +301,16 @@ class Session { std::vector takePropStates() { return std::exchange(m_propStates, {}); } static constexpr std::size_t kMaxQueuedPropStates = 16; + // --- The host's rules state (OpenMM2, net/RulesState.h, protocol 14) --- + // Host: sends one player its race's rules state on the unreliable State + // channel (the reliable messages are game events). Returns the encoded + // size in bytes (0 when nothing was sent). + std::size_t sendRulesState(std::uint8_t playerId, const RulesStateMsg& msg); + // Client: the messages received since the last call, oldest first (at + // most kMaxQueuedRulesStates; older ones are dropped). + std::vector takeRulesStates() { return std::exchange(m_rulesStates, {}); } + static constexpr std::size_t kMaxQueuedRulesStates = 16; + 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). @@ -409,6 +420,7 @@ class Session { std::vector m_playerInputs; // host: the clients' inputs, not yet taken std::vector m_ownCarStates; // client: the host's word on its car, not yet taken std::vector m_propStates; // client: received, not yet taken + std::vector m_rulesStates; // client: received, not yet taken }; // Proof sent in Hello: SHA-256(nonce || password); all zeros when empty. diff --git a/tests/game/test_net_race_rules.cpp b/tests/game/test_net_race_rules.cpp index c81027b..2e922c4 100644 --- a/tests/game/test_net_race_rules.cpp +++ b/tests/game/test_net_race_rules.cpp @@ -289,24 +289,21 @@ TEST(NetRaceRules, RefereeRanksEveryCarAsItsMachineWould) { EXPECT_EQ(r.player(0)->racers, 2); } -TEST(NetRaceRules, RefereeLetsACarRespawnOnlyWhereItHasBeen) { +TEST(NetRaceRules, RefereeNamesTheCheckpointTheWaterPutsACarBackAt) { // mmGameMulti::HitWaterHandler puts the car back at the last checkpoint - // it cleared: the host takes such a reset at the start or a checkpoint - // it counted for that car, not at one it never reached. + // it cleared (mmWaypoints' respawn point): the start until it clears one. RaceReferee::Config c; c.mode = GameMode::Checkpoint; c.checkpoints = straightCourse(3); RaceReferee r(c); r.addPlayer(1); + EXPECT_EQ(r.lastCleared(1), 0); Car a{1}; for (int i = 0; i < 120; ++i) a.sample(r, false); // clears gate 1 ASSERT_EQ(r.player(1)->hits, (std::vector{1})); - EXPECT_TRUE(r.mayRespawnAt(1, c.checkpoints[0].position)); - EXPECT_TRUE(r.mayRespawnAt(1, c.checkpoints[1].position + Vec3{0.2f, 0.0f, 0.2f})); - EXPECT_FALSE(r.mayRespawnAt(1, c.checkpoints[2].position)); - EXPECT_FALSE(r.mayRespawnAt(1, c.checkpoints[1].position + Vec3{2.0f, 0.0f, 0.0f})); - EXPECT_FALSE(r.mayRespawnAt(7, c.checkpoints[0].position)); // nobody's + EXPECT_EQ(r.lastCleared(1), 1); + EXPECT_EQ(r.lastCleared(7), std::nullopt); // nobody's } // --- Cops and Robbers on the host ------------------------------------------------------- @@ -752,6 +749,111 @@ bool pumpAll(std::initializer_list games, const std::function& } // namespace +TEST(NetRaceRules, AClientTakesTheNewestStateOfEitherKind) { + MM2_REQUIRE_GAME_DATA(); + ASSERT_TRUE(rulesRetail()); + // Protocol 14: the race's state also goes unreliably, 20 times a second + // and at once on a hit; whichever copy arrives first stands, and the + // reliable messages' decisions are taken whatever their state's age. + auto hs = netSession(GameMode::Checkpoint, true); + auto cs = netSession(GameMode::Checkpoint, false); + ASSERT_TRUE(hs && cs); + NetRules host, client; + host.begin({true, 0, 7, hs.get(), nullptr, {0, 1}}); + client.begin({false, 1, 7, cs.get(), nullptr, {0, 1}}); + for (auto* r : {&host, &client}) { + r->setName(0, "Host"); + r->setName(1, "Client"); + } + std::vector> wire; + std::vector states; + auto send = [&](std::uint8_t, std::vector payload) { wire.push_back(std::move(payload)); }; + auto sendState = [&](std::uint8_t to, const net::RulesStateMsg& st) { + EXPECT_EQ(to, 1); + // What the State channel carries: encoded and decoded again. + net::RulesStateMsg back; + EXPECT_TRUE(net::decodeMessage(net::encodeMessage(st), back)); + states.push_back(back.rules); + }; + auto event = [](const std::vector& payload) { + NetGameEvent e; + e.from = net::kHostPlayerId; + e.type = static_cast(net::kRulesEvent); + e.payload = payload; + return e; + }; + for (int i = 0; i < 3; ++i) { + hs->update(kStep, away(*hs)); + cs->update(kStep, away(*cs)); + } + const auto& cps = hs->checkpoints(); + std::uint32_t seq = 0; + auto drive = [&](int waypoint, int samples) { + for (int i = 0; i < samples; ++i) { + const Mat34 at = i == samples / 2 ? spawnAt(cps[static_cast(waypoint)]) + : away(*hs).transform; + host.hostSample(1, ++seq, at, kBox, false); + host.hostSample(0, seq, away(*hs).transform, kBox, false); + } + }; + // Nothing happened: the first message goes both ways, then only the + // state, every 50 ms. + host.hostUpdate(kStep, 1000, away(*hs).transform.m3, send, sendState); + EXPECT_EQ(wire.size(), 1u); + EXPECT_EQ(states.size(), 1u); + host.hostUpdate(kStep, 1030, away(*hs).transform.m3, send, sendState); + EXPECT_EQ(states.size(), 1u); + host.hostUpdate(kStep, 1050, away(*hs).transform.m3, send, sendState); + EXPECT_EQ(wire.size(), 1u); + ASSERT_EQ(states.size(), 2u); + EXPECT_TRUE(states[1].decisions.empty()); + EXPECT_TRUE(states[1].results.empty()); + EXPECT_GT(states[1].seq, states[0].seq); + wire.clear(); + states.clear(); + // A hit: both at once. The state arrives first and is taken; the + // reliable message after it is older (its state left alone). + drive(1, 80); + host.hostUpdate(kStep, 1060, away(*hs).transform.m3, send, sendState); + ASSERT_EQ(wire.size(), 1u); + ASSERT_EQ(states.size(), 1u); + client.receiveStates(states, {}); + EXPECT_EQ(cs->netHits(), (std::vector{1})); + EXPECT_EQ(client.stats().statesTaken, 1u); + client.receive({event(wire[0])}, {}); + EXPECT_EQ(cs->netHits(), (std::vector{1})); + EXPECT_EQ(client.stats().received, 1u); + // The same state again, another race's, one with decisions: refused. + client.receiveStates(states, {}); + net::RulesMsg other = states[0]; + other.seq = 900; + other.race = 6; + client.receiveStates({other}, {}); + other.race = 7; + other.decisions.push_back({901, net::RuleDecision::TimedOut}); + client.receiveStates({other}, {}); + EXPECT_EQ(client.stats().statesTaken, 1u); + EXPECT_FALSE(cs->netFinishKnown()); + wire.clear(); + states.clear(); + // The client's car finishes: the decision is reliable only; the state + // that comes first does not finish it, the older reliable message does, + // once. + for (int w = 2; w < static_cast(cps.size()); ++w) + drive(w, 40); + host.hostUpdate(kStep, 1100, away(*hs).transform.m3, send, sendState); + ASSERT_EQ(wire.size(), 1u); + ASSERT_EQ(states.size(), 1u); + client.receiveStates(states, {}); + EXPECT_FALSE(cs->netFinishKnown()); + client.receive({event(wire[0])}, {}); + EXPECT_TRUE(cs->netFinishKnown()); + EXPECT_TRUE(client.finished(1)); + const auto decisions = client.stats().decisions; + client.receive({event(wire[0])}, {}); // a duplicate: stale + EXPECT_EQ(client.stats().decisions, decisions); +} + TEST(NetRaceRules, APlayersOwnWordOnTheRulesReachesNobody) { NetGame host(rulesOptions("Hosty")), cheat(rulesOptions("Cheaty")), honest(rulesOptions("Honesty")); RaceConfig race; diff --git a/tests/game/test_net_water.cpp b/tests/game/test_net_water.cpp new file mode 100644 index 0000000..926bb5d --- /dev/null +++ b/tests/game/test_net_water.cpp @@ -0,0 +1,265 @@ +// The water and the fall in a network race (OpenMM2, protocol 14): mmGame:: +// Update's checks run on a car's samples (game::NetCarDriver), on the host +// for every car it simulates; the car's own machine predicts them and the +// host's states confirm or correct them. A client cannot put its own car +// back (game::ResetRules). +#include "TestData.h" +#include "game/PlayerVehicle.h" +#include "game/net/PlayerCars.h" +#include "net/PlayerCars.h" +#include "phys/World.h" + +#include + +#include +#include +#include +#include + +using namespace mm2; +using namespace mm2::game; + +namespace { + +constexpr float kDt = phys::kFixedSampleStep; + +phys::PolygonSoup flatGround() { + constexpr float half = 3000.0f; + phys::SoupGeometry g; + g.vertices = {{-half, 0, -half}, {-half, 0, half}, {half, 0, half}, {half, 0, -half}}; + g.polys.push_back({{0, 1, 2, 3}, 4, 0}); + g.materialNames = {"default"}; + phys::PolygonSoup soup; + soup.add(g, Mat34::identity(), phys::MaterialTable{}); + soup.finalize(2048.0f); + return soup; +} + +// One machine's world with one car on flat ground, all of it under water +// at `level` (a water room, mmGame::Update's vehSplash test). +struct Machine { + phys::World world; + std::unique_ptr car; + NetCarDriver driver; + Machine(const vfs::Vfs& vfs, std::optional level) { + world.setStatic(flatGround()); + std::string error; + car = SimVehicle::loadPlayer(vfs, "vpbug", &error, false); + EXPECT_TRUE(car) << error; + car->sim().options.player = true; + car->sim().setPolygonalBound(true); + car->sim().ownRandom = true; + car->sim().randomState = 1; + car->addTo(world); + driver.attach(*car); + car->setResetPos(Mat34::translation({0.0f, 0.6f, 0.0f})); + car->reset(); + car->sim().setWaterLevel(level); + } +}; + +const net::CarCommand kReset{0, net::CarCommandKind::Reset, {}, 0.0f}; + +net::CarInputFrame throttle(float amount) { + phys::PedalInput p; + p.accelerator = amount; + net::CarInputFrame f = inputFrame(p); + f.flags = net::kInputAutomatic; + return f; +} + +} // namespace + +TEST(NetWater, TheHandlerPutsTheCarBackAfterFiveSecondsInTheWater) { + MM2_REQUIRE_GAME_DATA(); + Machine m(*test::gameData(), 5.0f); + NetCarDriver idle = m.driver; // no handler: a car this machine only follows + m.driver.setWaterHandler(kReset); + std::vector resets; + for (int t = 1; t <= 700; ++t) { + const std::uint32_t before = m.driver.waterResets(); + m.driver.apply(*m.car, throttle(0.5f)); + if (m.driver.waterResets() != before) { + resets.push_back(t); + // mmPlayer::Reset: back at the reset position, out of the water + // until the next sample finds it under the level again. + EXPECT_FALSE(m.car->sim().splash.active()); + EXPECT_EQ(m.driver.waterTime(), 0.0f); + EXPECT_LT(m.car->sim().body.ics.matrix.m3.dist2(m.car->sim().resetPos()), 0.01f); + } + m.world.step(kDt); + } + // The splash latches in the first sample; the next 300 samples are 5 s + // (in 32-bit steps of 1/60 the sum passes 5 at the 301st). + ASSERT_EQ(resets.size(), 2u); + EXPECT_GE(resets[0], 300); + EXPECT_LE(resets[0], 303); + EXPECT_EQ(resets[1] - resets[0], resets[0] - 1); + + // No handler: never checked. + Machine n(*test::gameData(), 5.0f); + for (int t = 1; t <= 400; ++t) { + idle.apply(*n.car, throttle(0.5f)); + n.world.step(kDt); + } + EXPECT_EQ(idle.waterResets(), 0u); + EXPECT_TRUE(n.car->sim().splash.active()); +} + +TEST(NetWater, BelowTheCityTheHandlerRunsAtOnce) { + MM2_REQUIRE_GAME_DATA(); + Machine m(*test::gameData(), std::nullopt); + // mmGameMulti::HitWaterHandler in a race: at the last checkpoint cleared, + // facing its heading. + const Vec3 checkpoint{120.0f, 3.0f, -40.0f}; + m.driver.setWaterHandler(net::CarCommand{0, net::CarCommandKind::RespawnAt, checkpoint, 1.25f}); + for (int t = 0; t < 30; ++t) { + m.driver.apply(*m.car, throttle(0.0f)); + m.world.step(kDt); + } + EXPECT_EQ(m.driver.waterResets(), 0u); + m.car->sim().body.ics.matrix.m3.y = -50.5f; // mmGame::Update's -50 m + m.driver.apply(*m.car, throttle(0.0f)); + EXPECT_EQ(m.driver.waterResets(), 1u); + EXPECT_GT(m.car->sim().body.ics.matrix.m3.y, 0.0f); + EXPECT_LT(std::abs(m.car->sim().body.ics.matrix.m3.x - checkpoint.x), 1.0f); + EXPECT_LT(std::abs(m.car->sim().body.ics.matrix.m3.z - checkpoint.z), 1.0f); +} + +namespace { + +struct WaterRun { + std::vector clientResets; // the client's samples its prediction put the car back at + std::vector hostResets; // the client's input numbers the host did at + int corrections = 0; + float finalDistance = 0.0f; // the client's car against the host's after its last sample + std::uint64_t refused = 0; +}; + +// A client drives its car in the water, its inputs reaching the host +// `latency` samples later and the host's states coming back as late, every +// third sample. `predict`: the client runs the water's handler on its own +// car; `cheatAt`: it asks the host to put its car back at that sample. +WaterRun waterRace(const vfs::Vfs& vfs, int samples, std::uint32_t latency, bool predict, + std::uint32_t cheatAt = 0) { + // (The host's car stays out of the water until the client's placement + // puts it in the race.) + Machine host(vfs, std::nullopt), client(vfs, 5.0f); + host.driver.setWaterHandler(kReset); + if (predict) + client.driver.setWaterHandler(kReset); + CarPrediction prediction; + HostInputQueue queue; + bool placed = false; + std::uint32_t lastMove = 0; + std::deque> up; + std::deque>> down; + WaterRun run; + for (std::uint32_t t = 1; t <= static_cast(samples); ++t) { + while (!down.empty() && down.front().first <= t) { + const auto& [ack, state] = down.front().second; + if (prediction.acknowledge(*client.car, client.driver, client.world, ack, state).corrected) + ++run.corrections; + down.pop_front(); + } + if (prediction.nextSeq() == 1) + prediction.command(*client.car, {0, net::CarCommandKind::ResetTo, client.car->sim().resetPos(), + client.car->sim().resetRotation}); + if (cheatAt != 0 && prediction.nextSeq() == cheatAt) + prediction.command(*client.car, kReset); + const std::uint32_t seq = prediction.nextSeq(); + const std::uint32_t before = client.driver.waterResets(); + prediction.beginSample(*client.car, client.driver, throttle(0.4f)); + if (client.driver.waterResets() != before) + run.clientResets.push_back(seq); + client.world.step(kDt); + prediction.endSample(*client.car, client.driver); + if (const auto m = prediction.message()) + up.emplace_back(t + latency, *m); + while (!up.empty() && up.front().first <= t) { + queue.receive(up.front().second); + up.pop_front(); + } + if (!placed && queue.ready()) + if (const auto c = queue.placement()) { + NetCarDriver::command(*host.car, *c); + host.car->sim().setWaterLevel(5.0f); + lastMove = c->seq; + placed = true; + } + if (placed) + if (auto next = queue.next()) { + for (const auto& c : next->commands) { + if (c.kind == net::CarCommandKind::ResetTo && c.seq == lastMove) + continue; + ResetRules r; + r.placed = true; + r.lastMoveSeq = lastMove; + r.city = {{-3000.0f, -100.0f, -3000.0f}, {3000.0f, 300.0f, 3000.0f}}; + if (!r.allows(c)) { + ++run.refused; + continue; + } + NetCarDriver::command(*host.car, c); + } + const std::uint32_t hostBefore = host.driver.waterResets(); + host.driver.apply(*host.car, next->frame); + if (host.driver.waterResets() != hostBefore) + run.hostResets.push_back(next->seq); + } + host.world.step(kDt); + if (placed && t % 3 == 0 && queue.lastApplied() != 0) + down.emplace_back(t + latency, + std::pair{queue.lastApplied(), ownCarState(*host.car, 0, &host.driver)}); + } + // The host runs the client's last inputs: both cars at the same sample. + for (const auto& [due, m] : up) + queue.receive(m); + while (queue.lastApplied() + 1 < prediction.nextSeq()) { + auto next = queue.next(); + if (!next) + break; + host.driver.apply(*host.car, next->frame); + host.world.step(kDt); + } + run.finalDistance = + std::sqrt(client.car->sim().body.ics.matrix.m3.dist2(host.car->sim().body.ics.matrix.m3)); + return run; +} + +} // namespace + +TEST(NetWater, AClientPredictsTheWaterAsTheHostDecidesIt) { + MM2_REQUIRE_GAME_DATA(); + // The same simulation on both: the client's car goes back at the very + // samples the host's does, and nothing is corrected. + const WaterRun run = waterRace(*test::gameData(), 900, 6, true); + ASSERT_EQ(run.hostResets.size(), 2u); + EXPECT_EQ(run.clientResets, run.hostResets); + EXPECT_EQ(run.corrections, 0); + EXPECT_LT(run.finalDistance, 0.01f); +} + +TEST(NetWater, TheHostPutsBackACarWhoseClientDoesNot) { + MM2_REQUIRE_GAME_DATA(); + // A client that leaves its car in the water (no handler of its own) is + // put back by the host's states all the same. + const WaterRun run = waterRace(*test::gameData(), 900, 6, false); + ASSERT_EQ(run.hostResets.size(), 2u); + EXPECT_TRUE(run.clientResets.empty()); + EXPECT_GE(run.corrections, 2); + EXPECT_LT(run.finalDistance, 0.01f); +} + +TEST(NetWater, AClientCannotPutItsOwnCarBack) { + MM2_REQUIRE_GAME_DATA(); + // A client asking for a reset two seconds into the water: refused; the + // host's states take its car back into the water, and the water's own + // handler runs at the host's time. + const WaterRun honest = waterRace(*test::gameData(), 900, 6, true); + const WaterRun cheat = waterRace(*test::gameData(), 900, 6, true, 120); + EXPECT_EQ(cheat.refused, 1u); + EXPECT_EQ(cheat.hostResets, honest.hostResets); + EXPECT_GE(cheat.corrections, 1); + EXPECT_LT(cheat.finalDistance, 0.01f); +} diff --git a/tests/game/test_player_cars.cpp b/tests/game/test_player_cars.cpp index d6367c1..58e56a6 100644 --- a/tests/game/test_player_cars.cpp +++ b/tests/game/test_player_cars.cpp @@ -310,9 +310,6 @@ TEST(PlayerCars, AShuntIsCorrectedOnlyWhereTheOtherPlayerChangedItsInput) { TEST(PlayerCars, TheHostResetsAClientsCarOnlyWhereTheRulesDo) { ResetRules r; r.city = {{-2000.0f, -100.0f, -2000.0f}, {2000.0f, 300.0f, 2000.0f}}; - const Mat34 checkpoint = Mat34::rotationY(1.0f) * Mat34::translation({100.0f, 5.0f, 200.0f}); - const Mat34 points[] = {checkpoint}; - r.respawnPoints = points; const net::CarCommand placeAt{1, net::CarCommandKind::ResetTo, {10.0f, 2.0f, 30.0f}, 0.5f}; // The start: once, in the city. EXPECT_TRUE(r.allows(placeAt)); @@ -322,44 +319,26 @@ TEST(PlayerCars, TheHostResetsAClientsCarOnlyWhereTheRulesDo) { r.placed = true; r.lastMoveSeq = 1; EXPECT_FALSE(r.allows({600, net::CarCommandKind::ResetTo, {10.0f, 2.0f, 30.0f}, 0.5f})); - // Back to the reset position: only from the water or below the city. + // Back to the reset position or at a checkpoint: never (the water and + // the fall are the host's own, NetCarDriver::setWaterHandler). const net::CarCommand reset{600, net::CarCommandKind::Reset, {}, 0.0f}; - r.height = 2.0f; EXPECT_FALSE(r.allows(reset)); - r.waterSamples = ResetRules::kWaterSamples - 1; - EXPECT_FALSE(r.allows(reset)); - r.waterSamples = ResetRules::kWaterSamples; - EXPECT_TRUE(r.allows(reset)); - r.waterSamples = 0; - r.height = -60.0f; - EXPECT_TRUE(r.allows(reset)); - // Not four times a second. - r.lastMoveSeq = 595; - EXPECT_FALSE(r.allows(reset)); - r.lastMoveSeq = 1; - // At a checkpoint, facing its heading. - r.height = -60.0f; - const float heading = phys::resetRotationOf(checkpoint); - EXPECT_TRUE(r.allows({600, net::CarCommandKind::RespawnAt, checkpoint.m3, heading})); - EXPECT_FALSE(r.allows({600, net::CarCommandKind::RespawnAt, checkpoint.m3, heading + 0.5f})); - EXPECT_FALSE(r.allows({600, net::CarCommandKind::RespawnAt, checkpoint.m3 + Vec3{3.0f, 0, 0}, heading})); - r.height = 2.0f; - EXPECT_FALSE(r.allows({600, net::CarCommandKind::RespawnAt, checkpoint.m3, heading})); - // Repairs: a wreck's, or Cops and Robbers'. + EXPECT_FALSE(r.allows({600, net::CarCommandKind::RespawnAt, {100.0f, 5.0f, 200.0f}, 1.0f})); + // Repairs: a wreck's only (Cops and Robbers' at a delivery is the host's). const net::CarCommand repair{600, net::CarCommandKind::ClearDamage, {}, 0.0f}; EXPECT_FALSE(r.allows(repair)); r.wrecked = true; EXPECT_TRUE(r.allows(repair)); - r.wrecked = false; - r.copsAndRobbers = true; - EXPECT_TRUE(r.allows(repair)); - // The development hook lets any reset in the city through. + // The development hook lets any reset in the city through, not four times + // a second. ResetRules d = r; d.debug = true; - d.copsAndRobbers = false; + d.wrecked = false; EXPECT_TRUE(d.allows(reset)); EXPECT_TRUE(d.allows({600, net::CarCommandKind::RespawnAt, {0.0f, 1.0f, 0.0f}, 0.0f})); EXPECT_FALSE(d.allows({600, net::CarCommandKind::RespawnAt, {0.0f, 1.0f, 9000.0f}, 0.0f})); + d.lastMoveSeq = 595; + EXPECT_FALSE(d.allows(reset)); } TEST(PlayerCars, InputFramesCarryTheRecordedPedalsExactly) { diff --git a/tests/net/test_rules_state.cpp b/tests/net/test_rules_state.cpp index 680242a..528b52a 100644 --- a/tests/net/test_rules_state.cpp +++ b/tests/net/test_rules_state.cpp @@ -252,3 +252,45 @@ TEST(RulesState, RandomAndMutatedPayloadsDecodeSafely) { } EXPECT_GT(decoded, 0); } + +TEST(RulesState, TheUnreliableStateCarriesARacesStateAlone) { + // Protocol 14: RulesState on the State channel, the same message + // without decisions or results; anything else is refused. + RulesStateMsg st; + st.rules = raceMessage(); + st.rules.decisions.clear(); + st.rules.results.clear(); + RulesStateMsg back; + ASSERT_TRUE(decodeMessage(encodeMessage(st), back)); + EXPECT_EQ(back.rules, st.rules); + RulesStateMsg withDecisions; + withDecisions.rules = raceMessage(); + ASSERT_FALSE(withDecisions.rules.decisions.empty()); + EXPECT_FALSE(decodeMessage(encodeMessage(withDecisions), back)); + RulesStateMsg cops; + cops.rules = copsMessage(); + cops.rules.decisions.clear(); + EXPECT_FALSE(decodeMessage(encodeMessage(cops), back)); + // Random and damaged packets decode safely, or not at all. + std::mt19937 rng(14); + const auto seed = encodeMessage(st); + int decoded = 0; + for (int i = 0; i < 3000; ++i) { + auto bytes = seed; + for (int k = 0; k < 1 + i % 5; ++k) { + const auto at = 1 + static_cast(rng()) % (bytes.size() - 1); + bytes[at] ^= static_cast(1 << (rng() % 8)); + } + if (i % 5 == 0) + bytes.resize(1 + static_cast(rng()) % bytes.size()); + RulesStateMsg m; + if (decodeMessage(bytes, m)) { + ++decoded; + EXPECT_FALSE(m.rules.cops); + EXPECT_TRUE(m.rules.decisions.empty()); + EXPECT_TRUE(m.rules.results.empty()); + expectSane(m.rules); + } + } + EXPECT_GT(decoded, 0); +} diff --git a/tests/net/test_water_state.cpp b/tests/net/test_water_state.cpp new file mode 100644 index 0000000..2bcb6c7 --- /dev/null +++ b/tests/net/test_water_state.cpp @@ -0,0 +1,69 @@ +// The water in a car's full state (protocol 14, net/PlayerCars.h +// OwnCarState): the host decides when the water puts a car back, and a +// client carries on from the host's state with its vehSplash and the water +// handler's time. +#include "net/PlayerCars.h" + +#include + +#include + +using namespace mm2; +using namespace mm2::net; + +namespace { + +CarStatesMsg ownOnly(const OwnCarState& own) { + CarStatesMsg m; + m.ack = 300; + m.hasOwn = true; + m.own = own; + m.own.matrix = Mat34::identity(); + return m; +} + +} // namespace + +TEST(WaterState, TheSplashAndTheHandlersTimeTravelWithTheCar) { + OwnCarState wet; + wet.splash = true; + wet.buoyancy = 0.61f; + wet.waterLevel = -1.9f; + wet.waterTime = 3.25f; + CarStatesMsg out; + ASSERT_TRUE(decodeMessage(encodeMessage(ownOnly(wet)), out)); + EXPECT_TRUE(out.own.splash); + EXPECT_EQ(out.own.buoyancy, 0.61f); + EXPECT_EQ(out.own.waterLevel, -1.9f); + EXPECT_EQ(out.own.waterTime, 3.25f); + // Out again (vehCar::Reset clears the latch, not the buoyancy). + OwnCarState out2 = wet; + out2.splash = false; + out2.waterTime = 0.0f; + ASSERT_TRUE(decodeMessage(encodeMessage(ownOnly(out2)), out)); + EXPECT_FALSE(out.own.splash); + EXPECT_EQ(out.own.buoyancy, 0.61f); + EXPECT_EQ(out.own.waterLevel, 0.0f); + // A car that never met the water costs two bits more than before. + const auto dry = encodeMessage(ownOnly(OwnCarState{})); + const auto inWater = encodeMessage(ownOnly(wet)); + EXPECT_EQ(inWater.size(), dry.size() + 12); + ASSERT_TRUE(decodeMessage(dry, out)); + EXPECT_FALSE(out.own.splash); + EXPECT_EQ(out.own.buoyancy, 0.7f); + EXPECT_EQ(out.own.waterTime, 0.0f); +} + +TEST(WaterState, AWaterStateOutOfRangeIsRefused) { + CarStatesMsg out; + OwnCarState bad; + bad.splash = true; + bad.waterLevel = 1.0e6f; + EXPECT_FALSE(decodeMessage(encodeMessage(ownOnly(bad)), out)); + bad = {}; + bad.waterTime = std::numeric_limits::infinity(); + EXPECT_FALSE(decodeMessage(encodeMessage(ownOnly(bad)), out)); + bad = {}; + bad.buoyancy = 1.0e9f; + EXPECT_FALSE(decodeMessage(encodeMessage(ownOnly(bad)), out)); +} From 83f57624552ec77efdeec804c708c5fa96517dd4 Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Fri, 9 Oct 2026 20:26:06 -0700 Subject: [PATCH 089/129] Put a client's car to the host's state whenever it runs again with near cars Within the tolerance a client kept its own prediction, but in a contact what the tolerance let pass grew from state to state: with near cars it may meet, the samples run again from the host's state either way (a shunt with the other car braking: 2 corrections over the tolerance instead of 7; a steady one: none). Co-Authored-By: Claude Opus 5.5 (1M context) --- src/game/net/PlayerCars.cpp | 6 +++++- tests/game/test_player_cars.cpp | 5 +++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/game/net/PlayerCars.cpp b/src/game/net/PlayerCars.cpp index 5fb0f76..63e8e85 100644 --- a/src/game/net/PlayerCars.cpp +++ b/src/game/net/PlayerCars.cpp @@ -518,7 +518,11 @@ CarPrediction::Correction CarPrediction::acknowledge(SimVehicle& car, NetCarDriv } else if (replay) { const Vec3 before = car.sim().modelMatrix().m3; restore(base, car, driver); - if (differs) { + // With other cars it may meet, the host's state even within the + // tolerance: in a contact what the tolerance lets pass grows from + // state to state (a shunt with the other car braking: 7 corrections + // over the tolerance before, 2 with it). + if (differs || !companions.empty()) { applyOwnCarState(car, host); save(base, car, driver); } diff --git a/tests/game/test_player_cars.cpp b/tests/game/test_player_cars.cpp index 06433da..c80c074 100644 --- a/tests/game/test_player_cars.cpp +++ b/tests/game/test_player_cars.cpp @@ -313,7 +313,8 @@ TEST(PlayerCars, AShuntPredictedWithTheOtherCarAsTheHostRunsIt) { TEST(PlayerCars, AShuntIsCorrectedOnlyWhereTheOtherPlayerChangedItsInput) { MM2_REQUIRE_GAME_DATA(); // The host's car brakes hard while the client's pushes it: the client - // learns of it a trip late and is corrected, by less than a metre. + // learns of it a trip late; corrected (a few centimetres, in the hard + // contact after) by less than a metre. const ShuntOutcome run = shunt(*test::gameData(), true, 0.3f, 200); std::printf("[ measure ] shunt with the host's car braking: %d corrections, largest %.3f m\n", run.corrections, static_cast(run.largest)); @@ -325,7 +326,7 @@ TEST(PlayerCars, AShuntIsCorrectedOnlyWhereTheOtherPlayerChangedItsInput) { const ShuntOutcome relayed = shunt(*test::gameData(), true, 0.3f, 200, 6); std::printf("[ measure ] the same with six inputs ahead: %d corrections, largest %.3f m\n", relayed.corrections, static_cast(relayed.largest)); - EXPECT_LT(relayed.corrections, run.corrections); + EXPECT_LE(relayed.corrections, run.corrections); EXPECT_LE(relayed.largest, run.largest + 1e-4f); } From 8dbc9a31a54a328a9d522b7798c9debabba0c412 Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Fri, 9 Oct 2026 20:39:13 -0700 Subject: [PATCH 090/129] Check that only the host's rules state reaches a player The live three-machine test: a client's NetGame sends no RulesState (to the host or to another player), the host's reaches its player and the host takes none. The players' cars review's O4 row notes protocol 14. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/review/multiplayer-desync-cars.md | 2 +- tests/game/test_net_race_rules.cpp | 17 +++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/docs/review/multiplayer-desync-cars.md b/docs/review/multiplayer-desync-cars.md index 293a6d0..848c2d4 100644 --- a/docs/review/multiplayer-desync-cars.md +++ b/docs/review/multiplayer-desync-cars.md @@ -509,7 +509,7 @@ apart, as in the first round, about 11 and 78 KB/s. | O1 | visible | the prediction | Corrections of up to 2-3 m while shunting another player's car, which the client sees in the past. At worst (one rear run of six) a correction puts the client's car where the host has it, right behind the other car, which is inside that car's past image: the car is pushed out every sample and shakes from side to side by up to 1.5 m for under a second. | fixed (c5c1b4c, 94d542a, 38fcf08): a client simulates the near cars with its own from the host's full states, in the host's order; corrections over 1 m in the rear runs 1 and 7 (first round 40 and 53), no jump over 1 m | | O2 | visible | props | Each machine runs its own props, so a car knocking them is corrected (the props work makes them the host's). | the props work landed (14dc206): docs/review/multiplayer-desync-props.md; a chase with props knocked is now corrected about 130-240 times a minute by a few millimetres | | O3 | minor | rules | Checkpoints, laps, finishes and Cops and Robbers' gold were decided on each player's own machine and relayed (a client could claim them); only the limits were the host's. | fixed (protocol 10, docs/review/multiplayer-desync-rules.md): the host runs every car's waypoints after every sample (`game::session::RaceReferee`), the finish exchange, timeout and standings, and Cops and Robbers' rules for every car (`CopsAndRobbers::updateHost`), and tells each player (`game::NetRules`); a client predicts its checkpoints and a pickup, which the host confirms or corrects, and the host refuses a player's own rule events | -| O4 | minor | commands | The host checks a client's resets only against the city's box and four a second, not against the race's checkpoints and the water. | fixed (78886cf): `game::ResetRules` (the water, the fall, the race's checkpoints, a wreck's or Cops and Robbers' repair) on the host's simulation of the car; in a race a respawn only at the start or at a checkpoint the host's referee counted for that car (docs/review/multiplayer-desync-rules.md) | +| O4 | minor | commands | The host checks a client's resets only against the city's box and four a second, not against the race's checkpoints and the water. | fixed (78886cf): `game::ResetRules` (the water, the fall, the race's checkpoints, a wreck's or Cops and Robbers' repair) on the host's simulation of the car; in a race a respawn only at the start or at a checkpoint the host's referee counted for that car (docs/review/multiplayer-desync-rules.md); since protocol 14 the water and the fall are the host's own (`NetCarDriver::setWaterHandler`) and a client's reset is refused, a repair only the wreck penalty's | | O5 | minor | `CarStatesMsg` | The own car's state is 270 bytes at 20 Hz to every client (78 KB/s for the host with eight players); since protocol 9 about 310 (370 in a contact), and up to two near cars in full: at most about 160 KB/s for the host with eight players all near each other. | open: it could go only when it changed beyond the tolerance, a near car only while the two can meet | | O6 | minor | MSVC against GCC | Not testable here; the noise test suggests corrections in crashes, eased away. | open | | O7 | minor | replays | The shared traffic's cars still on their rails are held where the frame placed them while a client runs its samples again. | fixed (e51e9bf, 60ddfd0): where each sample met them (corrections over 10 cm driving through the traffic 3 and 60 against 194 and 205; `poseAt` 34 and 89) | diff --git a/tests/game/test_net_race_rules.cpp b/tests/game/test_net_race_rules.cpp index 2e922c4..d67f861 100644 --- a/tests/game/test_net_race_rules.cpp +++ b/tests/game/test_net_race_rules.cpp @@ -927,4 +927,21 @@ TEST(NetRaceRules, APlayersOwnWordOnTheRulesReachesNobody) { })); EXPECT_EQ(static_cast(got[0].type), net::kRulesEvent); EXPECT_EQ(got[0].from, net::kHostPlayerId); + // The unreliable state (protocol 14) goes from the host only: a client + // cannot send one, and the host's reaches its player. + net::RulesStateMsg state; + state.rules.race = host.raceNumber(); + state.rules.seq = 2; + state.rules.hits = {1}; + EXPECT_EQ(cheat.sendRulesState(net::kHostPlayerId, state), 0u); + EXPECT_EQ(cheat.sendRulesState(honest.localId(), state), 0u); + EXPECT_GT(host.sendRulesState(honest.localId(), state), 0u); + std::vector states; + ASSERT_TRUE(pumpAll({&host, &cheat, &honest}, [&] { + for (auto& m : honest.takeRulesStates()) + states.push_back(std::move(m)); + return !states.empty(); + })); + EXPECT_EQ(states[0], state.rules); + EXPECT_TRUE(host.takeRulesStates().empty()); } From da64b7747193c0557fb9ae360e161d2295f1a59c Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Fri, 9 Oct 2026 20:39:58 -0700 Subject: [PATCH 091/129] Report whether a client's own pickups showed before the host's word netprobe rulesreport: for each pickup the host decided for a client's car, whether the client showed it before the host's decision (its prediction) or after (the host's word arriving), with the median times. Co-Authored-By: Claude Opus 5.5 (1M context) --- tools/netprobe/RulesReport.cpp | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tools/netprobe/RulesReport.cpp b/tools/netprobe/RulesReport.cpp index 87b2c60..c36cb6b 100644 --- a/tools/netprobe/RulesReport.cpp +++ b/tools/netprobe/RulesReport.cpp @@ -491,6 +491,24 @@ void gold(const std::vector& machines) { machines[i].path, mine.size(), static_cast(host.size()) - common, static_cast(mine.size()) - common, undone, scores, machines[i].scores == machines.front().scores ? "the same" : "different"); + // Its own pickups the host decided: shown before the host's + // decision (predicted) or after it (the host's word). + std::vector early, late; + for (const auto& h : machines.front().gold) { + if (h.type != 0 || h.car != machines[i].self) + continue; + const Gold* shown = nullptr; + for (const auto& g : machines[i].gold) + if (g.type == 0 && g.car == h.car && std::abs(g.clock - h.clock) < 2000.0 && + (!shown || std::abs(g.clock - h.clock) < std::abs(shown->clock - h.clock))) + shown = &g; + if (shown) + (shown->clock < h.clock ? early : late).push_back(std::abs(shown->clock - h.clock)); + } + if (!early.empty() || !late.empty()) + std::println(" its own pickups: {} shown {:.0f} ms before the host decided them (median), {} " + "{:.0f} ms after (the host's word)", + early.size(), percentile(early, 0.5), late.size(), percentile(late, 0.5)); } } From 86db4c63005b3dbbe93be5d2ba5f84a1139e8a2a Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Fri, 9 Oct 2026 20:40:51 -0700 Subject: [PATCH 092/129] Test a client waiting for the host when a car is about to reach the gold Co-Authored-By: Claude Opus 5.5 (1M context) --- tests/game/test_net_race_rules.cpp | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/game/test_net_race_rules.cpp b/tests/game/test_net_race_rules.cpp index d67f861..fc7e141 100644 --- a/tests/game/test_net_race_rules.cpp +++ b/tests/game/test_net_race_rules.cpp @@ -423,6 +423,22 @@ TEST(NetRaceRules, AClientsPredictedPickupIsConfirmedOrUndone) { const CopsAndRobbers::Car other{0, CrTeam::Robber, f.set().gold + Vec3{2.0f, 0.0f, 0.0f}, false, false}; EXPECT_FALSE(f.updatePredicted(0.1f, me, {me, other}, 2)); EXPECT_EQ(f.goldCarrier(), -1); + // ... or about to be (protocol 14): just outside its reach, or heading + // into it within a quarter of a second; one driving away, or farther + // off, does not hold this machine back. + const Vec3 gold = f.set().gold; + CopsAndRobbers::Car near{0, CrTeam::Robber, gold + Vec3{5.8f, 0.0f, 0.0f}, false, false, {}}; + EXPECT_FALSE(f.updatePredicted(0.1f, me, {me, near}, 2)); + CopsAndRobbers::Car coming{0, CrTeam::Robber, gold + Vec3{12.0f, 0.0f, 0.0f}, false, false, + Vec3{-30.0f, 0.0f, 0.0f}}; + EXPECT_FALSE(f.updatePredicted(0.1f, me, {me, coming}, 2)); + CopsAndRobbers::Car leaving = coming; + leaving.velocity = Vec3{30.0f, 0.0f, 0.0f}; + CopsAndRobbers::Car slow = coming; + slow.velocity = Vec3{-10.0f, 0.0f, 0.0f}; // 2.5 m in a quarter of a second: 9.5 m off + EXPECT_EQ(f.goldCarrier(), -1); + EXPECT_TRUE(f.updatePredicted(0.1f, me, {me, leaving, slow}, 2)); + EXPECT_EQ(f.goldCarrier(), 1); } // --- The message ---------------------------------------------------------------------------- From da767c3531813a7785590f285bb5025058949abf Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Fri, 9 Oct 2026 20:51:51 -0700 Subject: [PATCH 093/129] Keep Cops and Robbers' rules state at four messages a second Its state goes only reliably (no unreliable copy: its scores and gold stand in for decisions), and a predicted pickup the host never granted is undone from it: the idle interval stays 250 ms there, one second in a race. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/multiplayer.md | 15 ++++++++------- src/game/net/NetRules.cpp | 3 ++- src/game/net/NetRules.h | 8 +++++--- 3 files changed, 15 insertions(+), 11 deletions(-) diff --git a/docs/multiplayer.md b/docs/multiplayer.md index ad22748..2140b92 100644 --- a/docs/multiplayer.md +++ b/docs/multiplayer.md @@ -1122,13 +1122,14 @@ for everything) decides them on the host from the cars it simulates (see that car's waypoints hit (the newest 64), its place and the racers, the other cars' icon numbers, the results so far, the timeout and the end; in Cops and Robbers the gold's carrier and place, the set, every score and - the end. It goes at once when something happened for that player and once - a second otherwise. In a race the state alone (the newest 16 waypoints, the - samples seen, the place and the icons; no decisions, no results) also goes - unreliably (`RulesState` on the State channel, protocol 14) at once on a - new hit and 20 times a second, numbered with the messages: whichever - arrives first stands, so a lost packet no longer holds a checkpoint's - confirmation (or its taking back) until ENet sends it again. A client + the end. It goes at once when something happened for that player and + otherwise four times a second in Cops and Robbers, once a second in a + race, where the state alone (the newest 16 waypoints, the samples seen, + the place and the icons; no decisions, no results) also goes unreliably + (`RulesState` on the State channel, protocol 14) at once on a new hit and + 20 times a second, numbered with the messages: whichever arrives first + stands, so a lost packet no longer holds a checkpoint's confirmation (or + its taking back) until ENet sends it again. A client shows each decision once, in order (a reliable message older than the newest state still brings its decisions), and a missed one (a late loader whose queue of events overflowed) is made good by the state, silently. diff --git a/src/game/net/NetRules.cpp b/src/game/net/NetRules.cpp index 63c4474..7ff0f45 100644 --- a/src/game/net/NetRules.cpp +++ b/src/game/net/NetRules.cpp @@ -300,7 +300,8 @@ void NetRules::hostUpdate(float dt, std::uint64_t nowMs, const Vec3& ownPosition const bool news = c.lastDecision < m_log.size(); const auto* p = m_referee ? m_referee->player(id) : nullptr; const bool hit = p && p->hits.size() != c.hits; - if (!news && !hit && c.seq != 0 && nowMs - c.sentAt < kStateIntervalMs) + const std::uint64_t interval = m_referee ? kStateIntervalMs : kCopsStateIntervalMs; + if (!news && !hit && c.seq != 0 && nowMs - c.sentAt < interval) continue; const net::RulesMsg m = message(id, c); auto payload = net::encodePayload(m); diff --git a/src/game/net/NetRules.h b/src/game/net/NetRules.h index 2ffbb55..ac289eb 100644 --- a/src/game/net/NetRules.h +++ b/src/game/net/NetRules.h @@ -49,10 +49,12 @@ bool hostAcceptsGameEvent(std::uint8_t from, std::uint16_t type); class NetRules { public: - // How often a player hears from the host reliably when nothing happened, - // and in a race unreliably (the standings, the samples its rules have - // seen; the CarStates rate). + // How often a player hears from the host reliably when nothing happened + // (in Cops and Robbers, whose state goes no other way, four times a + // second), and in a race unreliably (the standings, the samples its + // rules have seen; the CarStates rate). static constexpr std::uint64_t kStateIntervalMs = 1000; + static constexpr std::uint64_t kCopsStateIntervalMs = 250; static constexpr std::uint64_t kFastStateIntervalMs = 50; // The waypoints a message lists at most (the newest; a client keeps the // earlier ones as it has them), and an unreliable state. From 184c739fa1734a84d8d653b930cd5225a1cd41ab Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Fri, 9 Oct 2026 20:52:23 -0700 Subject: [PATCH 094/129] Carry the police and knocked cars in full, and every near car (protocol 12) The shared traffic's wire, for the client to simulate what its car meets (docs/review/multiplayer-desync-traffic.md, round 2): - TrafficFull (net/TrafficFull.h): the police and knocked cars near a client in full at the physics sample of its CarStates: a police car's whole simulation state with its driver's controls, a knocked car's rigid body, what a sample hands the next, its wheels and sleep state. One datagram (1200 bytes, at most eight cars), on the State channel; checked on receipt like CarStates, queued 32 deep, during a race only. - AmbientState up to 2600 bytes and 320 cars, sent in unreliable fragments (ENet sends an unreliable packet's fragments reliably unless told otherwise): at traffic density 1 the farthest 8 % of the cars within 200 m no longer fit in 1100 bytes. Full rate within 120 m, every fourth state beyond 180 m. Tests: round trips, sizes, refused values and mutated messages, the fuzz corpus, delivery over a real session (a two-fragment message and a full state), and the budget at density 1. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/game/net/NetGame.cpp | 8 + src/game/net/NetGame.h | 4 + src/game/net/TrafficSync.h | 13 +- src/net/AmbientState.h | 2 +- src/net/Protocol.h | 10 +- src/net/Session.cpp | 24 +++ src/net/Session.h | 10 + src/net/TrafficFull.h | 154 +++++++++++++++ src/net/Transport.cpp | 4 +- tests/game/test_traffic_net.cpp | 54 ++++++ tests/game/test_traffic_sync.cpp | 32 ++- tests/net/test_fuzz.cpp | 62 ++++++ tests/net/test_traffic_full.cpp | 323 +++++++++++++++++++++++++++++++ 13 files changed, 688 insertions(+), 12 deletions(-) create mode 100644 src/net/TrafficFull.h create mode 100644 tests/net/test_traffic_full.cpp diff --git a/src/game/net/NetGame.cpp b/src/game/net/NetGame.cpp index 14417c0..b0cef5d 100644 --- a/src/game/net/NetGame.cpp +++ b/src/game/net/NetGame.cpp @@ -893,6 +893,14 @@ std::vector NetGame::takeAmbientStates() { return m_impl->session ? m_impl->session->takeAmbientStates() : std::vector{}; } +std::size_t NetGame::sendTrafficFull(std::uint8_t playerId, const net::TrafficFullMsg& msg) { + return m_impl->session ? m_impl->session->sendTrafficFull(playerId, msg) : 0; +} + +std::vector NetGame::takeTrafficFull() { + return m_impl->session ? m_impl->session->takeTrafficFull() : std::vector{}; +} + std::size_t NetGame::sendPropState(std::uint8_t playerId, const net::PropStateMsg& msg) { return m_impl->session ? m_impl->session->sendPropState(playerId, msg) : 0; } diff --git a/src/game/net/NetGame.h b/src/game/net/NetGame.h index 20caf68..443e828 100644 --- a/src/game/net/NetGame.h +++ b/src/game/net/NetGame.h @@ -334,6 +334,10 @@ class NetGame { 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(); + // Host: one player the police and knocked cars near it in full + // (net/TrafficFull.h); returns the bytes sent. Client: those received. + std::size_t sendTrafficFull(std::uint8_t playerId, const net::TrafficFullMsg& msg); + std::vector takeTrafficFull(); // Connection statistics to a player (host) or to the host (client). net::PeerStats peerStats(std::uint8_t playerId) const; diff --git a/src/game/net/TrafficSync.h b/src/game/net/TrafficSync.h index 16fcd28..4ee6b5a 100644 --- a/src/game/net/TrafficSync.h +++ b/src/game/net/TrafficSync.h @@ -103,12 +103,15 @@ class TrafficHost { // 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; + float fullRateRadius = 120.0f; // ... and beyond this every fourth (the client predicts it the while). - float slowRateRadius = 160.0f; - // The message's size, bytes (the header included): one unfragmented - // UDP datagram. - std::size_t maxBytes = 1100; + float slowRateRadius = 180.0f; + // The message's size, bytes (the header included): two of ENet's + // fragments (its MTU is 1392 bytes, its fragments are sent + // unreliably on the Ambient channel), so that every car within the + // interest radius fits even at traffic density 1 (protocol 12; 1100 + // bytes before, which left 12-15 % of the cars at 170-200 m out). + std::size_t maxBytes = 2600; // When the cars near a client do not all fit, the ones it has are // kept ahead of new ones up to this much further away, so a car at the // edge of what fits does not come and go with every message. diff --git a/src/net/AmbientState.h b/src/net/AmbientState.h index 0a6fde2..fd01092 100644 --- a/src/net/AmbientState.h +++ b/src/net/AmbientState.h @@ -36,7 +36,7 @@ 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 = 160; +inline constexpr std::size_t kMaxAmbientPerMessage = 320; // Offsets from the origin: +-512 m across, +-256 m up and down. inline constexpr float kAmbientOffsetRange = 512.0f; inline constexpr float kAmbientHeightRange = 256.0f; diff --git a/src/net/Protocol.h b/src/net/Protocol.h index 83f0148..d5119ae 100644 --- a/src/net/Protocol.h +++ b/src/net/Protocol.h @@ -42,7 +42,10 @@ inline constexpr std::uint16_t kProtocolMagic = 0x4D32; // "M2" // 10: the host's rules (net/RulesState.h: the rules message; a player's own // word on a checkpoint, a finish or the gold is refused). (8 was its number // on its branch.) -inline constexpr std::uint16_t kProtocolVersion = 10; +// 12: the shared traffic's police and knocked cars near a client in full +// (net/TrafficFull.h: TrafficFull), AmbientState up to 2600 bytes and 320 +// cars (two unreliable fragments). +inline constexpr std::uint16_t kProtocolVersion = 12; inline constexpr std::uint32_t kConnectData = (std::uint32_t{kProtocolMagic} << 16) | kProtocolVersion; // ENet channels. @@ -107,8 +110,9 @@ enum class MsgType : std::uint8_t { RaceStart, PlayerInput, // net/PlayerCars.h CarStates, - PropState, // net/PropState.h - Last = PropState, + PropState, // net/PropState.h + TrafficFull, // net/TrafficFull.h + Last = TrafficFull, }; // Sent as ENet disconnect data and in Reject/PlayerLeft messages. diff --git a/src/net/Session.cpp b/src/net/Session.cpp index 99f48f1..cb7c53b 100644 --- a/src/net/Session.cpp +++ b/src/net/Session.cpp @@ -239,6 +239,7 @@ void Session::resetState() { m_lastSnapshotSent = 0; m_lastPlayoutUpdate = -1.0; m_ambientStates.clear(); + m_trafficFull.clear(); } void Session::leave() { @@ -1147,6 +1148,16 @@ void Session::clientHandle(MsgType type, std::span data) { m_ambientStates.push_back(std::move(m)); return; } + case MsgType::TrafficFull: { + // As the cars' states: only during a race. + TrafficFullMsg m; + if (m_phase == SessionPhase::Lobby || !decodeMessage(data, m)) + return; + if (m_trafficFull.size() >= kMaxQueuedTrafficFull) + m_trafficFull.erase(m_trafficFull.begin()); + m_trafficFull.push_back(std::move(m)); + return; + } case MsgType::PropState: { // As the cars' states: only during a race. PropStateMsg m; @@ -1298,6 +1309,7 @@ void Session::resetReplication() { m_localState.reset(); m_lastSentStateTime = 0; m_ambientStates.clear(); + m_trafficFull.clear(); } SnapshotBuffer::Result Session::sampleRemoteAt(std::uint8_t playerId, double sessionTime, @@ -1372,6 +1384,18 @@ std::size_t Session::sendAmbientState(std::uint8_t playerId, const AmbientStateM return m_transport->send(r->peer, Channel::Ambient, packet) ? packet.size() : 0; } +std::size_t Session::sendTrafficFull(std::uint8_t playerId, const TrafficFullMsg& msg) { + if (m_role != Role::Host || !m_transport || m_state != State::Active || m_phase == SessionPhase::Lobby || + playerId == m_localId) + return 0; + Remote* r = remoteForPlayer(playerId); + if (!r) + return 0; + const auto packet = encodeMessage(msg); + // Unsequenced: each message stands alone (the client keys it by time). + return m_transport->send(r->peer, Channel::State, packet) ? packet.size() : 0; +} + std::size_t Session::sendPropState(std::uint8_t playerId, const PropStateMsg& msg) { if (m_role != Role::Host || !m_transport || m_state != State::Active || m_phase == SessionPhase::Lobby || playerId == m_localId) diff --git a/src/net/Session.h b/src/net/Session.h index 691c58f..7f743ef 100644 --- a/src/net/Session.h +++ b/src/net/Session.h @@ -21,6 +21,7 @@ #include "net/PropState.h" #include "net/Protocol.h" #include "net/Snapshot.h" +#include "net/TrafficFull.h" #include "net/Transport.h" #include @@ -290,6 +291,14 @@ class Session { // most kMaxQueuedAmbientStates; older ones are dropped). std::vector takeAmbientStates() { return std::exchange(m_ambientStates, {}); } static constexpr std::size_t kMaxQueuedAmbientStates = 16; + // Host: sends one player the police and knocked cars near it in full + // (net/TrafficFull.h) on the unreliable State channel (unsequenced). + // Returns the encoded size in bytes (0 when nothing was sent). + std::size_t sendTrafficFull(std::uint8_t playerId, const TrafficFullMsg& msg); + // Client: the messages received since the last call, oldest first (at + // most kMaxQueuedTrafficFull; older ones are dropped). + std::vector takeTrafficFull() { return std::exchange(m_trafficFull, {}); } + static constexpr std::size_t kMaxQueuedTrafficFull = 32; // --- The host's props (OpenMM2, net/PropState.h) --- // Host: sends one player the props' state on the unreliable State @@ -409,6 +418,7 @@ class Session { std::vector m_playerInputs; // host: the clients' inputs, not yet taken std::vector m_ownCarStates; // client: the host's word on its car, not yet taken std::vector m_propStates; // client: received, not yet taken + std::vector m_trafficFull; // client: received, not yet taken }; // Proof sent in Hello: SHA-256(nonce || password); all zeros when empty. diff --git a/src/net/TrafficFull.h b/src/net/TrafficFull.h new file mode 100644 index 0000000..65b17bc --- /dev/null +++ b/src/net/TrafficFull.h @@ -0,0 +1,154 @@ +#pragma once + +// The shared traffic's simulated cars near a client, in full (OpenMM2, +// protocol 12; docs/multiplayer.md "Shared traffic"): the police cars and +// the traffic cars the host knocked loose (bodies) round the client's car. +// The client simulates them along with its own car from these states, as it +// does the players' cars near it (net::NearCarState), so that its car meets +// them as the host's simulation of it does: with their mass, where they +// will be. A police car's state comes with the controls its driver set, +// averaged over the host's frames since the last state (it runs on them +// until the next); a body needs none. +// +// The states are the host's physics sample of `time`, the time of the +// CarStates it sends the client in the same frame, so the client puts them +// in at the sample that message acknowledges. Each message stays one +// datagram (kTrafficFullBytes); the host sends as many as the cars need, +// unreliably on the State channel (unsequenced: each stands alone). + +#include "net/AmbientState.h" +#include "net/PlayerCars.h" + +#include +#include +#include +#include + +namespace mm2::net { + +inline constexpr std::size_t kMaxTrafficFullCars = 8; // per message +inline constexpr std::size_t kTrafficFullBytes = 1200; // a message's size at most + +// A knocked traffic car's body (aiVehicleActive): its rigid body, what a +// sample hands the next, its four vehWheelCheaps and its phSleep. +struct TrafficBodyState { + Mat34 matrix; // the body frame (the model origin: aiVehicleActive turns about it) + Vec3 linearMomentum, angularMomentum, linearVelocity, angularVelocity; + Vec3 force, torque, lastPush; + bool contact = false; // the impulses and pushes below are not all zero + Vec3 linearImpulse, angularImpulse, linearPush, turnForce, framePush; + struct Wheel { + float compression = 0.0f, lateral = 0.0f, longitudinal = 0.0f; + }; + std::array wheels{}; + std::int32_t sleepState = 1; // phys::Sleep::State + std::int32_t stillUpdates = 0, dormantUpdates = 0; +}; + +struct TrafficFullCar { + std::uint16_t id = 0; // the car's ambient id (AmbientEntity::id) + std::uint8_t generation = 0; // and generation + AmbientKind kind = AmbientKind::Police; + // Police: its simulation, and the controls its driver set (averaged; + // with the throttle's cap, which vehStuck compares against). + OwnCarState car; + float throttle = 0.0f, brake = 0.0f, steering = 0.0f, handBrake = 0.0f; + float maxThrottle = 1.0f; + // Traffic: its body. + TrafficBodyState body; +}; + +struct TrafficFullMsg { + static constexpr MsgType kType = MsgType::TrafficFull; + std::uint32_t time = 0; // session ms of the host's physics sample (its CarStates' time) + std::vector cars; +}; + +template +bool serialize(S& s, TrafficBodyState& b) { + s.vec3(b.matrix.m0); + s.vec3(b.matrix.m1); + s.vec3(b.matrix.m2); + s.vec3(b.matrix.m3); + s.vec3(b.linearMomentum); + s.vec3(b.angularMomentum); + s.vec3(b.linearVelocity); + s.vec3(b.angularVelocity); + s.vec3(b.force); + s.vec3(b.torque); + s.vec3(b.lastPush); + s.boolean(b.contact); + if (b.contact) { + s.vec3(b.linearImpulse); + s.vec3(b.angularImpulse); + s.vec3(b.linearPush); + s.vec3(b.turnForce); + s.vec3(b.framePush); + } else if constexpr (S::kReading) { + b.linearImpulse = b.angularImpulse = b.linearPush = b.turnForce = b.framePush = {}; + } + for (auto& w : b.wheels) { + s.f32(w.compression); + s.f32(w.lateral); + s.f32(w.longitudinal); + } + s.ranged(b.sleepState, 0, 2); + s.ranged(b.stillUpdates, 0, 1023); + s.ranged(b.dormantUpdates, 0, 1023); + if constexpr (S::kReading) { + // Untrusted: every number finite (f32 refuses the rest) and in range. + const auto ok = [](const Vec3& v, float r) { + return std::abs(v.x) <= r && std::abs(v.y) <= r && std::abs(v.z) <= r; + }; + const Mat34& m = b.matrix; + bool good = ok(m.m0, 2.0f) && ok(m.m1, 2.0f) && ok(m.m2, 2.0f) && ok(m.m3, kOwnStateMaxCoordinate); + for (const Vec3* v : {&b.linearMomentum, &b.angularMomentum, &b.linearVelocity, &b.angularVelocity, + &b.force, &b.torque, &b.lastPush, &b.linearImpulse, &b.angularImpulse, + &b.linearPush, &b.turnForce, &b.framePush}) + good = good && ok(*v, kOwnStateMaxValue); + for (const auto& w : b.wheels) + good = good && std::abs(w.compression) <= 100.0f && std::abs(w.lateral) <= 100.0f && + std::abs(w.longitudinal) <= 100.0f; + if (!good) + return s.fail(); + } + return s.ok(); +} + +template +bool serialize(S& s, TrafficFullMsg& m) { + s.u32(m.time); + auto count = static_cast(std::min(m.cars.size(), kMaxTrafficFullCars)); + s.ranged(count, 0, static_cast(kMaxTrafficFullCars)); + if constexpr (S::kReading) + m.cars.resize(static_cast(count)); + for (std::int32_t i = 0; i < count; ++i) { + TrafficFullCar& c = m.cars[static_cast(i)]; + std::int32_t id = c.id, generation = c.generation; + s.ranged(id, 0, static_cast(kMaxAmbientIds) - 1); + s.ranged(generation, 0, static_cast(kAmbientGenerations) - 1); + c.id = static_cast(id); + c.generation = static_cast(generation); + s.enumeration(c.kind, AmbientKind::Last); + if (c.kind == AmbientKind::Police) { + if (!serialize(s, c.car)) + return s.fail(); + s.f32(c.throttle); + s.f32(c.brake); + s.f32(c.steering); + s.f32(c.handBrake); + s.f32(c.maxThrottle); + if constexpr (S::kReading) { + // The controls CarSim::setInputs takes, held to its ranges. + if (std::abs(c.throttle) > 1.0f || std::abs(c.brake) > 1.0f || std::abs(c.steering) > 1.0f || + std::abs(c.handBrake) > 1.0f || std::abs(c.maxThrottle) > 10.0f) + return s.fail(); + } + } else if (!serialize(s, c.body)) { + return s.fail(); + } + } + return s.ok(); +} + +} // namespace mm2::net diff --git a/src/net/Transport.cpp b/src/net/Transport.cpp index ba0c046..99010a7 100644 --- a/src/net/Transport.cpp +++ b/src/net/Transport.cpp @@ -31,7 +31,9 @@ std::uint32_t packetFlags(Channel channel) { // 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; - case Channel::Ambient: return 0; // unreliable, sequenced + // Unreliable, sequenced; a message beyond the MTU is fragmented + // unreliably (ENet would send its fragments reliably otherwise). + case Channel::Ambient: return ENET_PACKET_FLAG_UNRELIABLE_FRAGMENT; } return ENET_PACKET_FLAG_RELIABLE; } diff --git a/tests/game/test_traffic_net.cpp b/tests/game/test_traffic_net.cpp index 1eb957b..ff5be6b 100644 --- a/tests/game/test_traffic_net.cpp +++ b/tests/game/test_traffic_net.cpp @@ -130,9 +130,63 @@ TEST(SharedTrafficNet, HostAndClientInOneProcess) { EXPECT_EQ(tc.known(), 3u); EXPECT_EQ(tc.stats().refused, 0u); + // Protocol 12: at traffic density 1 a message is two of ENet's fragments + // (sent unreliably), and arrives whole. + { + std::vector many; + for (int i = 0; i < 200; ++i) { + const Vec3 at{static_cast(i % 20) * 6.0f - 60.0f, 0.0f, static_cast(i / 20) * 8.0f}; + many.push_back(movingCar(10 + i, net::AmbientKind::Traffic, at)); + } + const std::uint32_t t = host.sessionTime(); + const auto msg = th.build({client.localId(), {0, 0, 0}}, many, t, t / 33, 0x1234); + const std::size_t bytes = host.sendAmbientState(client.localId(), msg); + EXPECT_GT(bytes, 1400u); + EXPECT_LE(bytes, TrafficHost::Options{}.maxBytes); + std::vector got; + ASSERT_TRUE(pump({&host, &client}, [&] { + for (auto& m : client.takeAmbientStates()) + got.push_back(std::move(m)); + return !got.empty(); + })); + EXPECT_EQ(got.back().entities.size(), msg.entities.size()); + } + // ... and the police and knocked cars near the client in full, on the + // State channel. + net::TrafficFullMsg full; + full.time = host.sessionTime(); + net::TrafficFullCar cop; + cop.id = 400; + cop.kind = net::AmbientKind::Police; + cop.car.matrix.m3 = {0, 0, -15}; + cop.throttle = 0.75f; + cop.steering = -0.25f; + net::TrafficFullCar knocked; + knocked.id = 3; + knocked.generation = 1; + knocked.kind = net::AmbientKind::Traffic; + knocked.body.matrix.m3 = {5, 0, 2}; + knocked.body.linearVelocity = {1, 0, -2}; + full.cars = {cop, knocked}; + EXPECT_GT(host.sendTrafficFull(client.localId(), full), 0u); + std::vector fulls; + ASSERT_TRUE(pump({&host, &client}, [&] { + for (auto& m : client.takeTrafficFull()) + fulls.push_back(std::move(m)); + return !fulls.empty(); + })); + ASSERT_EQ(fulls.size(), 1u); + EXPECT_EQ(fulls[0].time, full.time); + ASSERT_EQ(fulls[0].cars.size(), 2u); + EXPECT_EQ(fulls[0].cars[0].kind, net::AmbientKind::Police); + EXPECT_EQ(fulls[0].cars[0].throttle, 0.75f); + EXPECT_EQ(fulls[0].cars[1].id, 3); + EXPECT_EQ(fulls[0].cars[1].body.linearVelocity, knocked.body.linearVelocity); + // 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); + EXPECT_EQ(host.sendTrafficFull(client.localId(), full), 0u); } diff --git a/tests/game/test_traffic_sync.cpp b/tests/game/test_traffic_sync.cpp index e928494..d088b18 100644 --- a/tests/game/test_traffic_sync.cpp +++ b/tests/game/test_traffic_sync.cpp @@ -132,6 +132,34 @@ TEST(TrafficHost, ChasingPoliceComeFirstAndTheBudgetHolds) { EXPECT_GT(m.entities.size(), 50u); } +// Protocol 12: at traffic density 1 (100-130 cars within 200 m in San +// Francisco's downtown) every car within the interest radius fits once the +// client knows them (the far ones carry their state in every other or fourth +// message), turning and changing speed, the police chasing the client among +// them; the message is at most two of ENet's fragments. +TEST(TrafficHost, EveryCarWithin200MetresFitsAtDensityOne) { + TrafficHost host; + std::vector cars; + for (int i = 0; i < 150; ++i) { + const float angle = static_cast(i) * 2.4f; + const float r = 10.0f + static_cast(i) * 1.25f; // out to 196 m + SharedCar c = railCar(i, {r * std::cos(angle), 0, r * std::sin(angle)}); + c.motion.accel = 1.5f; + c.motion.curvature = 0.05f; + cars.push_back(c); + } + cars.push_back(cop(300, {40, 0, 0}, 1)); + cars.push_back(cop(301, {-60, 0, 5}, 1)); + net::AmbientStateMsg m; + for (std::uint32_t t = 0; t < 8; ++t) + m = host.build({1, {0, 0, 0}}, cars, t * 50, 0, 0); + EXPECT_EQ(m.entities.size(), cars.size()); + const std::size_t bytes = net::encodeMessage(m).size(); + EXPECT_LE(bytes, host.options().maxBytes); + EXPECT_LE(host.options().maxBytes, 2u * 1392u - 100u); // two fragments, their headers included + EXPECT_LE(m.entities.size(), net::kMaxAmbientPerMessage); +} + TEST(TrafficClient, FollowsSpawnUpdateAndDespawn) { TrafficClient client(8, 0x1234); client.receive(message(1000, {railCar(5, {0, 0, 0})}, 30)); @@ -360,7 +388,7 @@ TEST(TrafficHost, FarCarsComeWithTheirStateEveryOtherMessage) { // When the cars near a client do not all fit, the ones it has stay ahead of // new ones a little nearer, so the car at the edge of what fits does not -// come and go; beyond 160 m a known car gets its state every fourth message. +// come and go; beyond 180 m a known car gets its state every fourth message. TEST(TrafficHost, TheCarsThatFitStayAndTheFarthestComeEveryFourthMessage) { game::TrafficHost::Options options; options.maxBytes = 70; // the header and three cars with their state @@ -383,7 +411,7 @@ TEST(TrafficHost, TheCarsThatFitStayAndTheFarthestComeEveryFourthMessage) { EXPECT_EQ(ids(host.build(viewer, cars, 100, 0, 0)), (std::vector{1, 2, 4})); TrafficHost far; - const std::vector distant = {railCar(10, {180, 0, 0})}; + const std::vector distant = {railCar(10, {190, 0, 0})}; int states = 0; for (std::uint32_t i = 0; i < 9; ++i) states += far.build(viewer, distant, i * 50, 0, 0).entities.at(0).hasState ? 1 : 0; diff --git a/tests/net/test_fuzz.cpp b/tests/net/test_fuzz.cpp index 42f40ac..0be4684 100644 --- a/tests/net/test_fuzz.cpp +++ b/tests/net/test_fuzz.cpp @@ -8,6 +8,7 @@ #include "net/Discovery.h" #include "net/NatPmp.h" #include "net/Session.h" +#include "net/TrafficFull.h" #include "net/VehicleDamage.h" #include @@ -203,6 +204,35 @@ std::vector corpus() { knocksEvent.time = 6100; knocksEvent.payload = encodePayload(knocks); c.push_back(encodeMessage(knocksEvent)); + // The police and a knocked car near a client in full (protocol 12). + TrafficFullMsg full; + full.time = 6200; + TrafficFullCar cop; + cop.id = 401; + cop.generation = 2; + cop.kind = AmbientKind::Police; + cop.car.matrix = Mat34::rotationY(0.4f); + cop.car.matrix.m3 = {-1150.0f, 112.0f, 160.0f}; + cop.car.linearVelocity = {12.0f, 0.0f, -3.0f}; + cop.car.contact = true; + cop.car.linearImpulse = {500.0f, 0.0f, 0.0f}; + cop.throttle = 1.0f; + cop.steering = -0.3f; + full.cars.push_back(cop); + TrafficFullCar knocked; + knocked.id = 12; + knocked.generation = 6; + knocked.kind = AmbientKind::Traffic; + knocked.body.matrix = Mat34::rotationY(-1.0f); + knocked.body.matrix.m3 = {-1145.0f, 111.0f, 150.0f}; + knocked.body.linearVelocity = {3.0f, -0.5f, 1.0f}; + knocked.body.contact = true; + knocked.body.framePush = {0.0f, 0.001f, 0.0f}; + knocked.body.wheels[1] = {0.2f, -0.1f, 0.5f}; + knocked.body.sleepState = 2; + knocked.body.stillUpdates = 40; + full.cars.push_back(knocked); + c.push_back(encodeMessage(full)); LanAdvert advert; advert.sessionName = "Fuzz"; @@ -389,6 +419,30 @@ void checkProps(const PropStateMsg& m) { } } +void checkTrafficFull(const TrafficFullMsg& m) { + ASSERT_LE(m.cars.size(), kMaxTrafficFullCars); + for (const TrafficFullCar& c : m.cars) { + ASSERT_LT(c.id, kMaxAmbientIds); + ASSERT_LT(c.generation, kAmbientGenerations); + ASSERT_LE(c.kind, AmbientKind::Last); + if (c.kind == AmbientKind::Police) { + for (float v : {c.throttle, c.brake, c.steering, c.handBrake}) + ASSERT_LE(std::abs(v), 1.0f); + ASSERT_LE(std::abs(c.car.matrix.m3.y), kOwnStateMaxCoordinate); + continue; + } + const Mat34& b = c.body.matrix; + for (const Vec3& v : {b.m0, b.m1, b.m2}) + ASSERT_TRUE(std::abs(v.x) <= 2.0f && std::abs(v.y) <= 2.0f && std::abs(v.z) <= 2.0f); + ASSERT_LE(std::abs(b.m3.x), kOwnStateMaxCoordinate); + for (const Vec3& v : {c.body.linearVelocity, c.body.angularMomentum, c.body.framePush}) + ASSERT_TRUE(std::abs(v.x) <= kOwnStateMaxValue && std::abs(v.y) <= kOwnStateMaxValue && + std::abs(v.z) <= kOwnStateMaxValue); + ASSERT_GE(c.body.sleepState, 0); + ASSERT_LE(c.body.sleepState, 2); + } +} + void checkPlayer(const PlayerInfo& p) { ASSERT_LE(p.name.size(), kMaxNameLength); ASSERT_LE(p.car.size(), kMaxShortStringLength); @@ -493,6 +547,9 @@ void decodeEverything(std::span b) { if (const auto m = decoded(b)) { checkProps(*m); } + if (const auto m = decoded(b)) { + checkTrafficFull(*m); + } std::uint32_t nonce = 0; (void)decodeLanQuery(b, nonce); @@ -770,6 +827,11 @@ TEST(Fuzz, ClientSurvivesMutatedTraffic) { EXPECT_LE(props.size(), Session::kMaxQueuedPropStates); for (const auto& m : props) checkProps(m); + // And the police and knocked cars in full. + const auto full = client.takeTrafficFull(); + EXPECT_LE(full.size(), Session::kMaxQueuedTrafficFull); + for (const auto& m : full) + checkTrafficFull(m); } // The race start under mutated race messages from a joiner: whatever it diff --git a/tests/net/test_traffic_full.cpp b/tests/net/test_traffic_full.cpp new file mode 100644 index 0000000..5a97690 --- /dev/null +++ b/tests/net/test_traffic_full.cpp @@ -0,0 +1,323 @@ +// The shared traffic's police and knocked cars in full (net/TrafficFull.h, +// protocol 12): their encoding, its size, what a receiver refuses and +// mutated messages. +#include "net/TrafficFull.h" + +#include + +#include +#include +#include +#include + +using namespace mm2; +using namespace mm2::net; + +namespace { + +using Bytes = std::vector; + +OwnCarState sampleCop() { + OwnCarState o; + o.matrix = Mat34::rotationY(-1.2f); + o.matrix.m3 = {-1180.25f, 111.5f, 140.125f}; + o.linearMomentum = {-21000.5f, 3.25f, 9000.0f}; + o.angularMomentum = {12.0f, -800.5f, 0.25f}; + o.linearVelocity = {-14.0f, 0.002f, 6.0f}; + o.angularVelocity = {0.01f, -0.4f, 0.0f}; + for (std::size_t i = 0; i < 4; ++i) + o.wheels[i] = {-40.0f - static_cast(i), 3.0f, 0.04f, 0.1f, -0.003f, 0.002f}; + o.engineSpeed = 500.0f; + o.gear = 3; + o.damage = 250.0f; + o.random = 0x12345678u; + o.resets = 2; + o.force = {1.0f, 20000.0f, -3.0f}; + o.contact = true; + o.linearImpulse = {-900.0f, 0.0f, 12.0f}; + return o; +} + +TrafficBodyState sampleBody(bool contact) { + TrafficBodyState b; + b.matrix = Mat34::rotationY(0.3f); + b.matrix.m3 = {-1150.0f, 110.0f, 170.5f}; + b.linearMomentum = {3000.0f, -100.0f, 250.0f}; + b.angularMomentum = {50.0f, 900.0f, -25.0f}; + b.linearVelocity = {2.0f, -0.07f, 0.16f}; + b.angularVelocity = {0.02f, 0.5f, -0.01f}; + b.force = {0.0f, -14000.0f, 0.0f}; + b.torque = {10.0f, 0.0f, -5.0f}; + b.lastPush = {0.0f, 0.001f, 0.0f}; + b.contact = contact; + if (contact) { + b.linearImpulse = {800.0f, 0.0f, -40.0f}; + b.angularImpulse = {0.0f, 120.0f, 0.0f}; + b.linearPush = {0.002f, 0.0f, 0.0f}; + b.turnForce = {0.0f, 0.0004f, 0.0f}; + b.framePush = {0.0f, 0.0f, 0.001f}; + } + for (std::size_t i = 0; i < 4; ++i) + b.wheels[i] = {0.05f * static_cast(i), -0.3f, 1.25f}; + b.sleepState = 2; + b.stillUpdates = 17; + b.dormantUpdates = 1023; + return b; +} + +TrafficFullCar police(std::uint16_t id) { + TrafficFullCar c; + c.id = id; + c.generation = 5; + c.kind = AmbientKind::Police; + c.car = sampleCop(); + c.throttle = 0.85f; + c.brake = 0.0f; + c.steering = -0.42f; + c.handBrake = 1.0f; + c.maxThrottle = 0.9f; + return c; +} + +TrafficFullCar body(std::uint16_t id, bool contact = true) { + TrafficFullCar c; + c.id = id; + c.generation = 7; + c.kind = AmbientKind::Traffic; + c.body = sampleBody(contact); + return c; +} + +TrafficFullMsg sample() { + TrafficFullMsg m; + m.time = 123456; + m.cars = {police(401), body(12), body(13, false)}; + return m; +} + +bool sameBits(float a, float b) { return std::memcmp(&a, &b, sizeof(float)) == 0; } +bool sameBits(const Vec3& a, const Vec3& b) { + return sameBits(a.x, b.x) && sameBits(a.y, b.y) && sameBits(a.z, b.z); +} + +void expectSameBody(const TrafficBodyState& a, const TrafficBodyState& b) { + const auto same = [](const Vec3& x, const Vec3& y) { EXPECT_TRUE(sameBits(x, y)); }; + same(a.matrix.m0, b.matrix.m0); + same(a.matrix.m1, b.matrix.m1); + same(a.matrix.m2, b.matrix.m2); + same(a.matrix.m3, b.matrix.m3); + same(a.linearMomentum, b.linearMomentum); + same(a.angularMomentum, b.angularMomentum); + same(a.linearVelocity, b.linearVelocity); + same(a.angularVelocity, b.angularVelocity); + same(a.force, b.force); + same(a.torque, b.torque); + same(a.lastPush, b.lastPush); + same(a.linearImpulse, b.linearImpulse); + same(a.angularImpulse, b.angularImpulse); + same(a.linearPush, b.linearPush); + same(a.turnForce, b.turnForce); + same(a.framePush, b.framePush); + for (std::size_t i = 0; i < 4; ++i) { + EXPECT_TRUE(sameBits(a.wheels[i].compression, b.wheels[i].compression)); + EXPECT_TRUE(sameBits(a.wheels[i].lateral, b.wheels[i].lateral)); + EXPECT_TRUE(sameBits(a.wheels[i].longitudinal, b.wheels[i].longitudinal)); + } + EXPECT_EQ(a.contact, b.contact); + EXPECT_EQ(a.sleepState, b.sleepState); + EXPECT_EQ(a.stillUpdates, b.stillUpdates); + EXPECT_EQ(a.dormantUpdates, b.dormantUpdates); +} + +// What a decoded message may hold, whatever arrived. +void checkFull(const TrafficFullMsg& m) { + ASSERT_LE(m.cars.size(), kMaxTrafficFullCars); + for (const auto& c : m.cars) { + ASSERT_LT(c.id, kMaxAmbientIds); + ASSERT_LT(c.generation, kAmbientGenerations); + ASSERT_LE(c.kind, AmbientKind::Last); + if (c.kind == AmbientKind::Police) { + for (float v : {c.throttle, c.brake, c.steering, c.handBrake}) + ASSERT_LE(std::abs(v), 1.0f); + ASSERT_LE(std::abs(c.maxThrottle), 10.0f); + ASSERT_TRUE(std::abs(c.car.matrix.m3.x) <= kOwnStateMaxCoordinate); + ASSERT_TRUE(std::isfinite(c.car.linearVelocity.y)); + continue; + } + const TrafficBodyState& b = c.body; + for (const Vec3& v : {b.matrix.m0, b.matrix.m1, b.matrix.m2}) + ASSERT_TRUE(std::abs(v.x) <= 2.0f && std::abs(v.y) <= 2.0f && std::abs(v.z) <= 2.0f); + ASSERT_TRUE(std::abs(b.matrix.m3.x) <= kOwnStateMaxCoordinate && + std::abs(b.matrix.m3.y) <= kOwnStateMaxCoordinate && + std::abs(b.matrix.m3.z) <= kOwnStateMaxCoordinate); + for (const Vec3& v : {b.linearMomentum, b.angularMomentum, b.linearVelocity, b.angularVelocity, + b.force, b.torque, b.lastPush, b.linearImpulse, b.framePush}) + ASSERT_TRUE(std::abs(v.x) <= kOwnStateMaxValue && std::abs(v.y) <= kOwnStateMaxValue && + std::abs(v.z) <= kOwnStateMaxValue); + ASSERT_TRUE(b.contact || b.linearImpulse == Vec3{}); + for (const auto& w : b.wheels) + ASSERT_TRUE(std::abs(w.compression) <= 100.0f && std::abs(w.lateral) <= 100.0f && + std::abs(w.longitudinal) <= 100.0f); + ASSERT_GE(b.sleepState, 0); + ASSERT_LE(b.sleepState, 2); + ASSERT_GE(b.stillUpdates, 0); + ASSERT_LE(b.dormantUpdates, 1023); + } +} + +Bytes mutate(const std::vector& corpus, std::mt19937& rng) { + auto pick = [&](std::size_t n) { return std::uniform_int_distribution(0, n - 1)(rng); }; + Bytes b = corpus[pick(corpus.size())]; + const int rounds = 1 + static_cast(pick(4)); + for (int r = 0; r < rounds; ++r) { + switch (pick(5)) { + case 0: + 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: + for (std::size_t i = 0, n = 1 + pick(4); i < n && !b.empty(); ++i) + b[pick(b.size())] = static_cast(rng() & 0xFF); + break; + case 2: + if (!b.empty()) + b.resize(pick(b.size())); + break; + case 3: + for (std::size_t i = 0, n = 1 + pick(32); i < n; ++i) + b.push_back(static_cast(rng() & 0xFF)); + break; + default: + b.resize(pick(1300)); + for (auto& x : b) + x = static_cast(rng() & 0xFF); + if (!b.empty()) + b[0] = static_cast(MsgType::TrafficFull); + break; + } + } + return b; +} + +} // namespace + +TEST(TrafficFull, RoundTripsEveryFieldBitForBit) { + const TrafficFullMsg m = sample(); + TrafficFullMsg out; + ASSERT_TRUE(decodeMessage(encodeMessage(m), out)); + EXPECT_EQ(out.time, m.time); + ASSERT_EQ(out.cars.size(), 3u); + const TrafficFullCar& cop = out.cars[0]; + EXPECT_EQ(cop.id, 401); + EXPECT_EQ(cop.generation, 5); + EXPECT_EQ(cop.kind, AmbientKind::Police); + EXPECT_TRUE(sameBits(cop.car.matrix.m3, m.cars[0].car.matrix.m3)); + EXPECT_TRUE(sameBits(cop.car.linearMomentum, m.cars[0].car.linearMomentum)); + EXPECT_TRUE(sameBits(cop.car.linearImpulse, m.cars[0].car.linearImpulse)); + EXPECT_EQ(cop.car.random, m.cars[0].car.random); + EXPECT_EQ(cop.car.gear, m.cars[0].car.gear); + EXPECT_TRUE(sameBits(cop.throttle, 0.85f) && sameBits(cop.steering, -0.42f)); + EXPECT_TRUE(sameBits(cop.handBrake, 1.0f) && sameBits(cop.maxThrottle, 0.9f)); + for (std::size_t i = 1; i < 3; ++i) { + EXPECT_EQ(out.cars[i].id, m.cars[i].id); + EXPECT_EQ(out.cars[i].generation, 7); + EXPECT_EQ(out.cars[i].kind, AmbientKind::Traffic); + expectSameBody(out.cars[i].body, m.cars[i].body); + } + // Out of a contact the impulses and pushes do not travel. + EXPECT_EQ(out.cars[2].body.linearImpulse, Vec3{}); + TrafficFullMsg calm; + calm.cars = {body(1, false)}; + TrafficFullMsg hit; + hit.cars = {body(1, true)}; + EXPECT_LT(encodeMessage(calm).size(), encodeMessage(hit).size()); +} + +TEST(TrafficFull, AMessageIsOneDatagram) { + // The host packs its cars into messages of kTrafficFullBytes at most + // (one ENet packet, never fragmented): a police car and four bodies in a + // contact each fit one, two police cars or five bodies at rest too. + TrafficFullMsg m; + m.cars = {police(400)}; + for (std::uint16_t i = 0; i < 3; ++i) + m.cars.push_back(body(i)); + EXPECT_LE(encodeMessage(m).size(), kTrafficFullBytes); + m.cars = {police(400), police(401)}; + EXPECT_LE(encodeMessage(m).size(), kTrafficFullBytes); + m.cars.clear(); + for (std::uint16_t i = 0; i < 4; ++i) + m.cars.push_back(body(i)); + EXPECT_LE(encodeMessage(m).size(), kTrafficFullBytes); + m.cars.clear(); + for (std::uint16_t i = 0; i < 5; ++i) + m.cars.push_back(body(i, false)); + EXPECT_LE(encodeMessage(m).size(), kTrafficFullBytes); + // More than kMaxTrafficFullCars: the first ones only. + m.cars.clear(); + for (std::uint16_t i = 0; i < kMaxTrafficFullCars + 2; ++i) + m.cars.push_back(body(i, false)); + TrafficFullMsg out; + ASSERT_TRUE(decodeMessage(encodeMessage(m), out)); + EXPECT_EQ(out.cars.size(), kMaxTrafficFullCars); +} + +TEST(TrafficFull, OutOfRangeStatesAreRefused) { + TrafficFullMsg out; + const auto refused = [&](const TrafficFullMsg& m) { return !decodeMessage(encodeMessage(m), out); }; + auto bad = sample(); + bad.cars[0].throttle = 1.5f; // beyond what CarSim::setInputs takes + EXPECT_TRUE(refused(bad)); + bad = sample(); + bad.cars[0].steering = std::numeric_limits::quiet_NaN(); + EXPECT_TRUE(refused(bad)); + bad = sample(); + bad.cars[0].maxThrottle = 1.0e9f; + EXPECT_TRUE(refused(bad)); + bad = sample(); + bad.cars[0].car.matrix.m3.y = -1.0e7f; // the police car's own state is checked as a player's + EXPECT_TRUE(refused(bad)); + bad = sample(); + bad.cars[1].body.matrix.m1.y = 3.0f; // not a rotation + EXPECT_TRUE(refused(bad)); + bad = sample(); + bad.cars[1].body.matrix.m3.x = 1.0e6f; + EXPECT_TRUE(refused(bad)); + bad = sample(); + bad.cars[1].body.angularVelocity.z = std::numeric_limits::infinity(); + EXPECT_TRUE(refused(bad)); + bad = sample(); + bad.cars[1].body.framePush.y = 1.0e20f; + EXPECT_TRUE(refused(bad)); + bad = sample(); + bad.cars[2].body.wheels[3].lateral = 1.0e5f; + EXPECT_TRUE(refused(bad)); + // phSleep has three states: a fourth is not written (held to the + // range), and a reader refuses its bits. + bad = sample(); + bad.cars[2].body.sleepState = 3; + ASSERT_TRUE(decodeMessage(encodeMessage(bad), out)); + EXPECT_EQ(out.cars[2].body.sleepState, 2); + // A message cut short is refused. + auto bytes = encodeMessage(sample()); + bytes.resize(bytes.size() - 20); + EXPECT_FALSE(decodeMessage(bytes, out)); +} + +TEST(TrafficFull, DecoderSurvivesMutatedMessages) { + TrafficFullMsg big; + big.time = 99; + for (std::uint16_t i = 0; i < 4; ++i) + big.cars.push_back(body(static_cast(500 + i))); + const std::vector corpus{encodeMessage(sample()), encodeMessage(big), + encodeMessage(TrafficFullMsg{7, {police(511)}})}; + std::mt19937 rng(20261012); + for (int i = 0; i < 40000; ++i) { + const Bytes b = mutate(corpus, rng); + if (TrafficFullMsg m; decodeMessage(b, m)) + checkFull(m); + if (HasFatalFailure()) { + ADD_FAILURE() << "iteration " << i; + return; + } + } +} From baa3c01b7a84cbf28da43e2a33084bf2201eb5a9 Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Fri, 9 Oct 2026 20:52:35 -0700 Subject: [PATCH 095/129] Simulate the host's police and knocked cars with the client's car A client met the host's police and knocked cars as kinematic bodies of infinite mass, dead-reckoned over its lead (O1, O2 of the traffic review): a moving police car beside its car was 0.4 / 1.0 / 1.6 m off (median, 90th, 99th percentile), a car it hit 0.25 / 0.71 / 2.81 m, and its own car was corrected where the host's pushed them. The host now sends each client, with its CarStates, the police within 60 m (100 m chasing it) and the knocked cars within 50 m in full, nearest first, one datagram a state, a car sent the last time kept 20 m further. The police's controls go averaged over the frames since the last state (the driver's throttle and brake go on and off), and their wheels draw from their own random streams. The client puts each state in at that sample's acknowledgement and runs it again with its own car (a CarPrediction::Companion without a player: a police car driven by its controls, a knocked car a TrafficBodies body put to its state), so its car meets them with their mass, in the host's mover order. A simulated police car is drawn between its samples with the corrections blended away and knocks the traffic it hits loose; a car whose full states stop for 250 ms goes back to its kinematic place. Also: in a replay a rail car meets the replayed cars as the body it would take (TrafficBodies' heldInertia) rather than a wall; a car standing off its rail without a body on the host stands as a rail car on the client, which its car knocks loose as the host's does; a police car standing still is shared standing (it kept 15 m/s against a wall for 10 s); the trace tells the simulated police apart (mm2tool nettrace). Co-Authored-By: Claude Opus 5.5 (1M context) --- src/app/RaceScreen.cpp | 394 +++++++++++++++++++++++++-- src/game/TrafficBodies.cpp | 96 +++++++ src/game/TrafficBodies.h | 10 + src/game/net/NetTrafficCars.cpp | 50 +++- src/game/net/NetTrafficCars.h | 17 ++ src/game/net/PlayerCars.cpp | 49 +++- src/game/net/PlayerCars.h | 9 + src/game/net/TrafficTrace.h | 5 +- tests/game/test_net_traffic_cars.cpp | 96 +++++++ tests/game/test_traffic_bodies.cpp | 89 ++++++ tools/mm2tool/cmd_nettrace.cpp | 8 +- 11 files changed, 780 insertions(+), 43 deletions(-) diff --git a/src/app/RaceScreen.cpp b/src/app/RaceScreen.cpp index 91ad5d5..d439124 100644 --- a/src/app/RaceScreen.cpp +++ b/src/app/RaceScreen.cpp @@ -752,6 +752,7 @@ class RaceScreen final : public Screen { m_drawnPhys.record(drawnKey(Drawn::RemoteTrailer, id), rv.sim->trailerPose(), resets); } m_remoteStepBodies = std::move(stepBodies); + recordNetCopSteps(false); // a shared-traffic client's police simulated here if (m_netTrafficCars && m_trafficBodies) game::recordTrafficBodies(m_drawnPhys, *m_trafficBodies); // a shared-traffic client's else if (m_ai && m_trafficBodies) @@ -794,6 +795,8 @@ class RaceScreen final : public Screen { if (const auto base = remoteDrawnBase(id, rv)) m_remoteDrawn[id] = rv.blend.apply(*base); } + for (auto& [id, c] : m_netCops) + c.blend.update(m_frameDt); if (!m_player) return; m_drawPose = drawnPose(game::Drawn::Player, 0, *m_player); @@ -910,7 +913,7 @@ class RaceScreen final : public Screen { std::optional physicalTrafficCar(int id) const { // OpenMM2: a shared-traffic client's knocked cars, as the host has them // (or, until the host's messages lead, as this machine knocked them). - if (m_trafficClient && !(m_netTrafficCars && m_netTrafficCars->knocked(id))) { + if (m_trafficClient && !(m_netTrafficCars && m_netTrafficCars->simulated(id))) { const auto it = m_netPhysical.find(id); return it != m_netPhysical.end() ? std::optional(it->second) : std::nullopt; } @@ -1628,6 +1631,13 @@ class RaceScreen final : public Screen { cop.sim = loadAiCar(ctx, p.vehicle, {}, post); if (!cop.sim) continue; + // OpenMM2: a shared-traffic host's police draw their wheels' bump + // numbers from their own streams, which travel with their states + // (net/TrafficFull.h): a client simulates them alike. + if (netTrafficHost(ctx)) { + cop.sim->sim().ownRandom = true; + cop.sim->sim().randomState = 1; + } ai::PoliceSettings settings = ai::PoliceSettings::fromData(p.params, chaseDistance); settings.seed += i; cop.driver = &m_police->add(cop.sim->sim(), post, 100 + static_cast(m_cops.size()), settings, @@ -1800,6 +1810,15 @@ class RaceScreen final : public Screen { c.sim->sim().body.declare(m.type, m.flags); else c.sim->sim().body.declared = false; + // OpenMM2: the controls its driver set for this frame's samples, + // which a shared-traffic host sends with its state + // (sendNetTrafficFull). + const phys::CarSim& sim = c.sim->sim(); + const std::array set{sim.engine.throttle, sim.brakes, sim.steering, sim.handBrake}; + c.controls = set; + for (std::size_t k = 0; k < set.size(); ++k) + c.controlSum[k] += set[k]; + ++c.controlFrames; } // Development aid: OPENMM2_DEBUG_AI logs the AI cars once a second. if (std::getenv("OPENMM2_DEBUG_AI") && std::floor(m_time) != std::floor(m_time - dt)) { @@ -4174,6 +4193,107 @@ class RaceScreen final : public Screen { } } + // Host, with each client's CarStates (the same physics sample): the + // police cars and the knocked traffic cars near its car in full + // (net/TrafficFull.h), which the client simulates with its own. One + // message a state (kTrafficFullBytes, 24 KB/s at most), nearest first (a + // police car chasing the client counting half as far); a car sent the + // last time is kept kFullKeep metres further, so that one at the edge + // does not switch between being simulated and placed on the client. + static constexpr float kFullPoliceRadius = 60.0f; // m + static constexpr float kFullChaseRadius = 100.0f; // a police car chasing the client + static constexpr float kFullBodyRadius = 50.0f; // m + static constexpr float kFullKeep = 20.0f; // m + void sendNetTrafficFull(Context& ctx) { + if (!netTrafficHost(ctx) || !m_ai || !m_trafficBodies) + return; + for (const auto& rc : m_remoteCars) { + if (!rc.hasState) + continue; + const Vec3 at = rc.transform.m3; + std::set& sent = m_trafficFullSent[rc.id]; + const auto within = [&](int id, float d, float radius) { + return d <= radius + (sent.contains(id) ? kFullKeep : 0.0f); + }; + std::vector> cars; + for (std::size_t i = 0; i < m_cops.size(); ++i) { + const Cop& cop = m_cops[i]; + const int id = kNetPoliceId + static_cast(i); + if (!cop.sim || id >= static_cast(net::kMaxAmbientIds)) + continue; + const phys::CarSim& sim = cop.sim->sim(); + const float d = sim.body.ics.matrix.m3.dist(at); + const int target = cop.driver->target(); + const bool chasing = target > kNetPlayerTrackedId && + target - kNetPlayerTrackedId == static_cast(rc.id); + if (!within(id, d, chasing ? kFullChaseRadius : kFullPoliceRadius)) + continue; + net::TrafficFullCar f; + f.id = static_cast(id); + f.generation = + static_cast(static_cast(cop.resets) % net::kAmbientGenerations); + f.kind = net::AmbientKind::Police; + f.car = game::ownCarState(*cop.sim, static_cast(cop.resets)); + // The controls its driver set (not what the car's own rules + // made of them in the samples), on average over the frames + // since the last message: the client runs it on them until + // the next, and the driver's throttle and brake go on and + // off from frame to frame (in chases the average met the + // host's car 0.29 m off at the 90th percentile, the last + // controls 0.49 m). + std::array controls = cop.controls; + if (cop.controlFrames > 0) + for (std::size_t k = 0; k < controls.size(); ++k) + controls[k] = cop.controlSum[k] / static_cast(cop.controlFrames); + f.throttle = controls[0]; + f.brake = controls[1]; + f.steering = controls[2]; + f.handBrake = controls[3]; + f.maxThrottle = sim.engine.maxThrottle; + cars.emplace_back(chasing ? d * 0.5f : d, std::move(f)); + } + for (const ai::AmbientCar& c : m_ai->cars()) { + if (!c.physical || c.id < 0 || c.id >= kNetPoliceId) + continue; + const auto state = m_trafficBodies->bodyState(c.id); + if (!state) + continue; + const float d = state->matrix.m3.dist(at); + if (!within(c.id, d, kFullBodyRadius)) + continue; + net::TrafficFullCar f; + f.id = static_cast(c.id); + f.generation = + static_cast(static_cast(c.spawns) % net::kAmbientGenerations); + f.kind = net::AmbientKind::Traffic; + f.body = *state; + cars.emplace_back(d, std::move(f)); + } + std::ranges::sort(cars, {}, &std::pair::first); + net::TrafficFullMsg msg; + msg.time = m_netStateTime; + sent.clear(); + for (auto& [d, f] : cars) { + if (msg.cars.size() == net::kMaxTrafficFullCars) + break; + msg.cars.push_back(std::move(f)); + if (net::encodeMessage(msg).size() > net::kTrafficFullBytes) + msg.cars.pop_back(); // a smaller one further away may fit + else + sent.insert(msg.cars.back().id); + } + if (msg.cars.empty()) + continue; + auto& st = m_trafficSent[rc.id]; + st.fullBytes += ctx.netGame->sendTrafficFull(rc.id, msg); + st.fullCars += msg.cars.size(); + } + for (Cop& cop : m_cops) { + cop.controlSum = {}; + cop.controlFrames = 0; + } + } + void sendNetTraffic(Context& ctx) { if (!m_trafficHost || !m_ai || !netTrafficHost(ctx)) return; @@ -4257,6 +4377,13 @@ class RaceScreen final : public Screen { s.transform = sim.modelMatrix(); s.velocity = sim.body.ics.linearVelocity; s.angularVelocity = sim.body.ics.angularVelocity; + // One that stands still is shared standing too: a police car + // pressed against a wall kept 15 m/s for 10 s while it stood, and + // a client predicted it 3.9 m ahead. + if (m_stillBodies.still(s.id, s.transform.m3, netBodyStateTime(ctx))) { + s.velocity = {}; + s.angularVelocity = {}; + } s.speed = sim.speed(); const auto mode = cop.driver->mode(); if (cop.driver->siren()) @@ -4345,10 +4472,13 @@ class RaceScreen final : public Screen { 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; police " - "damage and knocked cars' wheels {:.0f} B/s ({} cars with wheels sent)", + "damage and knocked cars' wheels {:.0f} B/s ({} cars with wheels sent); in full " + "{:.0f} B/s ({:.1f} cars a second)", id, static_cast(st.messages) / seconds, static_cast(st.bytes) / seconds, perMessage, - static_cast(st.damageBits) / 8.0 / seconds, st.wheels); + static_cast(st.damageBits) / 8.0 / seconds, st.wheels, + static_cast(st.fullBytes) / seconds, + static_cast(st.fullCars) / seconds); st = {}; } m_trafficStatsAt = now; @@ -4371,6 +4501,7 @@ class RaceScreen final : public Screen { return; for (const auto& m : ctx.netGame->takeAmbientStates()) m_trafficClient->receive(m); + receiveNetTrafficFull(ctx); // The cars this machine's car knocked loose in the last frame's // samples (NetTrafficCars): only traced, since the host simulates // that car too and knocks them itself. @@ -4410,12 +4541,20 @@ class RaceScreen final : public Screen { if (c.transform.m3.dist2(me) >= 9e4f) continue; // A car this machine knocked loose: where its body is (mode 3). - const Mat34* local = m_netTrafficCars && m_netTrafficCars->knocked(c.id) && m_trafficBodies + const Mat34* local = m_netTrafficCars && m_netTrafficCars->simulated(c.id) && m_trafficBodies ? m_trafficBodies->transformOf(c.id) : nullptr; game::TrafficClient::Car shown = c; if (local) shown.transform = *local; + // A police car simulated here (predictNetTraffic): its body. + std::optional cop; + if (const auto it = m_netCops.find(c.id); it != m_netCops.end() && it->second.predicted) + cop = it->second.sim->sim().modelMatrix(); + if (cop) { + shown.transform = *cop; + local = &*cop; + } game::traceClientCar(trace, frame, renderTime, shown, local ? 3 : c.extrapolated ? 2 : 0); } } @@ -4442,7 +4581,7 @@ class RaceScreen final : public Screen { seen.insert(c.id); if (c.wheels && m_netCars.back().data) wheeled.push_back({m_netCars.size() - 1, c.wheelOffsets}); - received.push_back({m_netCars.back(), c.stateTime}); + received.push_back({m_netCars.back(), c.stateTime, netBodyInFull(ctx, c.id, c.generation)}); } std::erase_if(m_netTireRotation, [&](const auto& e) { return !seen.contains(e.first); }); // The cars on their rails are the level's instances in TrafficBodies, @@ -4464,7 +4603,8 @@ class RaceScreen final : public Screen { std::vector moving; for (const ai::AmbientCar& c : m_netCars) if (!m_netTrafficCars || - (c.goal != ai::AmbientGoal::RandomDrive && !m_netTrafficCars->knocked(c.id))) + (c.goal != ai::AmbientGoal::RandomDrive && !m_netTrafficCars->simulated(c.id) && + !m_netTrafficCars->standing(c.id))) moving.push_back(c); m_trafficProxies->update(moving); } @@ -4583,6 +4723,14 @@ class RaceScreen final : public Screen { std::unique_ptr fx; std::vector impacts; bool fresh = true; + // Protocol 12: simulated here from the host's full states + // (predictNetTraffic) on the controls its driver set last, a body of + // its mass; else a kinematic body at its predicted place. + bool predicted = false; + double fullAt = 0.0; // session ms its last full state was put in + game::CorrectionBlend blend; + std::optional blendFrom; // drawn before a replay + bool switched = false; // to being simulated, in that replay }; // A police car's parts out of the world, kept for another one of its @@ -4593,6 +4741,7 @@ class RaceScreen final : public Screen { if (cop.audio) cop.audio->stop(); if (cop.sim) { + setNetCopPredicted(0, cop, false); // kinematic again const std::string model = cop.model; cop.fresh = true; // another car's damage when it is used again cop.impacts.clear(); @@ -4629,6 +4778,10 @@ class RaceScreen final : public Screen { } auto& sim = cop.sim->sim(); sim.options.weatherFriction = weatherFriction(); + // As the host's loadAiCar: of its player's model, the + // polygonal bound (simulated here, predictNetTraffic). + if (str::iequals(*model, ctx.netGame->playerCar(net::kHostPlayerId).vehicle)) + sim.setPolygonalBound(true); sim.body.kinematic = true; sim.body.resetCollider(); cop.renderer = std::make_unique( @@ -4664,14 +4817,20 @@ class RaceScreen final : public Screen { cop.fresh = true; // its damage drawn from its record again } 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; + // Simulated here while its full states keep coming + // (predictNetTraffic); else placed where it is predicted. + if (cop.predicted && ctx.netGame->frameTime() - cop.fullAt > kNetFullStaleMs) + setNetCopPredicted(c.id, cop, false); + if (!cop.predicted) { + 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; // OpenMM2: its damage as the host shows it. @@ -4692,20 +4851,175 @@ class RaceScreen final : public Screen { } } + // A shared-traffic client's police car switched between being simulated + // here (from the host's full states, a body of its mass, predictNetTraffic) + // and placed at its predicted place (kinematic, updateNetCops). Switched + // off, the drawing blends from where it was drawn. + void setNetCopPredicted(int id, NetCop& cop, bool on) { + if (!cop.sim || cop.predicted == on) + return; + const Mat34 drawnBefore = netCopPose(id, cop).body; + auto& sim = cop.sim->sim(); + sim.body.kinematic = !on; + sim.body.kinematicMoves = !on; + sim.ownRandom = on; + // What it hits as this machine's car's (World::collideInstances' + // PlayerInst): a traffic car it runs into is knocked loose here, as + // the host's police car knocks it (NetTrafficCars takes it as a + // knock of this machine's, for the host's messages to confirm). + sim.body.player = on; + sim.body.resetCollider(); + cop.predicted = on; + if (on) { + orderPlayerCars(); // the players' cars after it in the movers, as on the host + return; + } + cop.blend.halfLife = 0.15f; + cop.blend.snapDistance = 20.0f; + cop.blend.add(drawnBefore, netCopDrawn(id).value_or(cop.state.transform)); + } + static std::uint64_t netCopDrawnId(int id) { return kNetCopDrawnKey + static_cast(id); } + // A police car's body as drawn: predicted, between its last two + // samples; else at its interpolated state; the corrections and switches + // drawn away. + game::VehiclePose netCopPose(int id, const NetCop& cop) const { + game::VehiclePose pose; + if (cop.predicted && cop.sim) + pose = drawnPose(game::Drawn::Police, netCopDrawnId(id), *cop.sim); + else if (m_trafficClient) + pose.body = m_trafficClient->transformAt(id, m_netDrawTime).value_or(cop.state.transform); + else + pose.body = cop.state.transform; + return game::placePose(pose, cop.blend.apply(pose.body)); + } + + // Client, before the samples: the host's police and knocked cars in full + // (net/TrafficFull.h), kept by the host sample they belong to until its + // car's state acknowledges it (predictNetTraffic). + void receiveNetTrafficFull(Context& ctx) { + for (auto& m : ctx.netGame->takeTrafficFull()) { + auto& list = m_trafficFull[m.time]; + for (auto& c : m.cars) { + if (std::ranges::any_of(list, [&](const auto& o) { return o.id == c.id; })) + continue; // the same car twice: the first counts + m_trafficFullSeen[c.id] = {ctx.netGame->frameTime(), c.generation, c.kind}; + list.push_back(std::move(c)); + } + } + // A second's worth (CarStates come 20 times a second). + while (m_trafficFull.size() > 24) + m_trafficFull.erase(m_trafficFull.begin()); + std::erase_if(m_trafficFullSeen, [&](const auto& e) { + return ctx.netGame->frameTime() - e.second.at > kNetFullStaleMs; + }); + } + // Whether the host lately sent traffic car `id` (generation + // `generation`) in full: a knocked car simulated here. + bool netBodyInFull(Context& ctx, int id, int generation) const { + const auto it = m_trafficFullSeen.find(id); + return it != m_trafficFullSeen.end() && it->second.kind == net::AmbientKind::Traffic && + it->second.generation == static_cast(generation) % net::kAmbientGenerations && + ctx.netGame->frameTime() - it->second.at <= kNetFullStaleMs; + } + + // Client, in its car's acknowledgement (reconcileNetCar): the police and + // knocked cars the host sent in full at the acknowledged sample (`time`) + // run again with its car from those states, as the players' cars near + // it do; a police car on the controls its driver set last, which it keeps + // until the next. + void predictNetTraffic(Context& ctx, std::uint32_t time, + std::vector& out) { + m_netFullBodies.clear(); + const auto it = m_trafficFull.find(time); + if (it == m_trafficFull.end()) + return; + for (const net::TrafficFullCar& f : it->second) { + if (f.kind == net::AmbientKind::Police) { + const auto cop = m_netCops.find(f.id); + if (cop == m_netCops.end() || !cop->second.sim || + static_cast(cop->second.generation) % net::kAmbientGenerations != f.generation) + continue; + NetCop& c = cop->second; + c.blendFrom = netCopPose(f.id, c).body; + c.switched = !c.predicted; + setNetCopPredicted(f.id, c, true); + c.fullAt = ctx.netGame->frameTime(); + phys::CarSim* sim = &c.sim->sim(); + m_netFullBodies.push_back(&sim->body); + game::CarPrediction::Companion companion; + companion.car = c.sim.get(); + companion.state = &f.car; + companion.first = true; // the police come before the players' cars in the movers + companion.drive = [sim, &f] { + sim->setInputs(f.throttle, f.brake, f.steering, f.handBrake); + sim->engine.maxThrottle = f.maxThrottle; + }; + out.push_back(std::move(companion)); + continue; + } + if (!m_netTrafficCars || !m_trafficBodies || !m_netTrafficCars->simulated(f.id)) + continue; + // (A car without a body here takes one: put in by the replay.) + if (!m_trafficBodies->body(f.id) && !m_trafficBodies->setBodyState(f.id, f.body)) + continue; + game::CarPrediction::Companion companion; + companion.body = m_trafficBodies->body(f.id); + if (!companion.body) + continue; + m_netFullBodies.push_back(companion.body); + companion.rebase = [this, &f] { m_trafficBodies->setBodyState(f.id, f.body); }; + companion.position = f.body.matrix.m3; + companion.velocity = f.body.linearVelocity; + out.push_back(std::move(companion)); + } + } + // ... and after it: what the host's states moved the police by (and a + // switch to simulating one, more slowly) drawn away; the full states up + // to that sample forgotten. + void afterNetTrafficReplay(std::uint32_t time) { + for (auto& [id, c] : m_netCops) { + if (!c.blendFrom || !c.predicted || !c.sim) + continue; + const game::VehiclePose now = + drawnPose(game::Drawn::Police, netCopDrawnId(id), *c.sim); + c.blend.halfLife = c.switched ? 0.15f : 0.08f; + c.blend.snapDistance = 20.0f; + c.blend.add(*c.blendFrom, now.body); + c.blendFrom.reset(); + } + m_trafficFull.erase(m_trafficFull.begin(), m_trafficFull.upper_bound(time)); + m_netFullBodies.clear(); + } + // The predicted police's poses for the drawing between samples + // (recordStepPoses; before a replay's last sample, the ones it runs). + void recordNetCopSteps(bool replayed) { + for (const auto& [id, c] : m_netCops) { + if (!c.predicted || !c.sim || + (replayed && std::ranges::find(m_netFullBodies, &c.sim->sim().body) == m_netFullBodies.end())) + continue; + m_drawnPhys.record(game::drawnKey(game::Drawn::Police, netCopDrawnId(id)), c.sim->pose(), + c.sim->sim().resets); + } + } + 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; - if (m_trafficClient) - pose.body = m_trafficClient->transformAt(id, m_netDrawTime).value_or(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]; + if (c.predicted) { + // Simulated here (predictNetTraffic): between its last two + // samples, with what the host's states moved it by drawn away. + pose = netCopPose(id, c); + } else { + pose.body = netCopPose(id, c).body; + // 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; @@ -4926,7 +5240,9 @@ class RaceScreen final : public Screen { for (const auto& [id, rv] : m_remotes) if (rv.predicted && rv.sim && &rv.sim->sim().body == b) return true; - return false; + // The shared traffic's cars the acknowledgement runs again with this + // car (predictNetTraffic). + return std::ranges::find(m_netFullBodies, b) != m_netFullBodies.end(); } // The cars of players no longer in the race leave it. @@ -5351,6 +5667,7 @@ class RaceScreen final : public Screen { } std::vector companions; predictNearCars(newest->near, companions); + predictNetTraffic(ctx, newest->time, companions); // The shared traffic's cars on their rails around this car: the // samples run again meet each where that sample met it (or, the // experiment, where the host had it at that sample's time); after, @@ -5391,6 +5708,7 @@ class RaceScreen final : public Screen { if (rv.predicted && rv.sim) m_drawnPhys.record(game::drawnKey(game::Drawn::RemoteCar, id), rv.sim->pose(), rv.sim->sim().resets); + recordNetCopSteps(true); }, [&](std::uint32_t seq) { for (const auto& h : m_bodyHistory) { @@ -5422,6 +5740,7 @@ class RaceScreen final : public Screen { if (!railsNow.empty()) m_trafficBodies->placeRailCars(railsNow); m_netReplaying = false; + afterNetTrafficReplay(newest->time); // The other players' cars: what the host's states moved the ones // simulated here by, and a switch between simulating one and placing // it at its states, drawn away (a switch more slowly). @@ -5513,8 +5832,10 @@ class RaceScreen final : public Screen { const std::uint64_t now = net::monotonicMs(); if (ctx.netGame->isHost()) { collectHostCars(ctx); - if (now - m_netCarsSentAt >= kNetTrafficIntervalMs) + if (now - m_netCarsSentAt >= kNetTrafficIntervalMs) { sendNetCars(ctx, now); + sendNetTrafficFull(ctx); // OpenMM2: the shared traffic near each client, in full + } } else if (m_frameSteps > 0) { if (const auto msg = m_prediction.message()) { ctx.netGame->sendPlayerInput(*msg); @@ -5845,6 +6166,8 @@ class RaceScreen final : public Screen { return it != m_remoteDrawn.end() ? std::optional(it->second) : std::nullopt; } std::optional netCopDrawn(int id) const { + if (const auto it = m_netCops.find(id); it != m_netCops.end() && it->second.predicted) + return netCopPose(id, it->second).body; return m_trafficClient ? m_trafficClient->transformAt(id, m_netDrawTime) : std::nullopt; } std::uint64_t m_netFxLoggedAt = 0; @@ -6624,6 +6947,13 @@ class RaceScreen final : public Screen { float sirenAngle = 0.0f; // vehSiren::Update: 2.5 pi rad/s while on // A shared-traffic host: its damage for the clients (m_netDamage). game::DamageRecorder* damage = nullptr; + // The controls its driver set last (throttle, brake, steering, + // handbrake), and their sum over the frames since the last full + // state sent (updateAiDrivers, sendNetTrafficFull: their average + // travels). + std::array controls{}; + std::array controlSum{}; + int controlFrames = 0; }; std::unique_ptr m_police; std::vector m_cops; @@ -6694,6 +7024,20 @@ class RaceScreen final : public Screen { // session time the simulation's state belongs to this frame. game::NetDamage m_netDamage; std::uint32_t m_netStateTime = 0; + // A shared-traffic client: the host's police and knocked cars in full + // (receiveNetTrafficFull) by the host sample they belong to; when each + // car's came last; the ones the acknowledgement being run puts in + // (predictNetTraffic). + static constexpr double kNetFullStaleMs = 250.0; // five states + static constexpr std::uint64_t kNetCopDrawnKey = 1000; // its drawing's key: this + its id + std::map> m_trafficFull; + struct TrafficFullSeen { + double at = 0.0; // session ms + unsigned generation = 0; + net::AmbientKind kind = net::AmbientKind::Traffic; + }; + std::map m_trafficFullSeen; + std::vector m_netFullBodies; game::NetProps m_netProps; // OpenMM2: the host's props (game/net/NetProps) int m_netLocalId = -1; // this player's id in a network race std::vector m_remoteCars; // this frame's sample (updateRemoteCars) @@ -6765,8 +7109,10 @@ class RaceScreen final : public Screen { struct TrafficSent { std::uint64_t bytes = 0, messages = 0, cars = 0; std::uint64_t damageBits = 0, wheels = 0; // protocol 4's share (see sendNetTraffic) + std::uint64_t fullBytes = 0, fullCars = 0; // protocol 12: police and bodies in full }; std::map m_trafficSent; // host: per client, since the last log + std::map> m_trafficFullSent; // host: per client, the cars sent in full last std::vector m_netCars; // client: the received traffic this frame std::vector m_netCarsDrawn; // client: and as drawn, at m_netDrawTime double m_netDrawTime = 0.0; diff --git a/src/game/TrafficBodies.cpp b/src/game/TrafficBodies.cpp index ae68330..30d5f66 100644 --- a/src/game/TrafficBodies.cpp +++ b/src/game/TrafficBodies.cpp @@ -272,6 +272,13 @@ class TrafficBodies::RailCar final : public phys::Instance { phys::Body* entity() override; // aiVehicleInstance::AttachEntity. phys::Body* attachEntity() override; + // OpenMM2 (World::replaySample: a network client's samples run again): + // the body aiVehicleActive::Attach would give the car now, of its mass + // and moving along its rail at its speed, which a replayed car pushes as + // the samples first run did (they knocked the car loose), rather than + // meeting a car of infinite mass. Only a car its source lets a + // collision knock loose. + bool heldInertia(phys::InertialCS& out) const override; private: TrafficBodies& m_owner; @@ -363,6 +370,20 @@ phys::Body* TrafficBodies::RailCar::attachEntity() { return &a.body; } +bool TrafficBodies::RailCar::heldInertia(phys::InertialCS& out) const { + if (active || !data || !box || !m_owner.m_source.attachable(id, true)) + return false; + // As Active::attach sets its body up. + out = phys::InertialCS{}; + out.matrix = railMatrix; + out.setMass(data->size.x, data->size.y, data->size.z, data->mass); + const float alongZ = -speed; + out.linearVelocity = {alongZ * railMatrix.m2.x, alongZ * railMatrix.m2.y, alongZ * railMatrix.m2.z}; + out.linearMomentum = {out.mass * out.linearVelocity.x, out.mass * out.linearVelocity.y, + out.mass * out.linearVelocity.z}; + return true; +} + void TrafficBodies::Active::attach(RailCar& car) { const ai::VehicleData& d = *car.data; // The rail car stops being an instance of its own (flag 0x10 cleared) @@ -752,6 +773,81 @@ void TrafficBodies::instancesIn(int room, std::vector& out) con out.push_back(car); } +std::optional TrafficBodies::bodyState(int carId) const { + const RailCar* r = findRailCar(carId); + if (!r || !r->active) + return std::nullopt; + const Active& a = *r->active; + const phys::InertialCS& ics = a.body.ics; + net::TrafficBodyState s; + s.matrix = ics.matrix; + s.linearMomentum = ics.linearMomentum; + s.angularMomentum = ics.angularMomentum; + s.linearVelocity = ics.linearVelocity; + s.angularVelocity = ics.angularVelocity; + s.force = ics.linearForce; + s.torque = ics.angularTorque; + s.lastPush = ics.lastPush; + s.linearImpulse = ics.linearImpulse; + s.angularImpulse = ics.angularImpulse; + s.linearPush = ics.linearPush; + s.turnForce = ics.turnForce; + s.framePush = ics.framePush; + const Vec3 zero{}; + s.contact = !(s.linearImpulse == zero && s.angularImpulse == zero && s.linearPush == zero && + s.turnForce == zero && s.framePush == zero); + for (std::size_t i = 0; i < a.wheels.size(); ++i) + s.wheels[i] = {a.wheels[i].compression, a.wheels[i].lateral, a.wheels[i].longitudinal}; + s.sleepState = a.sleep.state; + s.stillUpdates = std::clamp(a.sleep.stillUpdates, 0, 1023); + s.dormantUpdates = std::clamp(a.sleep.dormantUpdates, 0, 1023); + return s; +} + +bool TrafficBodies::setBodyState(int carId, const net::TrafficBodyState& s) { + const auto index = static_cast(carId); + RailCar* r = carId >= 0 && index < m_railCars.size() ? m_railCars[index].get() : nullptr; + if (!r || !r->present || !r->data || !r->box) + return false; + if (!r->active) { + Active& fresh = attach(*r); + m_world.addNewMover(&fresh.body); + } + Active& a = *r->active; + phys::InertialCS& ics = a.body.ics; + ics.matrix = s.matrix; + ics.linearMomentum = s.linearMomentum; + ics.angularMomentum = s.angularMomentum; + ics.linearVelocity = s.linearVelocity; + ics.angularVelocity = s.angularVelocity; + ics.linearForce = s.force; + ics.angularTorque = s.torque; + ics.lastPush = s.lastPush; + ics.linearImpulse = s.linearImpulse; + ics.angularImpulse = s.angularImpulse; + ics.linearPush = s.linearPush; + ics.turnForce = s.turnForce; + ics.framePush = s.framePush; + for (std::size_t i = 0; i < a.wheels.size(); ++i) { + a.wheels[i].compression = s.wheels[i].compression; + a.wheels[i].lateral = s.wheels[i].lateral; + a.wheels[i].longitudinal = s.wheels[i].longitudinal; + } + a.sleep.state = std::clamp(s.sleepState, 0, 2); + a.sleep.stillUpdates = s.stillUpdates; + a.sleep.dormantUpdates = s.dormantUpdates; + // The bound follows the body; the next sweep starts where it is. + a.body.syncBoundMatrix(); + a.body.collider.lastMatrix = a.body.boundMatrix; + return true; +} + +phys::Body* TrafficBodies::body(int carId) { + const auto index = static_cast(carId); + RailCar* r = carId >= 0 && index < m_railCars.size() ? m_railCars[index].get() : nullptr; + return r && r->active ? &r->active->body : nullptr; +} + 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 1648bbb..4682c8b 100644 --- a/src/game/TrafficBodies.h +++ b/src/game/TrafficBodies.h @@ -16,6 +16,7 @@ #include "ai/World.h" #include "game/CityLevel.h" +#include "net/TrafficFull.h" #include "phys/World.h" #include @@ -130,6 +131,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): the state of a car's + // body as the host sends it in full (nullopt without a body), and on a + // client the car put to such a state, given a body first when it has + // none (aiVehicleManager::Attach without an impact). False when the + // source does not list the car. + std::optional bodyState(int carId) const; + bool setBodyState(int carId, const net::TrafficBodyState& state); + // The body of a car that has one (null without). + phys::Body* body(int carId); // Cars with a body (at most 32). std::size_t activeCount() const { return static_cast(m_count); } diff --git a/src/game/net/NetTrafficCars.cpp b/src/game/net/NetTrafficCars.cpp index 1cf5be8..ff000a3 100644 --- a/src/game/net/NetTrafficCars.cpp +++ b/src/game/net/NetTrafficCars.cpp @@ -3,6 +3,7 @@ #include "game/net/NetTrafficCars.h" #include +#include #include namespace mm2::game { @@ -10,11 +11,35 @@ namespace mm2::game { void NetTrafficCars::update(std::span received, double now, double confirmMs) { m_now = now; m_cars.clear(); + m_standing.clear(); std::unordered_set present; for (const Received& r : received) { const ai::AmbientCar& c = r.car; present.insert(c.id); const bool onRail = c.goal == ai::AmbientGoal::RandomDrive; + if (r.hostBody) { + // The host's body in full: simulated here from its states. A car + // this machine knocked loose goes on as it (the host's state is + // put in at the next acknowledgement). + if (const auto it = m_knocks.find(c.id); it != m_knocks.end()) { + if (!it->second.confirmed) + ++m_stats.confirmed; + m_hostBodies[c.id] = it->second.pose; + m_knocks.erase(it); + } + const auto [body, fresh] = m_hostBodies.try_emplace(c.id, c.transform); + Knock k; + k.generation = c.spawns; + k.pose = body->second; + keepLocal(c, k); + continue; + } + if (const auto it = m_hostBodies.find(c.id); it != m_hostBodies.end()) { + // Its full states stopped (at rest on the host, or far): the + // host's messages lead from here, the drawing blending. + m_handovers.push_back({c.id, it->second, true}); + m_hostBodies.erase(it); + } if (const auto it = m_knocks.find(c.id); it != m_knocks.end()) { Knock& k = it->second; const double heard = static_cast(r.stateTime); @@ -51,11 +76,24 @@ void NetTrafficCars::update(std::span received, double now, doub continue; } } - // Off its rail on the host: a moving instance (game::TrafficProxies). - if (onRail) + // Off its rail on the host: a moving instance (game::TrafficProxies); + // one standing still (a car that came to rest after a knock, without + // a body on the host) stands here as a rail car does, which this + // machine's car knocks loose as the host's does (protocol 12; before + // it met a car of infinite mass). + if (onRail) { m_cars.push_back(c); + } else if (c.velocity.mag2() < kStandingSpeed * kStandingSpeed && + std::abs(c.speed) < kStandingSpeed) { + ai::AmbientCar a = c; + a.physical = false; + a.speed = 0.0f; + m_cars.push_back(a); + m_standing.insert(c.id); + } } std::erase_if(m_knocks, [&](const auto& e) { return !present.contains(e.first); }); + std::erase_if(m_hostBodies, [&](const auto& e) { return !present.contains(e.first); }); } void NetTrafficCars::keepLocal(const ai::AmbientCar& c, const Knock& k) { @@ -73,8 +111,8 @@ void NetTrafficCars::handOver(int id, const Knock& knock, bool confirmed) { void NetTrafficCars::impact(int carId) { const auto it = std::ranges::find_if(m_cars, [carId](const ai::AmbientCar& c) { return c.id == carId; }); - if (it == m_cars.end()) - return; + if (it == m_cars.end() || m_hostBodies.contains(carId)) + return; // (a body the host sends in full is the host's knock) m_knocks[carId] = {it->spawns, m_now, it->transform}; it->physical = true; it->goal = ai::AmbientGoal::Collision; @@ -89,11 +127,15 @@ void NetTrafficCars::detach(int carId, const Mat34& pose, bool) { it->second.pose = pose; it->second.resting = true; } + if (const auto it = m_hostBodies.find(carId); it != m_hostBodies.end()) + it->second = pose; } void NetTrafficCars::setPhysicalTransform(int carId, const Mat34& transform) { if (const auto it = m_knocks.find(carId); it != m_knocks.end()) it->second.pose = transform; + if (const auto it = m_hostBodies.find(carId); it != m_hostBodies.end()) + it->second = transform; } bool NetTrafficCars::attachable(int, bool byPlayer) const { return byPlayer; } diff --git a/src/game/net/NetTrafficCars.h b/src/game/net/NetTrafficCars.h index e54cdad..d824cfa 100644 --- a/src/game/net/NetTrafficCars.h +++ b/src/game/net/NetTrafficCars.h @@ -30,6 +30,7 @@ #include #include +#include #include #include @@ -42,6 +43,11 @@ class NetTrafficCars final : public TrafficBodies::Source { struct Received { ai::AmbientCar car; std::uint32_t stateTime = 0; + // Protocol 12: the host sends its body in full now and then (a car it + // knocked loose near this machine's): the car is simulated here from + // those states, put in at each acknowledgement of this machine's car + // (RaceScreen::reconcileNetCar), as long as they keep coming. + bool hostBody = false; }; // Each frame before TrafficBodies::beforeStep: the received traffic cars // at session time `now` (this machine's car's). A car this machine @@ -65,6 +71,15 @@ class NetTrafficCars final : public TrafficBodies::Source { // The cars this machine's car knocked loose since the last call. std::vector takeKnocks() { return std::exchange(m_new, {}); } bool knocked(int id) const { return m_knocks.contains(id); } + // Simulated here: knocked loose by this machine's car, or a body the host + // sends in full. + bool simulated(int id) const { return m_knocks.contains(id) || m_hostBodies.contains(id); } + // Off its rail on the host but standing still, without a body there: a + // car this machine's car may knock loose here (listed as a rail car at + // rest). A car knocked loose from there counts as confirmed at once (the + // host's messages had it off its rail already). + bool standing(int id) const { return m_standing.contains(id); } + static constexpr float kStandingSpeed = 0.05f; // m/s struct Stats { std::uint64_t knocks = 0; // knocked loose by this machine's car @@ -96,6 +111,8 @@ class NetTrafficCars final : public TrafficBodies::Source { void keepLocal(const ai::AmbientCar& c, const Knock& k); std::map m_knocks; + std::map m_hostBodies; // the bodies the host sends in full, where they are + std::set m_standing; // off their rails, standing (listed this frame) std::vector m_cars; std::vector m_handovers; std::vector m_new; diff --git a/src/game/net/PlayerCars.cpp b/src/game/net/PlayerCars.cpp index 155c22d..7a10f77 100644 --- a/src/game/net/PlayerCars.cpp +++ b/src/game/net/PlayerCars.cpp @@ -416,6 +416,35 @@ std::optional CarPrediction::message() const { return msg; } +namespace { + +// A companion's body, its host state's place and velocity, putting it to +// that state and driving it for a sample (a player's car, or OpenMM2's +// shared traffic's police car or knocked car). +phys::Body& companionBody(const CarPrediction::Companion& c) { + return c.body ? *c.body : c.car->sim().body; +} +Vec3 companionPosition(const CarPrediction::Companion& c) { + return c.state ? c.state->matrix.m3 : c.position; +} +Vec3 companionVelocity(const CarPrediction::Companion& c) { + return c.state ? c.state->linearVelocity : c.velocity; +} +void rebaseCompanion(const CarPrediction::Companion& c) { + if (c.rebase) + c.rebase(); + else if (c.car && c.state) + applyOwnCarState(*c.car, *c.state); +} +void driveCompanion(const CarPrediction::Companion& c) { + if (c.drive) + c.drive(); + else if (c.driver && c.car) + c.driver->apply(*c.car, c.input); +} + +} // namespace + CarPrediction::Correction CarPrediction::acknowledge(SimVehicle& car, NetCarDriver& driver, phys::World& world, std::uint32_t ack, const net::OwnCarState& host, @@ -466,17 +495,17 @@ CarPrediction::Correction CarPrediction::acknowledge(SimVehicle& car, NetCarDriv const float span = static_cast(later + 1) * phys::kFixedSampleStep; const Vec3 carNow = car.sim().body.ics.matrix.m3; for (const Companion& c : companions) { - const float closing = (c.state->linearVelocity - predicted.linearVelocity).mag() * span; + const float closing = (companionVelocity(c) - predicted.linearVelocity).mag() * span; const float reach = m_options.companionReach + closing; - if (c.state->matrix.m3.dist2(predicted.matrix.m3) < reach * reach || - c.car->sim().body.ics.matrix.m3.dist2(carNow) < reach * reach) + if (companionPosition(c).dist2(predicted.matrix.m3) < reach * reach || + companionBody(c).ics.matrix.m3.dist2(carNow) < reach * reach) alone = false; } if (replay && alone) { std::vector bodies; for (const Companion& c : companions) { - applyOwnCarState(*c.car, *c.state); - bodies.push_back(&c.car->sim().body); + rebaseCompanion(c); + bodies.push_back(&companionBody(c)); } // (In the world's order: the companions come in player order.) world.beginReplay(world.time() - @@ -484,7 +513,7 @@ CarPrediction::Correction CarPrediction::acknowledge(SimVehicle& car, NetCarDriv for (std::size_t k = index + 1; k < m_history.size(); ++k) { restore(m_history[k - 1], car, driver); // the car as the sample met it for (const Companion& c : companions) - c.driver->apply(*c.car, c.input); + driveCompanion(c); if (beforeEach) beforeEach(m_history[k].seq); if (k + 1 == m_history.size() && beforeLast) @@ -509,15 +538,15 @@ CarPrediction::Correction CarPrediction::acknowledge(SimVehicle& car, NetCarDriv std::vector bodies; for (const Companion& c : companions) if (c.first) - bodies.push_back(&c.car->sim().body); + bodies.push_back(&companionBody(c)); bodies.push_back(&car.sim().body); if (phys::Trailer* t = car.trailer()) bodies.push_back(&t->body); for (const Companion& c : companions) if (!c.first) - bodies.push_back(&c.car->sim().body); + bodies.push_back(&companionBody(c)); for (const Companion& c : companions) - applyOwnCarState(*c.car, *c.state); + rebaseCompanion(c); // OpenMM2: the props the replay meets (World::collideHeld), the samples // having run for real one after another up to the world's time now. world.beginReplay(world.time() - @@ -528,7 +557,7 @@ CarPrediction::Correction CarPrediction::acknowledge(SimVehicle& car, NetCarDriv NetCarDriver::command(car, c); driver.apply(car, e.input); for (const Companion& c : companions) - c.driver->apply(*c.car, c.input); + driveCompanion(c); if (beforeEach) beforeEach(e.seq); if (k + 1 == m_history.size() && beforeLast) diff --git a/src/game/net/PlayerCars.h b/src/game/net/PlayerCars.h index 6847764..cf47e1f 100644 --- a/src/game/net/PlayerCars.h +++ b/src/game/net/PlayerCars.h @@ -238,6 +238,15 @@ class CarPrediction { // number: every machine keeps the players' cars in that order), and // collides first when they run again, as on the host. bool first = false; + // OpenMM2's shared traffic: a car no player drives (a police car: + // `drive` sets its controls before each sample, in place of `driver` + // and `input`) or a body that is no car (a knocked traffic car: + // `body`, put to the host's state by `rebase` in place of `state`, + // which then stays null; `position` and `velocity` are that state's). + std::function drive; + phys::Body* body = nullptr; + std::function rebase; + Vec3 position, velocity; }; // The host's state after sample `ack`. Forgets what it acknowledged; when // the state differs from the prediction for that sample, puts the car diff --git a/src/game/net/TrafficTrace.h b/src/game/net/TrafficTrace.h index 167b9ac..607c851 100644 --- a/src/game/net/TrafficTrace.h +++ b/src/game/net/TrafficTrace.h @@ -12,8 +12,9 @@ // TC frame time id gen kind x y z fx fz speed flags target mode // client: a car where its collisions meet it (the physics proxy) for the // frame that began at session time `frame`, placed for session time -// `time`; mode 0 interpolated, 1 extrapolated, 2 predicted, 3 knocked -// loose by this machine's car (where its body is) +// `time`; mode 0 interpolated, 1 extrapolated, 2 predicted, 3 +// simulated here (where its body is): knocked loose by this machine's +// car, or a knocked car or police car the host sends in full // TV frame time x y z // client: where this machine's car is for the same frame, and the // session time its state is at on the host's clock (its own time plus diff --git a/tests/game/test_net_traffic_cars.cpp b/tests/game/test_net_traffic_cars.cpp index 0d5a63f..7a11327 100644 --- a/tests/game/test_net_traffic_cars.cpp +++ b/tests/game/test_net_traffic_cars.cpp @@ -124,3 +124,99 @@ TEST(NetTrafficCars, ARecycledOrVanishedCarIsForgotten) { cars.impact(7); EXPECT_FALSE(cars.knocked(7)); } + +TEST(NetTrafficCars, ABodyTheHostSendsInFullIsSimulatedHere) { + // Protocol 12: a car the host knocked loose near this machine's car comes + // in full (net/TrafficFull.h); it is listed physical and simulated here + // (RaceScreen::predictNetTraffic puts its states in) while they come. + NetTrafficCars cars; + auto knocked = car(5, 2, 1000, false); + knocked.hostBody = true; + cars.update(std::vector{knocked}, 1100.0, 200.0); + EXPECT_TRUE(cars.simulated(5)); + EXPECT_FALSE(cars.knocked(5)); // not this machine's knock + const ai::AmbientCar* c = listed(cars, 5); + ASSERT_NE(c, nullptr); + EXPECT_TRUE(c->physical); + EXPECT_EQ(c->transform.m3, knocked.car.transform.m3); + // Hit by this machine's car it stays the host's knock. + cars.impact(5); + EXPECT_FALSE(cars.knocked(5)); + EXPECT_TRUE(cars.takeKnocks().empty()); + // Its body moves here: listed where it is. + const Mat34 moved = Mat34::translation({9, 0, 4}); + cars.setPhysicalTransform(5, moved); + knocked.stateTime = 1050; + cars.update(std::vector{knocked}, 1150.0, 200.0); + c = listed(cars, 5); + ASSERT_NE(c, nullptr); + EXPECT_EQ(c->transform.m3, moved.m3); + EXPECT_TRUE(cars.takeHandovers().empty()); + // The full states stop (at rest on the host, or farther away): the + // host's messages lead from there, the drawing blending from the body. + knocked.hostBody = false; + cars.update(std::vector{knocked}, 1200.0, 200.0); + EXPECT_FALSE(cars.simulated(5)); + EXPECT_EQ(listed(cars, 5), nullptr); // off its rail: a moving instance + const auto h = cars.takeHandovers(); + ASSERT_EQ(h.size(), 1u); + EXPECT_TRUE(h[0].confirmed); + EXPECT_EQ(h[0].pose.m3, moved.m3); +} + +TEST(NetTrafficCars, ACarStandingOffItsRailIsKnockedLooseAsOnTheHost) { + // Protocol 12: a car at rest off its rail without a body on the host + // (it came to rest after a knock) stands here as a rail car at rest, + // which this machine's car knocks loose; one moving off its rail is the + // host's moving instance. + NetTrafficCars cars; + auto standing = car(4, 1, 1000, false); + standing.car.speed = 0.0f; + standing.car.velocity = {}; + auto moving = car(6, 1, 1000, false); + moving.car.velocity = {3, 0, 0}; + cars.update(std::vector{standing, moving}, 1100.0, 200.0); + EXPECT_TRUE(cars.standing(4)); + EXPECT_FALSE(cars.standing(6)); + const ai::AmbientCar* c = listed(cars, 4); + ASSERT_NE(c, nullptr); + EXPECT_FALSE(c->physical); // an instance at rest, not a body + EXPECT_EQ(c->transform.m3, standing.car.transform.m3); + EXPECT_EQ(listed(cars, 6), nullptr); + cars.impact(4); + EXPECT_TRUE(cars.knocked(4)); + // Off its rail in the host's messages already: confirmed at once, the + // local body leading until it rests. + standing.stateTime = 1150; + cars.update(std::vector{standing}, 1200.0, 200.0); + EXPECT_EQ(cars.stats().confirmed, 1u); + EXPECT_TRUE(cars.simulated(4)); + EXPECT_FALSE(cars.standing(4)); + ASSERT_NE(listed(cars, 4), nullptr); + EXPECT_TRUE(listed(cars, 4)->physical); +} + +TEST(NetTrafficCars, ALocalKnockGoesOnAsTheHostsBody) { + // This machine's car knocked the car loose; the host's full states of + // it confirm the knock and take it over (no handover: it stays + // simulated here, from the host's states). + NetTrafficCars cars; + cars.update(std::vector{car(1, 3, 1000)}, 1100.0, 200.0); + cars.impact(1); + const Mat34 moved = Mat34::translation({2, 0, 1}); + cars.setPhysicalTransform(1, moved); + auto full = car(1, 3, 1150, false); + full.hostBody = true; + cars.update(std::vector{full}, 1200.0, 200.0); + EXPECT_FALSE(cars.knocked(1)); + EXPECT_TRUE(cars.simulated(1)); + EXPECT_EQ(cars.stats().confirmed, 1u); + EXPECT_TRUE(cars.takeHandovers().empty()); + const ai::AmbientCar* c = listed(cars, 1); + ASSERT_NE(c, nullptr); + EXPECT_TRUE(c->physical); + EXPECT_EQ(c->transform.m3, moved.m3); // where the local body is + // Gone from the host's messages: forgotten. + cars.update(std::vector{}, 1300.0, 200.0); + EXPECT_FALSE(cars.simulated(1)); +} diff --git a/tests/game/test_traffic_bodies.cpp b/tests/game/test_traffic_bodies.cpp index 48363db..f2b83cd 100644 --- a/tests/game/test_traffic_bodies.cpp +++ b/tests/game/test_traffic_bodies.cpp @@ -5,6 +5,7 @@ #include "city/CityData.h" #include "game/PlayerVehicle.h" #include "game/TrafficBodies.h" +#include "phys/Sleep.h" #include "phys/World.h" #include @@ -323,4 +324,92 @@ TEST(TrafficBodies, RailCarsStandElsewhereForAReplayAndComeBack) { game::TrafficBodies::RailPose unknown{100000, moved.transform}; scene.traffic->placeRailCars(std::span(&unknown, 1)); EXPECT_FALSE(there(moved.transform.m3)); + // OpenMM2: a car run again into a rail car (World::replaySample) meets + // the body it would take, of its mass and moving along its rail. + std::vector list; + scene.traffic->instancesIn(1, list); + const auto self = std::ranges::find_if(list, [&](const phys::Instance* i) { + return i->matrix().m3 == target->transform.m3; + }); + ASSERT_NE(self, list.end()); + phys::InertialCS held; + ASSERT_TRUE((*self)->heldInertia(held)); + EXPECT_EQ(held.mass, target->data->mass); + EXPECT_EQ(held.matrix.m3, target->transform.m3); + const Vec3 along = -target->transform.m2 * target->speed; + EXPECT_NEAR(held.linearVelocity.dist(along), 0.0f, 1e-4f); + EXPECT_NEAR(held.linearMomentum.dist(along * held.mass), 0.0f, 0.05f); +} + +// OpenMM2 (the shared traffic of a network cruise, net/TrafficFull.h): a +// car's body put to a state (setBodyState, which gives a car without a body +// one) moves on from it exactly as it did the first time: the state holds +// all a sample needs, so a client simulates the host's knocked cars alike. +TEST(TrafficBodies, ABodyPutToAStateMovesOnAlike) { + MM2_REQUIRE_GAME_DATA(); + const vfs::Vfs& vfs = *test::gameData(); + auto city = city::loadCity(vfs, "london"); + ASSERT_TRUE(city); + auto ai = londonTraffic(vfs, *city); + ASSERT_TRUE(ai); + const ai::AmbientCar* target = pickTarget(*ai, 0.0f); + ASSERT_TRUE(target); + const int id = target->id; + Scene scene(*ai, target->transform.m3); + scene.traffic->beforeStep(); + EXPECT_FALSE(scene.traffic->bodyState(id)); + EXPECT_EQ(scene.traffic->body(id), nullptr); + EXPECT_FALSE(scene.traffic->setBodyState(100000, {})) << "a car the source does not list"; + // Given a body where it stands, a little above the road; it settles. (Its + // source lists it off its rail: on a client NetTrafficCars does, for a + // car the host sends in full.) + ai->traffic().impact(id, {}); + net::TrafficBodyState drop; + drop.matrix = target->transform; + drop.matrix.m3.y += 0.2f; + ASSERT_TRUE(scene.traffic->setBodyState(id, drop)); + const phys::Body* body = scene.traffic->body(id); + ASSERT_NE(body, nullptr); + const auto samples = [&](int n) { + for (int i = 0; i < n; ++i) { + scene.traffic->beforeStep(); + scene.world->advanceFixed(phys::kFixedSampleStep); + scene.traffic->afterStep(); + } + }; + samples(20); + auto start = scene.traffic->bodyState(id); + ASSERT_TRUE(start); + // Then pushed along its lane, turning. + const Vec3 v = -target->transform.m2 * 6.0f; + start->linearVelocity = v; + start->linearMomentum = v * body->ics.mass; + start->angularVelocity = {0.0f, 0.3f, 0.0f}; + start->angularMomentum = Vec3{0.0f, 0.3f, 0.0f} * body->ics.inertia.y; + start->sleepState = phys::Sleep::Awake; + start->stillUpdates = 0; + ASSERT_TRUE(scene.traffic->setBodyState(id, *start)); + samples(40); + const auto first = scene.traffic->bodyState(id); + ASSERT_TRUE(first) << "still moving"; + EXPECT_GT(first->matrix.m3.dist(start->matrix.m3), 1.0f); // its wheels skid + ASSERT_TRUE(scene.traffic->setBodyState(id, *start)); + const auto put = scene.traffic->bodyState(id); + ASSERT_TRUE(put); + EXPECT_EQ(put->matrix.m3, start->matrix.m3); + EXPECT_EQ(put->linearMomentum, start->linearMomentum); + EXPECT_EQ(put->wheels[2].compression, start->wheels[2].compression); + samples(40); + const auto again = scene.traffic->bodyState(id); + ASSERT_TRUE(again); + EXPECT_EQ(again->matrix.m0, first->matrix.m0); + EXPECT_EQ(again->matrix.m3, first->matrix.m3); + EXPECT_EQ(again->linearVelocity, first->linearVelocity); + EXPECT_EQ(again->angularMomentum, first->angularMomentum); + for (std::size_t i = 0; i < 4; ++i) { + EXPECT_EQ(again->wheels[i].compression, first->wheels[i].compression); + EXPECT_EQ(again->wheels[i].lateral, first->wheels[i].lateral); + } + EXPECT_EQ(again->sleepState, first->sleepState); + EXPECT_EQ(again->stillUpdates, first->stillUpdates); } diff --git a/tools/mm2tool/cmd_nettrace.cpp b/tools/mm2tool/cmd_nettrace.cpp index 221d32c..e62809f 100644 --- a/tools/mm2tool/cmd_nettrace.cpp +++ b/tools/mm2tool/cmd_nettrace.cpp @@ -208,7 +208,8 @@ int cmdNettrace(std::span args) { return 1; } constexpr int kOffRail = 0x10; - std::vector rail, moving, offRail, police, policeMoving, near, local, shownMoving, afterHit; + std::vector rail, moving, offRail, police, policeMoving, policeSimulated; + std::vector near, local, shownMoving, afterHit; std::size_t present = 0, extra = 0, missing = 0, copFrames = 0, copTargets = 0; for (std::size_t i = 0; i < client.frames.size(); ++i) { const Frame& fr = client.frames[i]; @@ -227,7 +228,7 @@ int cmdNettrace(std::span args) { ++present; const double e = std::hypot(c.x - h->x, c.z - h->z); if (c.mode == 3) - local.push_back(e); + (c.kind == 1 ? policeSimulated : local).push_back(e); // In the second after the client's car hit it. if (std::ranges::any_of(client.hits, [&](const Event& hit) { return hit.id == c.key.first && hit.generation == c.key.second && fr.own >= hit.time && @@ -274,8 +275,9 @@ int cmdNettrace(std::span args) { row("off-rail (knocked) cars", offRail); row("police", police); row("police moving > 2 m/s", policeMoving); + row("police simulated here", policeSimulated); row("any car within 40 m", near); - row("knocked by the client", local); + row("simulated here (knocked)", local); row("hit by the client, 1 s", afterHit); std::println("error against the host at the time the client showed the car:"); row("rail cars moving > 2 m/s", shownMoving); From 37d212399f8cbf10e73b992158f68df4fed642d9 Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Fri, 9 Oct 2026 20:30:17 -0700 Subject: [PATCH 096/129] Give the simulation its own sin, cos, atan2, exp, log and pow The C runtimes round the elementary functions differently in the last bit. Over a million arguments in the game's ranges, a MinGW build run under Wine disagrees with glibc for 21 % of atan2f results, 14 % of tanf, 8 % of acosf, 2 % of atanf and about 1 % of sinf / cosf; MSVC's runtime is a third implementation. A host and a client built against different runtimes then simulated the same sample differently (the players' cars record's O6), and so did the AI's steering, the props and the damage encoding. core/Libm.h computes sin, cos, tan, asin, acos, atan, atan2, exp, exp2, log, log2, pow and hypot with the basic operations only (Cody and Waite reduction, series in Horner form, fdlibm's atanh form of the logarithm), so they give the same bits everywhere. The float versions are the double result rounded to float: correctly rounded except where a double lies within its error of a float boundary (5 of 2 billion log arguments, 1 to 2 for sin, atan, acos and exp2 in exhaustive sweeps). That is what MM2's x87 code got: fsin, fcos, fpatan, fyl2x and f2xm1 work in extended precision whatever the precision control, and the result is stored to a float. phys::age's rotations already rounded the runtime's double cos and sin to float, which these match bit for bit. Every call in the simulation goes through them: the car (vehWheel's MakeRotateY for the steered wheels and the road bumps, the trailer joint, the gearing's pow, the steering curve's pow), the core rotations, the AI (steering, paths, traffic, police, opponents, pedestrians, the aiMap road order), the session's spawns and gates, the network prediction and damage encoding. A test fails when a simulation source calls a runtime function again. On Linux the single-player results change where glibc's own sinf and cosf (for 1.3 % of arguments in -100 .. 100) and expf, logf and powf (0.02 to 0.06 %) were not correctly rounded; glibc also picks an FMA variant of them by CPU, so the old results could differ between Linux machines. The opponent sweep went from 516/517 finished and 508/517 across the line to 515/517 and 507/517: one racer of sf race7 (professional) wrecks at 92 %. With the runtime's sinf and cosf kept everywhere it is 516/508 again, and kept everywhere but in the car's MakeRotateY and road bumps it is 514/507: the sweep moves by a car or two for any change in the last bit of the steering. Co-Authored-By: Claude Opus 5.5 --- src/ai/AmbientRoute.cpp | 4 +- src/ai/Course.cpp | 4 +- src/ai/Driving.cpp | 21 +- src/ai/DrivingRoute.cpp | 7 +- src/ai/DrivingTargets.cpp | 39 +-- src/ai/Opponent.cpp | 5 +- src/ai/PathGeometry.cpp | 12 +- src/ai/Pedestrians.cpp | 33 +-- src/ai/Police.cpp | 3 +- src/ai/Traffic.cpp | 17 +- src/app/Controls.cpp | 4 +- src/city/AiMap.cpp | 3 +- src/core/CMakeLists.txt | 3 +- src/core/Libm.cpp | 447 +++++++++++++++++++++++++++++ src/core/Libm.h | 64 +++++ src/core/Math.cpp | 41 ++- src/game/net/Autopilot.cpp | 3 +- src/game/net/PlayerCars.cpp | 3 +- src/game/net/TrafficPrediction.cpp | 12 +- src/game/net/TrafficSync.cpp | 3 +- src/game/session/Gate.cpp | 4 +- src/game/session/RaceSetup.cpp | 5 +- src/net/VehicleDamage.h | 5 +- src/phys/AgeMath.cpp | 18 +- src/phys/TrailerJoint.cpp | 9 +- src/phys/vehicle/CarSim.cpp | 3 +- src/phys/vehicle/Controls.cpp | 5 +- src/phys/vehicle/Trailer.cpp | 3 +- src/phys/vehicle/Transmission.cpp | 7 +- src/phys/vehicle/Wheel.cpp | 11 +- tests/CMakeLists.txt | 5 + tests/core/test_libm.cpp | 372 ++++++++++++++++++++++++ 32 files changed, 1046 insertions(+), 129 deletions(-) create mode 100644 src/core/Libm.cpp create mode 100644 src/core/Libm.h create mode 100644 tests/core/test_libm.cpp diff --git a/src/ai/AmbientRoute.cpp b/src/ai/AmbientRoute.cpp index aa6c014..623902b 100644 --- a/src/ai/AmbientRoute.cpp +++ b/src/ai/AmbientRoute.cpp @@ -3,6 +3,8 @@ // Also: aiMap::ChooseNextRightStraightLink (chooseTurnOrStraight, +2). #include "ai/AmbientRoute.h" +#include "core/Libm.h" + #include #include #include @@ -329,7 +331,7 @@ TurnType solveTurnType(const RoadNetwork& net, const RailLink& from, int nextPat side = v.x * right.x + v.z * right.z + v.y * right.y; ahead = v.x * forward.x + v.z * forward.z + v.y * forward.y; } - const float angle = std::atan2(side, ahead); + const float angle = libm::atan2(side, ahead); if (angle > 0.5f) return TurnType::Right; if (angle < -0.5f) diff --git a/src/ai/Course.cpp b/src/ai/Course.cpp index f852cff..b992db0 100644 --- a/src/ai/Course.cpp +++ b/src/ai/Course.cpp @@ -6,6 +6,8 @@ // road starts). #include "ai/Course.h" +#include "core/Libm.h" + #include #include #include @@ -845,7 +847,7 @@ void Course::findTurns() { const std::size_t prev = i == 0 ? n - 2 : i - 1; const Vec3 a = flatUnit(pts[i] - pts[prev]); const Vec3 b = flatUnit(pts[i + 1] - pts[i]); - const float angle = std::atan2(a.x * b.z - a.z * b.x, a.x * b.x + a.z * b.z); + const float angle = libm::atan2(a.x * b.z - a.z * b.x, a.x * b.x + a.z * b.z); if (std::abs(angle) > 0.03f) bends.push_back({m_line.distances[i], angle, i}); } diff --git a/src/ai/Driving.cpp b/src/ai/Driving.cpp index b09197b..0be9783 100644 --- a/src/ai/Driving.cpp +++ b/src/ai/Driving.cpp @@ -19,6 +19,7 @@ #include "ai/PathGeometry.h" #include "ai/Pedestrians.h" #include "ai/Traffic.h" +#include "core/Libm.h" #include "phys/AgeMath.h" #include "phys/Bound.h" #include "phys/World.h" @@ -57,7 +58,7 @@ float vectorAngle(const Vec3& a, const Vec3& b) { return 0.0f; if (c < -1.0f) return 3.1415927f; - return std::acos(c); + return libm::acos(c); } // The steering MM2 computes from a heading error in @@ -169,12 +170,12 @@ bool nearestPlayer2(const Vec3& position, std::span cars, floa } float perFrame(float factor, float dt) { - return std::pow(factor, clampf(30.0f * dt, 0.01f, 2.0f)); + return libm::pow(factor, clampf(30.0f * dt, 0.01f, 2.0f)); } float headingError(const Mat34& m, const Vec3& target) { const float dx = target.x - m.m3.x, dz = target.z - m.m3.z; - return std::atan2(dx * m.m0.x + dz * m.m0.z, -(dx * m.m2.x + dz * m.m2.z)); + return libm::atan2(dx * m.m0.x + dz * m.m0.z, -(dx * m.m2.x + dz * m.m2.z)); } float forwardSpeed(const phys::CarSim& car) { @@ -262,7 +263,7 @@ float blockingDistance(const TrackedCar& obstacle, const Vec3& from, const Vec3& const Vec3 rel = c - from; const float lateral = rel.x * r.x + rel.z * r.z; const float along = rel.x * d.x + rel.z * d.z; - const float angle = std::atan2(lateral, along); + const float angle = libm::atan2(lateral, along); if (-halfWidth < lateral && lateral < halfWidth && along > 0.0f && along < length + extra && angle > -0.7f && angle < 0.7f) return along; @@ -305,7 +306,7 @@ void avoidPoints(const TrackedCar& obstacle, const Vec3& from, const Vec3& dir, left = right = obstacle.position; for (const Vec3& p : points) { const Vec3 rel = p - from; - const float a = std::atan2(rel.x * r.x + rel.z * r.z, rel.x * d.x + rel.z * d.z); + const float a = libm::atan2(rel.x * r.x + rel.z * r.z, rel.x * d.x + rel.z * d.z); if (a < lo) { lo = a; left = p; @@ -584,7 +585,7 @@ void PhysicsDriver::backup(float dt) { // rate). The inputs go to the car; the driver's values stay as they were. auto& ics = m_car.body.ics; const Vec3 d = m_target - ics.matrix.m3; - const float angle = std::atan2(d.dot(ics.matrix.m0), -d.dot(ics.matrix.m2)); + const float angle = libm::atan2(d.dot(ics.matrix.m0), -d.dot(ics.matrix.m2)); if (angle <= 0.1f && angle >= -0.1f) { m_car.steering = 0.0f; phys::age::rotate(ics.matrix, ics.matrix.m1, angle); @@ -665,7 +666,7 @@ void PhysicsDriver::mirror(float dt, const TrackedCar& target) { } } const Vec3& f = target.forward; - const float angle = std::atan2(f.x * ics.matrix.m0.x + f.z * ics.matrix.m0.z, + const float angle = libm::atan2(f.x * ics.matrix.m0.x + f.z * ics.matrix.m0.z, -(f.x * ics.matrix.m2.x + f.z * ics.matrix.m2.z)); m_steering = clampf(steeringGain(angle), -1.0f, 1.0f); apply(); @@ -688,7 +689,7 @@ void PhysicsDriver::calcSpeed(float dt) { const float a = vectorAngle(n[1].pos - n[0].pos, n[2].pos - n[1].pos); if (a > kSharpTurn) { const float speed = m_car.speed(); - const double t = std::tan((3.14 - std::abs(static_cast(a))) * 0.5); + const double t = libm::tan((3.14 - std::abs(static_cast(a))) * 0.5); const float v = static_cast(std::sqrt(t * 10.0 * kAiGripFactor * 19.8) * static_cast(params.cornerSpeedFactor)); float brake = 0.0f; @@ -764,7 +765,7 @@ void PhysicsDriver::calcRoadSpeed(float dt) { p->center[static_cast(vi + 1)] - p->center[static_cast(vi)]; const Vec3& x = p->xAxis[static_cast(vi)]; const Vec3& z = p->zAxis[static_cast(vi)]; - const float a = std::atan2(-x.z * seg.z + -x.x * seg.x, -z.z * seg.z + -z.x * seg.x); + const float a = libm::atan2(-x.z * seg.z + -x.x * seg.x, -z.z * seg.z + -z.x * seg.x); if (sharpTurn(turns, t, false).angle != a) k = 2; } @@ -876,7 +877,7 @@ void placeOnCourse(phys::CarSim& car, const Course& course, float s, float side, // vehCarSim::Reset: the body's centre at that position plus // CenterOfGravity, the identity turned about Y; the model origin one // R * CenterOfGravity on (vehCarSim::SetWorldMatrix). - const float rotation = phys::resetRotationOf(Mat34::rotationY(-std::atan2(f.x, -f.z))); + const float rotation = phys::resetRotationOf(Mat34::rotationY(-libm::atan2(f.x, -f.z))); Vec3 at = p + r * best; if (world) { phys::RayHit hit; diff --git a/src/ai/DrivingRoute.cpp b/src/ai/DrivingRoute.cpp index d9c93d6..d2ea892 100644 --- a/src/ai/DrivingRoute.cpp +++ b/src/ai/DrivingRoute.cpp @@ -9,6 +9,7 @@ #include "ai/PathGeometry.h" #include "ai/Pedestrians.h" #include "ai/Traffic.h" +#include "core/Libm.h" #include "phys/vehicle/CarSim.h" #include @@ -34,7 +35,7 @@ float angleBetween(const Vec3& a, const Vec3& b) { return 0.0f; if (c < -1.0f) return 3.1415927f; - return std::acos(c); + return libm::acos(c); } int wayPoint(const std::vector& wp, int i) { @@ -1013,7 +1014,7 @@ int PhysicsDriver::calcObstacleAvoidPoints(const TrackedCar& obstacle, int i, bo lon = d.x * z.x + d.z * z.z; } } - const float a = std::atan2(lat, lon); + const float a = libm::atan2(lat, lon); return a < 1.57f && a > -1.57f; }; if (ahead(left, false)) @@ -1092,7 +1093,7 @@ void PhysicsDriver::enumTargets(const Vec3& pt, const TrackedCar& obstacle, int const Vec3 d = q - prev.pos; const float lat = fwd ? -(d.x * x.x + d.z * x.z) : d.x * x.x + d.z * x.z; const float lon = fwd ? -(d.x * z.x + d.z * z.z) : d.x * z.x + d.z * z.z; - const float a = std::atan2(lat, lon); + const float a = libm::atan2(lat, lon); return a < 1.57f && a > -1.57f; }; // The gap before a further ambient car more than 15 m on, nearer the diff --git a/src/ai/DrivingTargets.cpp b/src/ai/DrivingTargets.cpp index 64ef8f2..1e48c64 100644 --- a/src/ai/DrivingTargets.cpp +++ b/src/ai/DrivingTargets.cpp @@ -8,6 +8,7 @@ #include "ai/MapView.h" #include "ai/PathGeometry.h" #include "ai/Traffic.h" +#include "core/Libm.h" #include "phys/vehicle/CarSim.h" #include @@ -74,11 +75,11 @@ void PhysicsDriver::initRoadTurns() { if (m_roadDir[w]) { const Vec3& x = at(a->xAxis, n - 1); const Vec3& z = at(a->zAxis, n - 1); - angle = std::atan2(d.z * -x.z + d.x * -x.x, d.z * -z.z + d.x * -z.x); + angle = libm::atan2(d.z * -x.z + d.x * -x.x, d.z * -z.z + d.x * -z.x); } else { const Vec3& x = at(a->xAxis, 0); const Vec3& z = at(a->zAxis, 0); - angle = std::atan2(d.z * x.z + d.x * x.x, d.z * z.z + d.x * z.x); + angle = libm::atan2(d.z * x.z + d.x * x.x, d.z * z.z + d.x * z.x); } m_turnAngle[w] = angle; m_turnDir[w] = angle >= 0.7f ? 1.0f : (angle <= -0.7f ? -1.0f : 0.0f); @@ -94,9 +95,9 @@ void PhysicsDriver::calcRoadTurns() { continue; const float d = calcTurnIntersection(w); const float h = (3.14f - std::abs(m_turnAngle[w])) * 0.5f; - const float r = d / (1.0f - std::sin(h)); + const float r = d / (1.0f - libm::sin(h)); m_turnRadius[w] = r; - const float t = std::cos(h) * r; + const float t = libm::cos(h) * r; m_turnSetback[w] = t; const float k = (r - d) * m_turnDir[w]; const int n = static_cast(a->center.size()); @@ -118,8 +119,8 @@ void PhysicsDriver::calcRoadTurns() { m_turnStartDir[w] = unitXZ( {(left.x * rd + c.x) - c.x, (left.y * rd + c.y) - c.y, (left.z * rd + c.z) - c.z}); const float e = m_turnAngle[w] * m_turnDir[w]; - const float s = std::sin(e) * r; - const float cs = std::cos(e) * r * m_turnDir[w]; + const float s = libm::sin(e) * r; + const float cs = libm::cos(e) * r * m_turnDir[w]; const Vec3 q{(c.x - back.x * s) + left.x * cs, (c.y - back.y * s) + left.y * cs, (c.z - back.z * s) + left.z * cs}; m_turnEndDir[w] = unitXZ(q - c); @@ -429,8 +430,8 @@ int PhysicsDriver::calcSharpTurnTarget(int& idx, int turnNode) { const Vec3& z = at(p->zAxis, k); const Vec3& c = st.center; auto arcPoint = [&](float a) { - const float sr = std::sin(a) * r; - const float cr = std::cos(a) * r * dir; + const float sr = libm::sin(a) * r; + const float cr = libm::cos(a) * r * dir; return Vec3{c.x - z.x * sr + x.x * cr, c.y, c.z - z.z * sr + x.z * cr}; }; Vec3 p0; @@ -441,7 +442,7 @@ int PhysicsDriver::calcSharpTurnTarget(int& idx, int turnNode) { const Vec3 u = normalized(p0 - c); const Vec3 pp = prevPos(); const float dx = pp.x - c.x, dz = pp.z - c.z; - float theta = std::atan2(u.x * dz - u.z * dx, u.x * dx + u.z * dz); + float theta = libm::atan2(u.x * dz - u.z * dx, u.x * dx + u.z * dz); if ((dir < 0.0f && rd) || (dir > 0.0f && !rd)) theta = -theta; if (theta < -0.1f) { @@ -494,7 +495,7 @@ int PhysicsDriver::calcSharpTurnTarget(int& idx, int turnNode) { const Vec3& sr = m_turnStartDir[w]; const Vec3 pp = prevPos(); const float dx = pp.x - c.x, dz = pp.z - c.z; - float theta = std::atan2(sr.x * dz - sr.z * dx, dx * sr.x + dz * sr.z); + float theta = libm::atan2(sr.x * dz - sr.z * dx, dx * sr.x + dz * sr.z); if (dir < 0.0f) theta = -theta; if (theta < -0.1f) { @@ -512,8 +513,8 @@ int PhysicsDriver::calcSharpTurnTarget(int& idx, int turnNode) { continue; node = &m_nodes[static_cast(idx)]; const float ai = static_cast(i) * step; - const float s = std::sin(ai) * r; - const float cc = std::cos(ai) * r * dir; + const float s = libm::sin(ai) * r; + const float cc = libm::cos(ai) * r * dir; node->pos = {(c.x - fwd.x * s) + left.x * cc, (c.y - fwd.y * s) + left.y * cc, (c.z - fwd.z * s) + left.z * cc}; saveTurnTarget(idx, false); @@ -653,7 +654,7 @@ void PhysicsDriver::calcRoadTarget(int i, Vec3& from) { lat = latValue; if (clampLon && lon < 1.0f) lon = 1.0f; - return std::atan2(lat, lon); + return libm::atan2(lat, lon); }; // The funnel at the start vertex. @@ -945,7 +946,7 @@ void PhysicsDriver::calcDestinationTarget(int i, Vec3& viewOrigin) { } if (b < 1.0f && i == 1) b = 1.0f; - angL = std::atan2(a, b); + angL = libm::atan2(a, b); const Vec3 pr = ra.b80(k0) - nrm * (R + 1.0f); d = pr - P; a = dotXZ(d, ra.x(dirIdx)); @@ -956,21 +957,21 @@ void PhysicsDriver::calcDestinationTarget(int i, Vec3& viewOrigin) { } if (b < 1.0f && i == 1) b = 1.0f; - angR = std::atan2(a, b); + angR = libm::atan2(a, b); } else { const Vec3 pl = ra.divided ? ra.c(v) - ra.x(v) * (L + 1.0f) : ra.b80(v) - ra.x(v) * L; Vec3 d = pl - P; float a = -dotXZ(d, ra.x(v)), b = -dotXZ(d, ra.z(v)); if (-0.01f < a && a < 0.01f) a = -1.0f; - angL = std::atan2(a, b); + angL = libm::atan2(a, b); const Vec3 pr = ra.be4(v) + ra.x(v) * R; d = pr - P; a = -dotXZ(d, ra.x(v)); b = -dotXZ(d, ra.z(v)); if (-0.01f < a && a < 0.01f) a = 1.0f; - angR = std::atan2(a, b); + angR = libm::atan2(a, b); } int leftV = v, rightV = v, leftSlot = r, rightSlot = r; bool found = false; @@ -1012,7 +1013,7 @@ void PhysicsDriver::calcDestinationTarget(int i, Vec3& viewOrigin) { a = -1.0f; if (!fwd && b < 1.0f) b = 1.0f; - const float al = std::atan2(a, b); + const float al = libm::atan2(a, b); if (al > angR) { // Crossed to the right: the right limit's curb point. found = true; @@ -1044,7 +1045,7 @@ void PhysicsDriver::calcDestinationTarget(int i, Vec3& viewOrigin) { a = 1.0f; if (!fwd && b < 1.0f) b = 1.0f; - const float ar = std::atan2(a, b); + const float ar = libm::atan2(a, b); if (ar < angL) { // Crossed to the left: the left limit's curb point. found = true; diff --git a/src/ai/Opponent.cpp b/src/ai/Opponent.cpp index 854509b..256c615 100644 --- a/src/ai/Opponent.cpp +++ b/src/ai/Opponent.cpp @@ -6,6 +6,7 @@ #include "ai/MapView.h" #include "ai/Traffic.h" +#include "core/Libm.h" #include "phys/vehicle/CarSim.h" #include @@ -164,8 +165,8 @@ void Opponent::setFinishLine(const Vec3& point, float headingDeg) { // aiMap::SetWaypoints: the point, and the z axis (0, 0, 1) turned by // RotateY(heading x -0.017453292). const float a = headingDeg * -0.017453292f; - const float s = std::sin(a); - const float c = std::cos(a); + const float s = libm::sin(a); + const float c = libm::cos(a); m_finishPoint = point; m_finishNormal = {s * 1.0f + c * 0.0f, 0.0f, c * 1.0f - s * 0.0f}; m_hasFinishLine = true; diff --git a/src/ai/PathGeometry.cpp b/src/ai/PathGeometry.cpp index 4c976d7..005e248 100644 --- a/src/ai/PathGeometry.cpp +++ b/src/ai/PathGeometry.cpp @@ -1,5 +1,7 @@ #include "ai/PathGeometry.h" +#include "core/Libm.h" + #include #include #include @@ -17,7 +19,7 @@ float sectionAngle(const city::AiPath& p, int v) { const float dz = p.center[i + 1].z - p.center[i].z; const Vec3& x = p.xAxis[i]; const Vec3& z = p.zAxis[i]; - return std::atan2(-x.x * dx + -x.z * dz, -z.x * dx + -z.z * dz); + return libm::atan2(-x.x * dx + -x.z * dz, -z.x * dx + -z.z * dz); } Vec3 unitXZ(Vec3 v) { @@ -281,8 +283,8 @@ void calcRoadTurns(const city::AiPath& p, std::span turns, const Vec3 } else { room = 3.0f; } - t.radius = room / (1.0f - std::sin(half)); - t.setback = std::cos(half) * t.radius; + t.radius = room / (1.0f - libm::sin(half)); + t.setback = libm::cos(half) * t.radius; const Vec3& x = p.xAxis[iv]; const Vec3& z = p.zAxis[iv]; const float across = (t.radius - room) * t.dir; @@ -293,8 +295,8 @@ void calcRoadTurns(const city::AiPath& p, std::span turns, const Vec3 t.startDir = unitXZ({(x.x * r + t.center.x) - t.center.x, (x.y * r + t.center.y) - t.center.y, (x.z * r + t.center.z) - t.center.z}); const float a = t.angle * t.dir; - const float s = std::sin(a) * t.radius; - const float c = std::cos(a) * t.radius * t.dir; + const float s = libm::sin(a) * t.radius; + const float c = libm::cos(a) * t.radius * t.dir; t.endDir = unitXZ({((t.center.x - z.x * s) + c * x.x) - t.center.x, ((t.center.y - z.y * s) + c * x.y) - t.center.y, ((t.center.z - z.z * s) + c * x.z) - t.center.z}); diff --git a/src/ai/Pedestrians.cpp b/src/ai/Pedestrians.cpp index da979c7..22fbf6a 100644 --- a/src/ai/Pedestrians.cpp +++ b/src/ai/Pedestrians.cpp @@ -3,6 +3,7 @@ // Also: aiPedestrian::GetRoadToLeft (setNextRoad). #include "ai/Pedestrians.h" +#include "core/Libm.h" #include "core/StringUtil.h" #include @@ -30,7 +31,7 @@ Vec3 scaledUnit(const Vec3& v, float m2) { // m0 = (-cos h, 0, sin h), m1 = up, m2 = (-sin h, 0, -cos h), so it faces // (sin h, 0, cos h). Mat34 frameOf(float h, const Vec3& pos) { - const float c = std::cos(h), s = std::sin(h); + const float c = libm::cos(h), s = libm::sin(h); Mat34 m; m.m0 = {-c, 0.0f, s}; m.m1 = {0.0f, 1.0f, 0.0f}; @@ -281,7 +282,7 @@ float Pedestrians::getHeading(int path, float dist, int row, int dir) const { if (dist <= cum[static_cast(i)]) { const Vec3& a = w.points[static_cast(i - 1)]; const Vec3& b = w.points[static_cast(i)]; - return dir == 1 ? std::atan2(b.x - a.x, b.z - a.z) : std::atan2(a.x - b.x, a.z - b.z); + return dir == 1 ? libm::atan2(b.x - a.x, b.z - a.z) : libm::atan2(a.x - b.x, a.z - b.z); } } return 0.0f; @@ -607,7 +608,7 @@ void Pedestrians::solveRoadSegment(Ped& p, float dist) { void Pedestrians::steer(Ped& p, const Vec3& target) { const Mat34 m = frameOf(p.frameHeading, p.position); const float dz = target.z - p.position.z, dx = target.x - p.position.x; - const float angle = std::atan2(dz * m.m0.z + dx * m.m0.x, -(dz * m.m2.z + dx * m.m2.x)); + const float angle = libm::atan2(dz * m.m0.z + dx * m.m0.x, -(dz * m.m2.z + dx * m.m2.x)); p.heading = turnTowards(p.heading, angle); } @@ -625,7 +626,7 @@ void Pedestrians::reset(int idx, int path, int side) { const int lanes = static_cast(w.rowCum.size()) - 1; const float dist = m_rng->frand() * subLength(path, side, 0, sections(path) - 1); p.dist = dist; - p.lateral = ((w.outer - w.inner) * 0.5f - 0.5f) * std::sin(m_rng->frand() * 6.2831f); + p.lateral = ((w.outer - w.inner) * 0.5f - 0.5f) * libm::sin(m_rng->frand() * 6.2831f); p.idx = sidewalkIndex(path, side, dist); p.dir = p.prevDir = m_rng->frand() < 0.5f ? 1 : -1; // (MM2 turns the old heading round here for direction +1; it is @@ -870,7 +871,7 @@ float bangerBlockingDistance(const Vec3& origin, float yRadius, const Vec3& from const float lateral = ox * nx + nz * oz; const float along = ox * d.x + oz * d.z; const float r = (std::min(yRadius, 2.0f) + width * 0.5f) + 1.0f; // aiBanger::Radius - const float angle = std::atan2(lateral, along); + const float angle = libm::atan2(lateral, along); if (-r < lateral && lateral < r && 0.0f < along && along < span + reach && -0.7f < angle && angle < 0.7f) return along; return -1.0f; @@ -1023,11 +1024,11 @@ void Pedestrians::backupAt(Ped& p) { if (p.side == 1) { p.position.x = x.x * 0.2f + p.wallHit.x; p.position.z = x.z * 0.2f + p.wallHit.z; - p.heading = std::atan2(x.x, x.z); + p.heading = libm::atan2(x.x, x.z); } else { p.position.x = p.wallHit.x - x.x * 0.2f; p.position.z = p.wallHit.z - x.z * 0.2f; - p.heading = std::atan2(-x.x, -x.z); + p.heading = libm::atan2(-x.x, -x.z); } startSeq(p, seqs(p).backup); } @@ -1086,7 +1087,7 @@ void Pedestrians::avoidObstacle(Ped& p, const Vec3& obstacle, float radius) { p.target = d * r + obstacle; const Mat34 m = frameOf(p.frameHeading, p.position); const Vec3 t = p.target - p.position; - const float angle = std::atan2((t.z * m.m0.z + t.y * m.m0.y) + t.x * m.m0.x, + const float angle = libm::atan2((t.z * m.m0.z + t.y * m.m0.y) + t.x * m.m0.x, -((t.z * m.m2.z + t.y * m.m2.y) + t.x * m.m2.x)); if (kPedTurnRate < angle) p.heading -= kPedTurnRate; @@ -1153,7 +1154,7 @@ void Pedestrians::anticipate(Ped& p, const PlayerCar& c) { queueSeq(p, s.standAntic); }; auto faceCar = [&] { - p.heading = std::atan2(c.transform.m3.x - p.position.x, c.transform.m3.z - p.position.z); + p.heading = libm::atan2(c.transform.m3.x - p.position.x, c.transform.m3.z - p.position.z); }; if (p.idx == 0 || p.idx == n) { if (entering) { @@ -1169,7 +1170,7 @@ void Pedestrians::anticipate(Ped& p, const PlayerCar& c) { if (p.wall) { if (std::sqrt(sq(p.position.z - p.wallHit.z) + sq(p.position.x - p.wallHit.x)) >= 1.25f) { // To the buildings. - p.heading = p.side == 1 ? std::atan2(-x.x, -x.z) : std::atan2(x.x, x.z); + p.heading = p.side == 1 ? libm::atan2(-x.x, -x.z) : libm::atan2(x.x, x.z); startSeq(p, s.run); } else { backupAt(p); @@ -1232,7 +1233,7 @@ void Pedestrians::avoid(Ped& p, const PlayerCar& c, float& latScale) { p.reversingAtDive = c.reversing; Vec3 a, b; axes(a, b); - p.heading = std::atan2(b.x, b.z); + p.heading = libm::atan2(b.x, b.z); p.sideDist0 = onSegment ? a.x * rel.x + a.y * rel.y + a.z * rel.z : a.y * rel.y + a.z * rel.z + a.x * rel.x; bool right; @@ -1249,12 +1250,12 @@ void Pedestrians::avoid(Ped& p, const PlayerCar& c, float& latScale) { } else if (entering) { const Vec3 x = axisX(p.path, p.idx); if (p.position.dist(p.wallHit) >= 1.25f) { - p.heading = std::atan2(-x.x, -x.z); // as coded, whatever the side + p.heading = libm::atan2(-x.x, -x.z); // as coded, whatever the side p.scream = true; startSeq(p, s.run); } else { // Unlike Anticipate, whatever the side. - p.heading = std::atan2(x.x, x.z); + p.heading = libm::atan2(x.x, x.z); p.position.x = x.x * 0.2f + p.wallHit.x; p.position.z = x.z * 0.2f + p.wallHit.z; startSeq(p, s.backup); @@ -1267,7 +1268,7 @@ void Pedestrians::avoid(Ped& p, const PlayerCar& c, float& latScale) { // probe when nothing was hit). if (p.wall && p.position.dist(p.wallHit) < 1.25f) { const Vec3 x = axisX(p.path, p.idx); - p.heading = std::atan2(x.x, x.z); + p.heading = libm::atan2(x.x, x.z); p.position.x = x.x * 0.2f + p.wallHit.x; p.position.z = x.z * 0.2f + p.wallHit.z; startSeq(p, s.backup); @@ -1289,7 +1290,7 @@ void Pedestrians::avoid(Ped& p, const PlayerCar& c, float& latScale) { } else if (p.frame > 12 && std::abs(now) < std::abs(f * v + p.sideDist0)) { latScale = 3.0f; } - p.heading = std::atan2(b.x, b.z); + p.heading = libm::atan2(b.x, b.z); } // --- Crossing the street ------------------------------------------------------ @@ -1429,7 +1430,7 @@ void Pedestrians::update(int idx, float dt, const PlayerCar& c) { const float r = c.radius; if ((p.position - c.transform.m3).dot(m) < 0.0f && (r + r) * (r + r) < d2) { p.reaction = 0; - p.lateral = std::sin(m_rng->frand() * 6.2831f) * kPedMaxLateral; + p.lateral = libm::sin(m_rng->frand() * 6.2831f) * kPedMaxLateral; calcCurve(p, p.idx - 1, p.idx, p.lateral); } } diff --git a/src/ai/Police.cpp b/src/ai/Police.cpp index 6211a15..32f43b0 100644 --- a/src/ai/Police.cpp +++ b/src/ai/Police.cpp @@ -6,6 +6,7 @@ #include "ai/MapView.h" #include "ai/PathGeometry.h" +#include "core/Libm.h" #include "phys/World.h" #include "phys/vehicle/CarSim.h" @@ -227,7 +228,7 @@ bool PoliceCar::inView(const TrackedCar& c) const { // aiPoliceOfficer::Fov: within 1.57 rad either side of the heading. const Mat34& m = m_car.body.ics.matrix; const Vec3 rel = c.position - m.m3; - const float angle = std::atan2(rel.dot(m.m0), -rel.dot(m.m2)); + const float angle = libm::atan2(rel.dot(m.m0), -rel.dot(m.m2)); return angle > -m_settings.fov && angle < m_settings.fov; } diff --git a/src/ai/Traffic.cpp b/src/ai/Traffic.cpp index 1d1fff2..3f5653c 100644 --- a/src/ai/Traffic.cpp +++ b/src/ai/Traffic.cpp @@ -20,6 +20,7 @@ // aiObstacle::InAccident. #include "ai/Traffic.h" +#include "core/Libm.h" #include "core/StringUtil.h" #include @@ -86,7 +87,7 @@ Mat34 frameFromRows(const Vec3& a, const Vec3& b, const Vec3& c, const Vec3& pos PlayerCar PlayerCar::at(const Vec3& pos, const Vec3& vel) { PlayerCar p; Vec3 f{vel.x, 0.0f, vel.z}; - const float yaw = f.mag2() > 0.01f ? std::atan2(-f.x, -f.z) : 0.0f; + const float yaw = f.mag2() > 0.01f ? libm::atan2(-f.x, -f.z) : 0.0f; // Mat34::rotationY(a) faces (-sin a, 0, -cos a): forward (-Z) along the // velocity (still, it faces -Z). p.transform = Mat34::rotationY(yaw); @@ -134,7 +135,7 @@ void Traffic::init() { if (i >= count) continue; Car& c = m_cars[static_cast(i)]; - c.laneRandomness = std::sin(lane * 6.2831f) * 0.5f; + c.laneRandomness = libm::sin(lane * 6.2831f) * 0.5f; c.totReactTicks = 8 - static_cast(react * -17.0f); } // Then per car: its type, aiVehicleAmbient::Init: aiVehicleSpline::Init @@ -688,7 +689,7 @@ void Traffic::adjustAmbients(int oldRoom, int newRoom, int slot) { for (int l = 0; l < lanes; ++l) { const float space = laneLength(p, dir, l) / static_cast(n + 1); for (int i = 0; i < n && !m_pool.empty(); ++i) { - const float jitter = std::sin(m_rng->frand() * 6.28f); + const float jitter = libm::sin(m_rng->frand() * 6.28f); const float dist = (static_cast(i + 1) * space - space * 0.5f) + (space - 10.0f) * jitter * 0.5f; if (!placeCar(m_pool.back(), p, dir, l, dist)) @@ -1800,7 +1801,7 @@ void Traffic::updateAvoidPlayer(int idx, float dt) { else brakeFor(); c.passOffset = (R + c.rightSide) + 2.5f; - c.heading = std::atan2(c.transform.m2.x, c.transform.m2.z); + c.heading = libm::atan2(c.transform.m2.x, c.transform.m2.z); const float lat = c.transform.m0.dot(p.transform.m3 - c.transform.m3); c.centred = -0.4f <= lat && lat <= 0.4f; } @@ -1818,17 +1819,17 @@ void Traffic::updateAvoidPlayer(int idx, float dt) { c.speed -= 0.5f; beta = 0.4f; } else { - beta = std::atan2(lat + off, fwd); + beta = libm::atan2(lat + off, fwd); } } else if (c.centred) { c.accel -= 10.0f; - beta = lat < 0.0f ? std::atan2(lat + off, fwd) : std::atan2(lat - off, fwd); + beta = lat < 0.0f ? libm::atan2(lat + off, fwd) : libm::atan2(lat - off, fwd); } else if (std::abs(lat) <= 0.4f) { beta = 0.4f; } else if (lat > 0.4f) { - beta = (p.horn || f < 0.7f) ? std::atan2(lat - off, fwd) : std::atan2(lat + off, fwd); + beta = (p.horn || f < 0.7f) ? libm::atan2(lat - off, fwd) : libm::atan2(lat + off, fwd); } else { - beta = (p.horn || f < 0.7f) ? std::atan2(lat + off, fwd) : std::atan2(lat - off, fwd); + beta = (p.horn || f < 0.7f) ? libm::atan2(lat + off, fwd) : libm::atan2(lat - off, fwd); } float ratio = cap > 0.0f ? c.speed / cap : 0.0f; if (c.centred && c.speed > 5.0f) diff --git a/src/app/Controls.cpp b/src/app/Controls.cpp index dba831b..892cb47 100644 --- a/src/app/Controls.cpp +++ b/src/app/Controls.cpp @@ -1,5 +1,7 @@ #include "app/Controls.h" +#include "core/Libm.h" + #include #include #include @@ -516,7 +518,7 @@ Options Options::load(const IniFile& ini) { namespace { float signOf(float v) { return 0.0f < v ? 1.0f : (v < 0.0f ? -1.0f : 0.0f); } float powf32(float base, float exponent) { - return static_cast(std::pow(static_cast(base), static_cast(exponent))); + return static_cast(libm::pow(static_cast(base), static_cast(exponent))); } } // namespace diff --git a/src/city/AiMap.cpp b/src/city/AiMap.cpp index f15a87b..d46a0e3 100644 --- a/src/city/AiMap.cpp +++ b/src/city/AiMap.cpp @@ -2,6 +2,7 @@ #include "city/Reader.h" #include "city/SdlDraw.h" +#include "core/Libm.h" #include #include @@ -201,7 +202,7 @@ void addShortcuts(AiMap& map, std::vector shortcuts, const Psdl* psdl) { if (p.center.empty()) continue; const Vec3& at = p.ends[0].intersection == node ? p.center.back() : p.center.front(); - key[i] = std::atan2(at.x - in.center.x, at.z - in.center.z); + key[i] = libm::atan2(at.x - in.center.x, at.z - in.center.z); } for (std::size_t i = 0; i < n; ++i) { for (std::size_t j = i + 1; j < n; ++j) { diff --git a/src/core/CMakeLists.txt b/src/core/CMakeLists.txt index f88e264..a74befc 100644 --- a/src/core/CMakeLists.txt +++ b/src/core/CMakeLists.txt @@ -6,7 +6,8 @@ add_library(mm2_core STATIC Paths.cpp StringUtil.cpp File.cpp - Math.cpp) + Math.cpp + Libm.cpp) target_include_directories(mm2_core PUBLIC "${PROJECT_SOURCE_DIR}/src" "${CMAKE_CURRENT_BINARY_DIR}/generated") diff --git a/src/core/Libm.cpp b/src/core/Libm.cpp new file mode 100644 index 0000000..4d95bb3 --- /dev/null +++ b/src/core/Libm.cpp @@ -0,0 +1,447 @@ +#include "core/Libm.h" + +// The algorithms are the textbook ones (Cody and Waite's argument reduction, +// Taylor and arctangent series in Horner form, the atanh form of the +// logarithm as in fdlibm); see Libm.h for why OpenMM2 has its own. Every +// operation is written out in the order it must be evaluated: the project +// builds with floating-point contraction off and without fast-math, so the +// compilers keep that order and round each step as IEEE 754 says. + +#include +#include +#include +#include +#include + +namespace mm2::libm { + +namespace { + +// The constants are the nearest doubles to their values (computed with +// 90-digit arithmetic), written in hex so every compiler reads the same bits. + +// pi/2 in three parts. The first two have 33 significant bits, so n times +// either is exact for |n| < 2^20. +constexpr double kPio2Part1 = 0x1.921fb544p+0; +constexpr double kPio2Part2 = 0x1.0b4611a6p-34; +constexpr double kPio2Part3 = 0x1.3198a2e037073p-69; +constexpr double kTwoOverPi = 0x1.45f306dc9c883p-1; +// pi/4, 3 pi/4; pi/2 and pi as the nearest double plus the rest. +constexpr double kPio4 = 0x1.921fb54442d18p-1; +constexpr double k3Pio4 = 0x1.2d97c7f3321d2p+1; +constexpr double kPio2Hi = 0x1.921fb54442d18p+0; +constexpr double kPio2Lo = 0x1.1a62633145c07p-54; +constexpr double kPiHi = 0x1.921fb54442d18p+1; +constexpr double kPiLo = 0x1.1a62633145c07p-53; +// ln 2 in two parts (the first has 42 significant bits, so n times it is +// exact for |n| < 2^11), log2(e) and sqrt(2). +constexpr double kLn2Hi = 0x1.62e42fefa38p-1; +constexpr double kLn2Lo = 0x1.ef35793c7673p-45; +constexpr double kLn2 = 0x1.62e42fefa39efp-1; +constexpr double kLog2e = 0x1.71547652b82fep+0; +constexpr double kSqrt2 = 0x1.6a09e667f3bcdp+0; +// ln(DBL_MAX) and ln of half the smallest subnormal: exp overflows above +// the first and is 0 below the second. +constexpr double kExpMax = 0x1.62e42fefa39efp+9; +constexpr double kExpMin = -0x1.74910d52d3052p+9; + +constexpr double kInf = std::numeric_limits::infinity(); +constexpr double kNaN = std::numeric_limits::quiet_NaN(); + +// sin's Taylor coefficients (-1)^k / (2k+1)! for x^3 .. x^17: on |r| <= pi/4 +// the first term left out, r^19 / 19!, is below 2^-62 of the result. +constexpr double kSin[] = { + -0x1.5555555555555p-3, 0x1.1111111111111p-7, -0x1.a01a01a01a01ap-13, 0x1.71de3a556c734p-19, + -0x1.ae64567f544e4p-26, 0x1.6124613a86d09p-33, -0x1.ae7f3e733b81fp-41, 0x1.952c77030ad4ap-49}; +// cos's, (-1)^k / (2k)! for x^4 .. x^18 (r^20 / 20! is below 2^-67). +constexpr double kCos[] = { + 0x1.5555555555555p-5, -0x1.6c16c16c16c17p-10, 0x1.a01a01a01a01ap-16, -0x1.27e4fb7789f5cp-22, + 0x1.1eed8eff8d898p-29, -0x1.93974a8c07c9dp-37, 0x1.ae7f3e733b81fp-45, -0x1.6827863b97d97p-53}; +// exp's, 1 / k! for x^2 .. x^14: on |r| <= ln(2) / 2, r^15 / 15! is below +// 2^-62. +constexpr double kExp[] = { + 0x1p-1, 0x1.5555555555555p-3, 0x1.5555555555555p-5, 0x1.1111111111111p-7, 0x1.6c16c16c16c17p-10, + 0x1.a01a01a01a01ap-13, 0x1.a01a01a01a01ap-16, 0x1.71de3a556c734p-19, 0x1.27e4fb7789f5cp-22, + 0x1.ae64567f544e4p-26, 0x1.1eed8eff8d898p-29, 0x1.6124613a86d09p-33, 0x1.93974a8c07c9dp-37}; +// atan's, (-1)^k / (2k+1) for x^3 .. x^17: on |u| <= 1/16 the first term +// left out is below 2^-76 of the result. +constexpr double kAtan[] = { + -0x1.5555555555555p-2, 0x1.999999999999ap-3, -0x1.2492492492492p-3, 0x1.c71c71c71c71cp-4, + -0x1.745d1745d1746p-4, 0x1.3b13b13b13b14p-4, -0x1.1111111111111p-4, 0x1.e1e1e1e1e1e1ep-5}; +// atanh's, 2 / (2k+1) for s^2 .. s^22: log(1 + f) = 2 atanh(s) with +// s = f / (2 + f), |s| <= 0.1716; s^24 / 25 is below 2^-65. +constexpr double kLog[] = { + 0x1.5555555555555p-1, 0x1.999999999999ap-2, 0x1.2492492492492p-2, 0x1.c71c71c71c71cp-3, + 0x1.745d1745d1746p-3, 0x1.3b13b13b13b14p-3, 0x1.1111111111111p-3, 0x1.e1e1e1e1e1e1ep-4, + 0x1.af286bca1af28p-4, 0x1.8618618618618p-4, 0x1.642c8590b2164p-4}; + +// atan(k / 8) for k = 0 .. 8 as the nearest double plus the rest. +constexpr double kAtanHi[9] = {0.0, + 0x1.fd5ba9aac2f6ep-4, + 0x1.f5b75f92c80ddp-3, + 0x1.6f61941e4def1p-2, + 0x1.dac670561bb4fp-2, + 0x1.1e00babdefeb4p-1, + 0x1.4978fa3269ee1p-1, + 0x1.700a7c5784634p-1, + 0x1.921fb54442d18p-1}; +constexpr double kAtanLo[9] = {0.0, + -0x1.cd37686760c17p-59, + 0x1.8ab6e3cf7afbdp-57, + -0x1.c63aae6f6e918p-56, + 0x1.a2b7f222f65e2p-56, + -0x1.928df287a668fp-58, + 0x1.2419a87f2a458p-56, + -0x1.8c34d25aadef6p-56, + 0x1.1a62633145c07p-55}; + +bool isNaN(double x) { return x != x; } + +// c[0] + x (c[1] + x (c[2] + ...)), evaluated from the innermost term out. +template +double horner(double x, const double (&c)[N]) { + double p = c[N - 1]; + for (std::size_t i = N - 1; i-- > 0;) + p = c[i] + x * p; + return p; +} + +// 2^k for -1022 <= k <= 1023. +double pow2(int k) { return std::bit_cast(static_cast(k + 1023) << 52); } + +// v * 2^n for v near 1 and -1076 <= n <= 1100: exact unless the result is +// subnormal (then rounded once) or overflows. +double scale2(double v, int n) { + if (n > 1023) { + v *= 0x1p+1023; + n -= 1023; + } + if (n < -1022) + return (v * pow2(n + 54)) * 0x1p-54; + return v * pow2(n); +} + +// The nearest integer to v (halves up), as a double. +double nearest(double v) { return std::floor(v + 0.5); } + +// x = n pi/2 + (hi + lo) with |hi| <= about pi/4 and |lo| below half an ulp +// of hi; `quadrant` is n mod 4. Cody and Waite's reduction: x - n p1 is +// exact, its difference with n p2 is taken with its rounding error (Knuth's +// TwoSum), and n p3 comes off the error. With 99 bits of pi/2 the remainder +// keeps full precision for |x| up to about 2^20, the game's range by far. +struct Reduced { + double hi = 0.0; + double lo = 0.0; + int quadrant = 0; +}; + +Reduced reduce(double x) { + if (std::abs(x) <= kPio4) + return {x, 0.0, 0}; + const double n = nearest(x * kTwoOverPi); + const double r1 = x - n * kPio2Part1; + const double w = n * kPio2Part2; + const double r2 = r1 - w; + const double v = r2 - r1; + const double error = (r1 - (r2 - v)) - (w + v); + const double tail = error - n * kPio2Part3; + Reduced r; + r.hi = r2 + tail; + r.lo = (r2 - r.hi) + tail; + r.quadrant = static_cast(n - 4.0 * std::floor(n * 0.25)); + return r; +} + +// sin(r + lo) for |r| <= about pi/4: r + r^3 P(r^2), plus lo cos(r). +double sinKernel(double r, double lo) { + const double z = r * r; + const double p = z * horner(z, kSin); + return r + (r * p + lo * (1.0 - 0.5 * z)); +} + +// cos(r + lo) for |r| <= about pi/4: 1 - r^2/2 + r^4 Q(r^2), less lo sin(r). +// 1 - r^2/2 rounds; its rounding error, (1 - w) - r^2/2, is added back. +double cosKernel(double r, double lo) { + const double z = r * r; + const double q = (z * z) * horner(z, kCos); + const double hz = 0.5 * z; + const double w = 1.0 - hz; + return w + (((1.0 - w) - hz) + (q - r * lo)); +} + +// atan(t) for 0 <= t <= 1 as hi + lo: atan(c) for c the nearest multiple of +// 1/8, plus atan((t - c) / (1 + t c)) (|u| <= 1/16) by its series. t - c +// is exact. +void atanCore(double t, double& hi, double& lo) { + const int k = static_cast(t * 8.0 + 0.5); + double u = t; + if (k > 0) { + const double c = static_cast(k) * 0.125; + u = (t - c) / (1.0 + t * c); + } + const double z = u * u; + const double p = z * horner(z, kAtan); + hi = kAtanHi[k]; + lo = kAtanLo[k] + (u + u * p); +} + +// c - (hi + lo) for c = cHi + cLo with |cHi| >= |hi|, as a new hi + lo. +void subtractFrom(double cHi, double cLo, double& hi, double& lo) { + const double d = cHi - hi; + const double error = (cHi - d) - hi; + lo = (cLo - lo) - error; + hi = d; +} + +// exp(r) for |r| <= about ln(2) / 2: 1 + r + r^2 P(r). +double expKernel(double r) { + const double p = horner(r, kExp); + return 1.0 + (r + (r * r) * p); +} + +// x > 0 finite as 2^e * m with m in [sqrt(1/2), sqrt(2)); returns log(m). +// fdlibm's form: with f = m - 1 (exact) and s = f / (2 + f), +// log(m) = f - (f^2/2 - s (f^2/2 + R)), R = 2 (s^2/3 + s^4/5 + ...). +double logReduced(double x, int& e) { + e = 0; + if (x < 0x1p-1022) { // subnormal + x *= 0x1p+54; + e = -54; + } + const auto bits = std::bit_cast(x); + e += static_cast((bits >> 52) & 0x7ffu) - 1023; + double m = std::bit_cast((bits & 0x000fffffffffffffu) | 0x3ff0000000000000u); + if (m > kSqrt2) { + m *= 0.5; + ++e; + } + const double f = m - 1.0; + const double s = f / (2.0 + f); + const double z = s * s; + const double hfsq = (0.5 * f) * f; + const double r = z * horner(z, kLog); + return f - (hfsq - s * (hfsq + r)); +} + +// log(x) for x > 0 finite. +double logPositive(double x) { + int e = 0; + const double logm = logReduced(x, e); + const double de = static_cast(e); + return de * kLn2Hi + (de * kLn2Lo + logm); +} + +} // namespace + +double sin(double x) { + if (isNaN(x)) + return x; + if (std::abs(x) == kInf) + return kNaN; + if (std::abs(x) < 0x1p-27) // x^3/6 is below half an ulp + return x; + const Reduced r = reduce(x); + switch (r.quadrant) { + case 0: + return sinKernel(r.hi, r.lo); + case 1: + return cosKernel(r.hi, r.lo); + case 2: + return -sinKernel(r.hi, r.lo); + default: + return -cosKernel(r.hi, r.lo); + } +} + +double cos(double x) { + if (isNaN(x)) + return x; + if (std::abs(x) == kInf) + return kNaN; + if (std::abs(x) < 0x1p-27) // x^2/2 is below half an ulp of 1 + return 1.0; + const Reduced r = reduce(x); + switch (r.quadrant) { + case 0: + return cosKernel(r.hi, r.lo); + case 1: + return -sinKernel(r.hi, r.lo); + case 2: + return -cosKernel(r.hi, r.lo); + default: + return sinKernel(r.hi, r.lo); + } +} + +double tan(double x) { + if (isNaN(x)) + return x; + if (std::abs(x) == kInf) + return kNaN; + if (std::abs(x) < 0x1p-27) + return x; + const Reduced r = reduce(x); + const double s = sinKernel(r.hi, r.lo); + const double c = cosKernel(r.hi, r.lo); + return (r.quadrant & 1) != 0 ? -c / s : s / c; +} + +double atan(double x) { + if (isNaN(x)) + return x; + const double ax = std::abs(x); + double hi = 0.0, lo = 0.0; + if (ax <= 1.0) { + atanCore(ax, hi, lo); + } else { + // pi/2 - atan(1/x); 1/inf is 0 + atanCore(1.0 / ax, hi, lo); + subtractFrom(kPio2Hi, kPio2Lo, hi, lo); + } + const double a = hi + lo; + return std::signbit(x) ? -a : a; +} + +double atan2(double y, double x) { + if (isNaN(x) || isNaN(y)) + return x + y; + const bool negative = std::signbit(y); + const double ax = std::abs(x), ay = std::abs(y); + double a = 0.0; // the angle of (x, |y|), 0 .. pi + if (ay == 0.0) { + // atan2(+-0, x): +-0 for x > 0 or +0, +-pi for x < 0 or -0 + a = std::signbit(x) ? kPiHi : 0.0; + } else if (ax == 0.0) { + a = kPio2Hi; + } else if (ay == kInf) { + a = ax == kInf ? (x > 0.0 ? kPio4 : k3Pio4) : kPio2Hi; + } else if (ax == kInf) { + a = x > 0.0 ? 0.0 : kPiHi; + } else { + double hi = 0.0, lo = 0.0; + if (ay <= ax) { + atanCore(ay / ax, hi, lo); + } else { + atanCore(ax / ay, hi, lo); + subtractFrom(kPio2Hi, kPio2Lo, hi, lo); + } + if (x < 0.0) + subtractFrom(kPiHi, kPiLo, hi, lo); + a = hi + lo; + } + return negative ? -a : a; +} + +double asin(double x) { + if (isNaN(x)) + return x; + if (std::abs(x) > 1.0) + return kNaN; + // (1 - x)(1 + x) loses nothing near |x| = 1, where 1 - x^2 would. + return atan2(x, std::sqrt((1.0 - x) * (1.0 + x))); +} + +double acos(double x) { + if (isNaN(x)) + return x; + if (std::abs(x) > 1.0) + return kNaN; + return atan2(std::sqrt((1.0 - x) * (1.0 + x)), x); +} + +double exp(double x) { + if (isNaN(x)) + return x; + if (x > kExpMax) + return kInf; + if (x < kExpMin) + return 0.0; + if (std::abs(x) < 0x1p-54) + return 1.0 + x; + // x = n ln2 + r, |r| <= ln(2)/2; x - n ln2hi is exact. + const double n = nearest(x * kLog2e); + const double r = (x - n * kLn2Hi) - n * kLn2Lo; + return scale2(expKernel(r), static_cast(n)); +} + +double exp2(double x) { + if (isNaN(x)) + return x; + if (x >= 1024.0) + return kInf; + if (x < -1075.0) + return 0.0; + // x = n + f, |f| <= 1/2 (exact): 2^f = exp(f ln 2). Integers are exact. + const double n = nearest(x); + const double f = x - n; + return scale2(expKernel(f * kLn2), static_cast(n)); +} + +double log(double x) { + if (isNaN(x)) + return x; + if (x < 0.0) + return kNaN; + if (x == 0.0) + return -kInf; + if (x == kInf) + return kInf; + return logPositive(x); +} + +double log2(double x) { + if (isNaN(x)) + return x; + if (x < 0.0) + return kNaN; + if (x == 0.0) + return -kInf; + if (x == kInf) + return kInf; + // e + log(m) / ln 2: exact for powers of two. + int e = 0; + const double logm = logReduced(x, e); + return static_cast(e) + logm * kLog2e; +} + +double pow(double x, double y) { + if (y == 0.0 || x == 1.0) + return 1.0; + if (isNaN(x) || isNaN(y)) + return x + y; + const double ax = std::abs(x); + if (std::abs(y) == kInf) { + if (ax == 1.0) + return 1.0; + return (ax < 1.0) == (y > 0.0) ? 0.0 : kInf; + } + const bool integer = std::floor(y) == y; + const bool odd = integer && std::abs(y) < 0x1p+53 && std::floor(y * 0.5) * 2.0 != y; + if (x == 0.0 || ax == kInf) { + // pow(+-0, y) and pow(+-inf, y): 0 or inf, signed for odd y + const double r = (x == 0.0) == (y > 0.0) ? 0.0 : kInf; + return odd && std::signbit(x) ? -r : r; + } + if (x < 0.0 && !integer) + return kNaN; + const double sign = x < 0.0 && odd ? -1.0 : 1.0; + // The simple powers exactly or correctly rounded. + if (y == 1.0) + return x; + if (y == 2.0) + return x * x; + if (y == -1.0) + return 1.0 / x; + if (y == 0.5) + return std::sqrt(x); + return sign * exp(y * logPositive(ax)); +} + +float hypot(float x, float y) { + if (std::isinf(x) || std::isinf(y)) + return std::numeric_limits::infinity(); + const double dx = x, dy = y; + return static_cast(std::sqrt(dx * dx + dy * dy)); +} + +} // namespace mm2::libm diff --git a/src/core/Libm.h b/src/core/Libm.h new file mode 100644 index 0000000..1908127 --- /dev/null +++ b/src/core/Libm.h @@ -0,0 +1,64 @@ +#pragma once + +// OpenMM2's own elementary functions for everything a host and its clients +// both simulate (docs/physics.md, "The same results on every platform"). +// +// The C runtimes round sin, cos, atan2, exp, log, pow and the rest +// differently in the last bit: glibc, the MSVC runtime and MinGW's (or +// Wine's) each have their own implementations. A network game whose host +// and client were built against different runtimes then simulated the same +// sample differently, and every difference became a correction (the +// players' cars record's O6). These functions use only the basic +// operations (+ - * /, which IEEE 754 rounds the same way everywhere when +// the compiler neither fuses nor reorders them: cmake/CompilerOptions.cmake) +// and the exact ones (sqrt, floor, the exponent bits), so they give the same +// bits on every platform and compiler. +// +// Accuracy. The double versions are within about one unit in the last place. +// The float versions are the double result rounded to float: that is what +// MM2's code got from the x87, whose fsin, fcos, fpatan, fyl2x and f2xm1 +// work in extended precision whatever the precision control, the result +// being stored to a float. They are correctly rounded except in the rare +// cases a double result lies within its error of a float's rounding +// boundary, which no test of the game's ranges met (docs/physics.md). +// +// Not here, because IEEE 754 makes their results exact (or correctly +// rounded) everywhere: std::sqrt, std::fmod, std::remainder, std::floor, +// std::ceil, std::trunc, std::round / std::lround, std::abs, std::copysign. + +namespace mm2::libm { + +double sin(double x); +double cos(double x); +double tan(double x); +double asin(double x); +double acos(double x); +double atan(double x); +double atan2(double y, double x); +double exp(double x); +double exp2(double x); +double log(double x); +double log2(double x); +double pow(double x, double y); + +inline float sin(float x) { return static_cast(sin(static_cast(x))); } +inline float cos(float x) { return static_cast(cos(static_cast(x))); } +inline float tan(float x) { return static_cast(tan(static_cast(x))); } +inline float asin(float x) { return static_cast(asin(static_cast(x))); } +inline float acos(float x) { return static_cast(acos(static_cast(x))); } +inline float atan(float x) { return static_cast(atan(static_cast(x))); } +inline float atan2(float y, float x) { + return static_cast(atan2(static_cast(y), static_cast(x))); +} +inline float exp(float x) { return static_cast(exp(static_cast(x))); } +inline float exp2(float x) { return static_cast(exp2(static_cast(x))); } +inline float log(float x) { return static_cast(log(static_cast(x))); } +inline float log2(float x) { return static_cast(log2(static_cast(x))); } +inline float pow(float x, float y) { + return static_cast(pow(static_cast(x), static_cast(y))); +} +// sqrt(x^2 + y^2): the squares are exact in double, so this is the sum +// rounded once and its square root rounded twice (to double, then float). +float hypot(float x, float y); + +} // namespace mm2::libm diff --git a/src/core/Math.cpp b/src/core/Math.cpp index a51680c..abef639 100644 --- a/src/core/Math.cpp +++ b/src/core/Math.cpp @@ -1,25 +1,18 @@ #include "core/Math.h" +#include "core/Libm.h" + namespace mm2 { // Rotation matrices are the transposes of the usual column-vector forms // because of the row-vector convention; positive angles rotate // counter-clockwise when looking down the axis toward the origin. -namespace { - -// cos and sin as the x87 computes them (wide), rounded to float. -float cosf32(float a) { - return static_cast(std::cos(static_cast(a))); -} -float sinf32(float a) { - return static_cast(std::sin(static_cast(a))); -} - -} // namespace +// cos and sin are OpenMM2's own (core/Libm.h): as the x87 computes them +// (wide), rounded to float, and the same on every platform. Mat34 Mat34::rotationX(float a) { - const float c = cosf32(a), s = sinf32(a); + const float c = libm::cos(a), s = libm::sin(a); Mat34 m; m.m1 = {0, c, s}; m.m2 = {0, -s, c}; @@ -27,7 +20,7 @@ Mat34 Mat34::rotationX(float a) { } Mat34 Mat34::rotationY(float a) { - const float c = cosf32(a), s = sinf32(a); + const float c = libm::cos(a), s = libm::sin(a); Mat34 m; m.m0 = {c, 0, -s}; m.m2 = {s, 0, c}; @@ -35,7 +28,7 @@ Mat34 Mat34::rotationY(float a) { } Mat34 Mat34::rotationZ(float a) { - const float c = cosf32(a), s = sinf32(a); + const float c = libm::cos(a), s = libm::sin(a); Mat34 m; m.m0 = {c, s, 0}; m.m1 = {-s, c, 0}; @@ -46,8 +39,8 @@ Mat34 Mat34::rotationAxis(const Vec3& k, float angle) { // Matrix34::MakeRotateUnitAxis: the transpose of Rodrigues' rotation. The // diagonal adds the cosine as fcos left it (wider than a float); every // product before it rounds to float. - const double c = std::cos(static_cast(angle)); - const float s = sinf32(angle); + const double c = libm::cos(static_cast(angle)); + const float s = libm::sin(angle); const float t = static_cast(1.0 - c); const auto diagonal = [&](float v) { return static_cast(static_cast((v * v) * t) + c); }; Mat34 m; @@ -87,7 +80,7 @@ void Mat34::normalize() { } Mat44 Mat44::perspective(float fovY, float aspect, float zNear, float zFar, bool zeroToOne) { - const float f = 1.0f / std::tan(fovY * 0.5f); + const float f = 1.0f / libm::tan(fovY * 0.5f); Mat44 r; r.m[0][0] = f / aspect; r.m[1][1] = f; @@ -104,7 +97,7 @@ Mat44 Mat44::perspective(float fovY, float aspect, float zNear, float zFar, bool } Mat44 Mat44::perspectiveReversedInfinite(float fovY, float aspect, float zNear) { - const float f = 1.0f / std::tan(fovY * 0.5f); + const float f = 1.0f / libm::tan(fovY * 0.5f); Mat44 r; r.m[0][0] = f / aspect; r.m[1][1] = f; @@ -133,8 +126,8 @@ Mat44 Mat44::orthographic(float left, float right, float bottom, float top, floa } Quat Quat::fromAxisAngle(const Vec3& axis, float angle) { - const float s = std::sin(angle * 0.5f); - return {axis.x * s, axis.y * s, axis.z * s, std::cos(angle * 0.5f)}; + const float s = libm::sin(angle * 0.5f); + return {axis.x * s, axis.y * s, axis.z * s, libm::cos(angle * 0.5f)}; } Quat Quat::fromMatrix(const Mat34& m) { @@ -206,10 +199,10 @@ Quat Quat::slerp(const Quat& a, const Quat& b, float t) { s0 = 1.0f - t; s1 = t; } else { - const float omega = std::acos(cosom); - const float sinom = std::sin(omega); - s0 = std::sin((1.0f - t) * omega) / sinom; - s1 = std::sin(t * omega) / sinom; + const float omega = libm::acos(cosom); + const float sinom = libm::sin(omega); + s0 = libm::sin((1.0f - t) * omega) / sinom; + s1 = libm::sin(t * omega) / sinom; } return Quat{a.x * s0 + end.x * s1, a.y * s0 + end.y * s1, a.z * s0 + end.z * s1, a.w * s0 + end.w * s1} .normalized(); diff --git a/src/game/net/Autopilot.cpp b/src/game/net/Autopilot.cpp index e6f12ea..c0280b4 100644 --- a/src/game/net/Autopilot.cpp +++ b/src/game/net/Autopilot.cpp @@ -3,6 +3,7 @@ #include "ai/Opponent.h" #include "ai/World.h" #include "city/Race.h" +#include "core/Libm.h" #include "core/Log.h" #include "core/StringUtil.h" @@ -119,7 +120,7 @@ NetAutopilot::Controls NetAutopilot::drive(float dt, phys::CarSim& car, bool hel const Mat34& m = car.body.ics.matrix; if (m_target && !held && m.m3.dist2(*m_target) < 80.0f * 80.0f) { const float dx = m_target->x - m.m3.x, dz = m_target->z - m.m3.z; - const float angle = std::atan2(dx * m.m0.x + dz * m.m0.z, -(dx * m.m2.x + dz * m.m2.z)); + const float angle = libm::atan2(dx * m.m0.x + dz * m.m0.z, -(dx * m.m2.x + dz * m.m2.z)); c.steering = std::clamp(angle * 1.33f, -1.0f, 1.0f); const bool behind = std::fabs(angle) > 2.0f; const float speed = -car.body.ics.linearVelocity.dot(m.m2); diff --git a/src/game/net/PlayerCars.cpp b/src/game/net/PlayerCars.cpp index 155c22d..d84a4ba 100644 --- a/src/game/net/PlayerCars.cpp +++ b/src/game/net/PlayerCars.cpp @@ -3,6 +3,7 @@ #include "game/net/PlayerCars.h" +#include "core/Libm.h" #include "game/Interpolation.h" #include @@ -581,7 +582,7 @@ void CorrectionBlend::add(const Mat34& before, const Mat34& after) { void CorrectionBlend::update(float dt) { if (!m_active) return; - const float keep = std::exp2(-std::max(0.0f, dt) / halfLife); + const float keep = libm::exp2(-std::max(0.0f, dt) / halfLife); m_position = m_position * keep; m_rotation = blendTransform(Mat34::identity(), m_rotation, keep); m_rotation.m3 = {}; diff --git a/src/game/net/TrafficPrediction.cpp b/src/game/net/TrafficPrediction.cpp index 296ec25..f349b9c 100644 --- a/src/game/net/TrafficPrediction.cpp +++ b/src/game/net/TrafficPrediction.cpp @@ -2,6 +2,8 @@ // TrafficPrediction.h. #include "game/net/TrafficPrediction.h" +#include "core/Libm.h" + #include #include @@ -11,13 +13,13 @@ namespace { // `v` turned by `angle` about the unit axis `k` (Rodrigues: a physical // rotation, the right-hand way round the axis). Vec3 rotated(const Vec3& v, const Vec3& k, float angle) { - const float c = std::cos(angle), s = std::sin(angle); + const float c = libm::cos(angle), s = libm::sin(angle); return v * c + k.cross(v) * s + k * (k.dot(v) * (1.0f - c)); } // Turned by `angle` about +y (from +z toward +x). Vec3 yawed(const Vec3& v, float angle) { - const float c = std::cos(angle), s = std::sin(angle); + const float c = libm::cos(angle), s = libm::sin(angle); return {v.x * c + v.z * s, v.y, v.z * c - v.x * s}; } @@ -31,12 +33,12 @@ float wrapAngle(float a) { // The chord of an arc of length `s` that turns by `angle`: its length. float chord(float s, float angle) { - return std::abs(angle) > 1e-4f ? s * (2.0f * std::sin(angle * 0.5f) / angle) : s; + return std::abs(angle) > 1e-4f ? s * (2.0f * libm::sin(angle * 0.5f) / angle) : s; } } // namespace -float groundHeading(const Vec3& forward) { return std::atan2(forward.x, forward.z); } +float groundHeading(const Vec3& forward) { return libm::atan2(forward.x, forward.z); } // --- RailMotionTracker -------------------------------------------------------------------- @@ -58,7 +60,7 @@ void RailMotionTracker::update(std::span cars, double time continue; // the AI has not stepped since const auto dt = static_cast(dtMs / 1000.0); last.motion.accel = (c.speed - last.speed) / dt; - const float ds = std::hypot(p.x - last.position.x, p.z - last.position.z); + const float ds = libm::hypot(p.x - last.position.x, p.z - last.position.z); // Over a short move the heading's change is mostly rounding: the // curvature stays as it was. if (ds > 0.05f) diff --git a/src/game/net/TrafficSync.cpp b/src/game/net/TrafficSync.cpp index 8b796e1..bfd9037 100644 --- a/src/game/net/TrafficSync.cpp +++ b/src/game/net/TrafficSync.cpp @@ -3,6 +3,7 @@ // interpolation. See TrafficSync.h and docs/multiplayer.md. #include "game/net/TrafficSync.h" +#include "core/Libm.h" #include "core/StringUtil.h" #include @@ -361,7 +362,7 @@ void TrafficClient::update(double renderTime) { const double elapsed = std::max(0.0, renderTime - entry.updatedAt); entry.updatedAt = renderTime; if (m_options.correctionMs > 0.0) { - const auto keep = static_cast(std::exp(-elapsed / m_options.correctionMs)); + const auto keep = static_cast(libm::exp(-elapsed / m_options.correctionMs)); entry.offset = entry.offset * keep; entry.offsetTurn *= keep; } diff --git a/src/game/session/Gate.cpp b/src/game/session/Gate.cpp index 89aa7a2..9132769 100644 --- a/src/game/session/Gate.cpp +++ b/src/game/session/Gate.cpp @@ -1,5 +1,7 @@ #include "game/session/Gate.h" +#include "core/Libm.h" + #include #include @@ -7,7 +9,7 @@ namespace mm2::game::session { void calculateGatePoints(const Vec3& p, float headingDeg, float radius, Vec2& a, Vec2& b) { const float h = headingDeg * 0.017453292f; - const float c = std::cos(h) * radius, s = std::sin(h) * radius; + const float c = libm::cos(h) * radius, s = libm::sin(h) * radius; a = {c + p.x, s + p.z}; b = {p.x - c, p.z - s}; } diff --git a/src/game/session/RaceSetup.cpp b/src/game/session/RaceSetup.cpp index b234269..72fdc29 100644 --- a/src/game/session/RaceSetup.cpp +++ b/src/game/session/RaceSetup.cpp @@ -2,6 +2,7 @@ #include "ai/Random.h" #include "city/RoomInfo.h" +#include "core/Libm.h" #include "core/Log.h" #include "core/StringUtil.h" #include "game/session/Gate.h" @@ -49,7 +50,7 @@ std::optional raceMode(GameMode m) { // Heading that faces from `from` towards `to`, as mmWaypoints computes it // for waypoints stored with heading 0. float headingTowards(const Vec3& from, const Vec3& to) { - return std::atan2(from.x - to.x, from.z - to.z) * -57.295776f; + return libm::atan2(from.x - to.x, from.z - to.z) * -57.295776f; } } // namespace @@ -88,7 +89,7 @@ std::vector buildCheckpoints(const std::vector& poin Vec3 headingDirection(float headingDeg) { const float h = headingDeg * kDegToRad; - return {std::sin(h), 0.0f, -std::cos(h)}; + return {libm::sin(h), 0.0f, -libm::cos(h)}; } Mat34 spawnAt(const Checkpoint& cp) { diff --git a/src/net/VehicleDamage.h b/src/net/VehicleDamage.h index b89b940..e90ef6d 100644 --- a/src/net/VehicleDamage.h +++ b/src/net/VehicleDamage.h @@ -25,6 +25,7 @@ // still converges on the parts and keeps every patch it did get. A new // `epoch` starts a new record (the car's damage was cleared). +#include "core/Libm.h" #include "net/AmbientState.h" #include "net/Protocol.h" @@ -92,8 +93,8 @@ struct VehicleDamageEvent { std::vector impacts; }; -inline float damageLog(float v) { return std::log2(1.0f + std::max(0.0f, v)); } -inline float damageExp(float v) { return std::exp2(v) - 1.0f; } +inline float damageLog(float v) { return libm::log2(1.0f + std::max(0.0f, v)); } +inline float damageExp(float v) { return libm::exp2(v) - 1.0f; } template bool serialize(S& s, VehicleDamageEvent& e) { diff --git a/src/phys/AgeMath.cpp b/src/phys/AgeMath.cpp index 9c173a3..d50c3a4 100644 --- a/src/phys/AgeMath.cpp +++ b/src/phys/AgeMath.cpp @@ -4,6 +4,8 @@ #include "phys/AgeMath.h" +#include "core/Libm.h" + #include namespace mm2::phys::age { @@ -12,20 +14,20 @@ namespace { // Matrix34::MakeRotateX / MakeRotateY / MakeRotateZ (cos and sin rounded // from the FPU's wide result). Mat34 makeRotateX(float angle) { - const float c = static_cast(std::cos(static_cast(angle))); - const float s = static_cast(std::sin(static_cast(angle))); + const float c = static_cast(libm::cos(static_cast(angle))); + const float s = static_cast(libm::sin(static_cast(angle))); return {{1.0f, 0.0f, 0.0f}, {0.0f, c, s}, {0.0f, -s, c}, {}}; } Mat34 makeRotateY(float angle) { - const float c = static_cast(std::cos(static_cast(angle))); - const float s = static_cast(std::sin(static_cast(angle))); + const float c = static_cast(libm::cos(static_cast(angle))); + const float s = static_cast(libm::sin(static_cast(angle))); return {{c, 0.0f, -s}, {0.0f, 1.0f, 0.0f}, {s, 0.0f, c}, {}}; } Mat34 makeRotateZ(float angle) { - const float c = static_cast(std::cos(static_cast(angle))); - const float s = static_cast(std::sin(static_cast(angle))); + const float c = static_cast(libm::cos(static_cast(angle))); + const float s = static_cast(libm::sin(static_cast(angle))); return {{c, s, 0.0f}, {-s, c, 0.0f}, {0.0f, 0.0f, 1.0f}, {}}; } @@ -53,8 +55,8 @@ Vec3 dot3x3Transpose(const Vec3& v, const Mat34& m) { Mat34 makeRotateUnitAxis(const Vec3& n, float angle) { // The diagonal adds the cosine as fcos left it (wider than a float); the // products before it round to float. - const double c = std::cos(static_cast(angle)); - const float s = static_cast(std::sin(static_cast(angle))); + const double c = libm::cos(static_cast(angle)); + const float s = static_cast(libm::sin(static_cast(angle))); const float omc = static_cast(1.0 - c); const auto diagonal = [&](float k) { const float p = (k * k) * omc; diff --git a/src/phys/TrailerJoint.cpp b/src/phys/TrailerJoint.cpp index 9a2df9b..298e37f 100644 --- a/src/phys/TrailerJoint.cpp +++ b/src/phys/TrailerJoint.cpp @@ -12,6 +12,7 @@ #include "phys/TrailerJoint.h" +#include "core/Libm.h" #include "phys/AgeMath.h" #include "phys/InertialCS.h" #include "phys/vehicle/TuneParams.h" @@ -146,7 +147,7 @@ void TrailerJoint::setPosition(const Vec3& pos) { } void TrailerJoint::setCosFreeLean() { - cosFreeLean = std::cos(freeLean); + cosFreeLean = libm::cos(freeLean); } void TrailerJoint::setRotate1(const Mat34& m) { @@ -407,7 +408,7 @@ void TrailerJoint::update(float dt, float invDt) { } else { // fsin's wide result times 1/m, rounded once. const float s = - static_cast(std::sin(static_cast(halfAngle)) * static_cast(invM)); + static_cast(libm::sin(static_cast(halfAngle)) * static_cast(invM)); factor = s + s; } force = {factor * (turned.x + par.x), factor * (turned.y + par.y), factor * (par.z + turned.z)}; @@ -456,7 +457,7 @@ void TrailerJoint::doJointTorque(const Mat34& a, const Mat34& at, const Mat34& b else if (!(bInA.z > -1.0f)) leanOut = kPi; else - leanOut = static_cast(std::acos(static_cast(bInA.z))); + leanOut = static_cast(libm::acos(static_cast(bInA.z))); if (static_cast(leanOut) > 1e-05) { a0 = {bInA.y, -bInA.x, 0.0f}; const float s = age::invMag(a0); @@ -512,7 +513,7 @@ void TrailerJoint::doJointTorque(const Mat34& a, const Mat34& at, const Mat34& b a0.y * bInA.x - a0.x * bInA.y}; const float num = (av.x * c.x + c.z * av.z) + c.y * av.y; const float den = (av.x * a0.x + av.z * a0.z) + av.y * a0.y; - rollOut = static_cast(std::atan2(static_cast(num), static_cast(den))); + rollOut = static_cast(libm::atan2(static_cast(num), static_cast(den))); const float kInv = 1.0f / (ics2->invInertia.z + ics1->invInertia.z); const float kS2 = kInv * kJointSpring; const float kD2 = kInv * kJointDamp; diff --git a/src/phys/vehicle/CarSim.cpp b/src/phys/vehicle/CarSim.cpp index 5656ec6..3001daf 100644 --- a/src/phys/vehicle/CarSim.cpp +++ b/src/phys/vehicle/CarSim.cpp @@ -5,6 +5,7 @@ #include "phys/vehicle/CarSim.h" +#include "core/Libm.h" #include "phys/AgeMath.h" #include @@ -184,7 +185,7 @@ void CarSim::reset(const Mat34& model) { float resetRotationOf(const Mat34& spawn) { // Mat34::rotationY(a) has m2 = (sin a, 0, cos a). - return std::atan2(spawn.m2.x, spawn.m2.z); + return libm::atan2(spawn.m2.x, spawn.m2.z); } void CarSim::setResetPos(const Vec3& position) { diff --git a/src/phys/vehicle/Controls.cpp b/src/phys/vehicle/Controls.cpp index 1bfb84f..02c5f85 100644 --- a/src/phys/vehicle/Controls.cpp +++ b/src/phys/vehicle/Controls.cpp @@ -4,6 +4,7 @@ #include "phys/vehicle/Controls.h" +#include "core/Libm.h" #include "phys/vehicle/CarSim.h" #include @@ -75,8 +76,8 @@ float SteeringFilter::filter(float target, float dt) { if (target < m_position) m_position = target; } - const float curve = - static_cast(std::pow(static_cast(std::abs(m_position)), static_cast(m_exponent))); + const double base = std::abs(m_position); + const float curve = static_cast(libm::pow(base, static_cast(m_exponent))); return curve * sign(m_position); } diff --git a/src/phys/vehicle/Trailer.cpp b/src/phys/vehicle/Trailer.cpp index fea8dce..31f2500 100644 --- a/src/phys/vehicle/Trailer.cpp +++ b/src/phys/vehicle/Trailer.cpp @@ -6,6 +6,7 @@ #include "phys/vehicle/Trailer.h" +#include "core/Libm.h" #include "phys/vehicle/CarSim.h" #include @@ -258,7 +259,7 @@ void Trailer::setTrailerHitchOffset() { float Trailer::hitchAngle() const { const Mat34& t = m_tractor->body.ics.matrix; const Vec3& back = body.ics.matrix.m2; - return std::atan2(back.dot(t.m0), back.dot(t.m2)); + return libm::atan2(back.dot(t.m0), back.dot(t.m2)); } void Trailer::beforeIntegrate(Body&, float, const World&) { diff --git a/src/phys/vehicle/Transmission.cpp b/src/phys/vehicle/Transmission.cpp index 93f68d1..87d370a 100644 --- a/src/phys/vehicle/Transmission.cpp +++ b/src/phys/vehicle/Transmission.cpp @@ -4,6 +4,7 @@ #include "phys/vehicle/Transmission.h" +#include "core/Libm.h" #include "phys/vehicle/Engine.h" #include @@ -31,13 +32,13 @@ void fillRatios(std::array& r, int n, const Transmi if (2 < forward) { const int m = forward - 1; const float fm = static_cast(m); - const float q = static_cast( - std::pow(static_cast(r[static_cast(n - 1)] / r[2]), static_cast(1.0f / fm))); + const double top = r[static_cast(n - 1)] / r[2]; + const float q = static_cast(libm::pow(top, static_cast(1.0f / fm))); for (int i = 1; i < m; ++i) { const float fi = static_cast(i); const float e = fi - ((static_cast(i - m) * fi) * p.gearBias) / fm; r[static_cast(2 + i)] = - static_cast(std::pow(static_cast(q), static_cast(e))) * r[2]; + static_cast(libm::pow(static_cast(q), static_cast(e))) * r[2]; } } } diff --git a/src/phys/vehicle/Wheel.cpp b/src/phys/vehicle/Wheel.cpp index 388cf23..aaece98 100644 --- a/src/phys/vehicle/Wheel.cpp +++ b/src/phys/vehicle/Wheel.cpp @@ -6,6 +6,7 @@ #include "phys/vehicle/Wheel.h" +#include "core/Libm.h" #include "phys/AgeMath.h" #include "phys/Impact.h" #include "phys/InertialCS.h" @@ -31,10 +32,12 @@ float signOf(float v) { std::uint32_t g_privateSeed = 1; -// Matrix34::MakeRotateY (3x3 only). +// Matrix34::MakeRotateY (3x3 only): fcos and fsin rounded to float, as +// phys::age's (core/Libm.h; the C runtime's cosf differed in the last bit +// for about 0.2 % of angles, and between platforms). void makeRotateY(Mat34& m, float a) { - const float c = std::cos(a); - const float s = std::sin(a); + const float c = libm::cos(a); + const float s = libm::sin(a); m.m0 = {c, 0.0f, -s}; m.m1 = {0.0f, 1.0f, 0.0f}; m.m2 = {s, 0.0f, c}; @@ -255,7 +258,7 @@ float Wheel::bumpDisplacement(float speed, float dt, std::uint32_t* seed) { return 0.0f; bumpPhase = (physFrand(seed ? *seed : g_privateSeed) + 0.618f) * dt * speed + bumpPhase; bumpPhase = std::fmod(bumpPhase, material->width); - const float b = std::sin((bumpPhase * 6.2831855f) / material->width) * material->height; + const float b = libm::sin((bumpPhase * 6.2831855f) / material->width) * material->height; return speed < 1.0f ? b * speed : b; } diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 3a1622b..186355a 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -16,5 +16,10 @@ foreach(module IN LISTS OPENMM2_MODULES) add_executable(test_${module} ${sources} "${CMAKE_CURRENT_SOURCE_DIR}/TestData.cpp") target_include_directories(test_${module} PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}") target_link_libraries(test_${module} PRIVATE mm2_${module} OpenMM2::vfs GTest::gtest_main) + if(module STREQUAL "core") + # core/test_libm reads the sources: the simulation calls no C runtime + # transcendental function. + target_compile_definitions(test_core PRIVATE OPENMM2_SOURCE_DIR="${PROJECT_SOURCE_DIR}") + endif() gtest_discover_tests(test_${module} DISCOVERY_MODE PRE_TEST) endforeach() diff --git a/tests/core/test_libm.cpp b/tests/core/test_libm.cpp new file mode 100644 index 0000000..b09a5be --- /dev/null +++ b/tests/core/test_libm.cpp @@ -0,0 +1,372 @@ +// OpenMM2's own elementary functions (core/Libm.h) give the same bits on +// every platform, and the float versions are the correctly rounded values. +// The expected values are the platform-independent ones; a build where they +// differ simulates differently from every other (docs/physics.md, "The same +// results on every platform"). CI runs these on GCC, Clang, MinGW (Wine) +// and MSVC. +#include "core/Libm.h" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace mm2; + +namespace { + +struct FloatCase { + const char* fn; + float a; // the argument (y for atan2, the base for pow) + float b; // the second argument (x for atan2, the exponent for pow) + float expected; // the correctly rounded result +}; + +float evaluate(const FloatCase& c) { + const std::string fn = c.fn; + if (fn == "sin") + return libm::sin(c.a); + if (fn == "cos") + return libm::cos(c.a); + if (fn == "tan") + return libm::tan(c.a); + if (fn == "asin") + return libm::asin(c.a); + if (fn == "acos") + return libm::acos(c.a); + if (fn == "atan") + return libm::atan(c.a); + if (fn == "exp") + return libm::exp(c.a); + if (fn == "exp2") + return libm::exp2(c.a); + if (fn == "log") + return libm::log(c.a); + if (fn == "log2") + return libm::log2(c.a); + if (fn == "atan2") + return libm::atan2(c.a, c.b); + if (fn == "pow") + return libm::pow(c.a, c.b); + if (fn == "hypot") + return libm::hypot(c.a, c.b); + ADD_FAILURE() << "unknown function " << fn; + return 0.0f; +} + +// Each checked against glibc's long double functions rounded to float. +constexpr FloatCase kFloatCases[] = { + {"sin", 0x1p-1f, 0, 0x1.eaee88p-2f}, + {"sin", -0x1p-1f, 0, -0x1.eaee88p-2f}, + {"sin", 0x1p+0f, 0, 0x1.aed548p-1f}, + {"sin", 0x1.921fb6p+0f, 0, 0x1p+0f}, + {"sin", 0x1.921fb6p+1f, 0, -0x1.777a5cp-24f}, + {"sin", 0x1.921fb6p+2f, 0, 0x1.777a5cp-23f}, + {"sin", 0x1.930bep-7f, 0, 0x1.930946p-7f}, + {"sin", 0x1.9p+6f, 0, -0x1.03425cp-1f}, + {"sin", -0x1.6p+1f, 0, -0x1.86d224p-2f}, + {"sin", 0x1.4484cp-100f, 0, 0x1.4484cp-100f}, + {"sin", 0x1.333334p+13f, 0, -0x1.65d4ap-2f}, + {"cos", 0x1p-1f, 0, 0x1.c1528p-1f}, + {"cos", -0x1p-1f, 0, 0x1.c1528p-1f}, + {"cos", 0x1p+0f, 0, 0x1.14a28p-1f}, + {"cos", 0x1.921fb6p+0f, 0, -0x1.777a5cp-25f}, + {"cos", 0x1.921fb6p+1f, 0, -0x1p+0f}, + {"cos", 0x1.921fb6p+2f, 0, 0x1p+0f}, + {"cos", 0x1.930bep-7f, 0, 0x1.fff616p-1f}, + {"cos", 0x1.9p+6f, 0, 0x1.b981dcp-1f}, + {"cos", -0x1.6p+1f, 0, -0x1.d93e2ap-1f}, + {"cos", 0x1.4484cp-100f, 0, 0x1p+0f}, + {"tan", 0x1p-1f, 0, 0x1.17b4f6p-1f}, + {"tan", -0x1p-1f, 0, -0x1.17b4f6p-1f}, + {"tan", 0x1.91eb86p-1f, 0, 0x1.ff97acp-1f}, + {"tan", 0x1.8p+0f, 0, 0x1.c33ed6p+3f}, + {"tan", 0x1.91eb86p+0f, 0, 0x1.39f64ap+10f}, + {"tan", 0x1.8p+1f, 0, -0x1.23ef72p-3f}, + {"asin", 0x1p-1f, 0, 0x1.0c1524p-1f}, + {"asin", -0x1p-1f, 0, -0x1.0c1524p-1f}, + {"asin", 0x1.ff7ceep-1f, 0, 0x1.86acap+0f}, + {"asin", 0x1p+0f, 0, 0x1.921fb6p+0f}, + {"asin", -0x1p+0f, 0, -0x1.921fb6p+0f}, + {"asin", 0x1.0624dep-10f, 0, 0x1.0624ep-10f}, + {"acos", 0x1p-1f, 0, 0x1.0c1524p+0f}, + {"acos", -0x1p-1f, 0, 0x1.0c1524p+1f}, + {"acos", 0x1.ccccccp-1f, 0, 0x1.cdd9fcp-2f}, + {"acos", 0x1.ffffdep-1f, 0, 0x1.752e52p-10f}, + {"acos", -0x1.ffffdep-1f, 0, 0x1.91f11p+1f}, + {"acos", 0.0f, 0, 0x1.921fb6p+0f}, + {"atan", 0x1p-1f, 0, 0x1.dac67p-2f}, + {"atan", -0x1p-1f, 0, -0x1.dac67p-2f}, + {"atan", 0x1p+0f, 0, 0x1.921fb6p-1f}, + {"atan", 0x1.8p+1f, 0, 0x1.3fc176p+0f}, + {"atan", -0x1.9p+6f, 0, -0x1.8f905ep+0f}, + {"atan", 0x1.eb851ep-5f, 0, 0x1.eaee72p-5f}, + {"atan", 0x1.2a05f2p+33f, 0, 0x1.921fb6p+0f}, + {"exp", 0x1p-1f, 0, 0x1.a61298p+0f}, + {"exp", -0x1p-1f, 0, 0x1.368b3p-1f}, + {"exp", 0x1p+0f, 0, 0x1.5bf0a8p+1f}, + {"exp", 0x1.4p+3f, 0, 0x1.5829dcp+14f}, + {"exp", -0x1.4p+4f, 0, 0x1.1b4866p-29f}, + {"exp", 0x1.0624dep-10f, 0, 0x1.004192p+0f}, + {"exp", 0x1.6p+6f, 0, 0x1.f1056ep+126f}, + {"exp2", 0x1p-1f, 0, 0x1.6a09e6p+0f}, + {"exp2", -0x1p-1f, 0, 0x1.6a09e6p-1f}, + {"exp2", 0x1p+0f, 0, 0x1p+1f}, + {"exp2", 0x1.8p+1f, 0, 0x1p+3f}, + {"exp2", -0x1.7f62b6p-6f, 0, 0x1.f7c336p-1f}, + {"exp2", 0x1.48p+4f, 0, 0x1.6a09e6p+20f}, + {"log", 0x1p-1f, 0, -0x1.62e43p-1f}, + {"log", 0x1p+1f, 0, 0x1.62e43p-1f}, + {"log", 0x1p+0f, 0, 0.0f}, + {"log", 0x1.4p+3f, 0, 0x1.26bb1cp+1f}, + {"log", 0x1.79ca1p-67f, 0, -0x1.7069e2p+5f}, + {"log", 0x1.00068ep+0f, 0, 0x1.a37aa2p-14f}, + {"log", 0x1.c363ccp+127f, 0, 0x1.62632cp+6f}, + {"log2", 0x1p-1f, 0, -0x1p+0f}, + {"log2", 0x1p+1f, 0, 0x1p+0f}, + {"log2", 0x1p+0f, 0, 0.0f}, + {"log2", 0x1.4p+3f, 0, 0x1.a934fp+1f}, + {"log2", 0x1.79ca1p-67f, 0, -0x1.09c116p+6f}, + {"log2", 0x1.00068ep+0f, 0, 0x1.2e9714p-13f}, + {"log2", 0x1p+10f, 0, 0x1.4p+3f}, + {"atan2", 0x1p+0f, 0x1p+0f, 0x1.921fb6p-1f}, + {"atan2", 0x1p+0f, -0x1p+0f, 0x1.2d97c8p+1f}, + {"atan2", -0x1p+0f, -0x1p+0f, -0x1.2d97c8p+1f}, + {"atan2", -0x1p+0f, 0x1p+0f, -0x1.921fb6p-1f}, + {"atan2", 0x1.333334p-2f, 0x1p+2f, 0x1.32a03ep-4f}, + {"atan2", 0x1p+2f, 0x1.333334p-2f, 0x1.7ef5b2p+0f}, + {"atan2", -0.0f, -0x1p+0f, -0x1.921fb6p+1f}, + {"atan2", 0x1.47ae14p-9f, -0x1.cp+2f, 0x1.921402p+1f}, + {"atan2", 0x1.ecp+6f, 0x1.0624dep-10f, 0x1.921f2cp+0f}, + {"pow", 0x1p+1f, 0x1p-1f, 0x1.6a09e6p+0f}, + {"pow", 0x1.666666p-1f, 0x1.4cccccp+0f, 0x1.4207e2p-1f}, + {"pow", 0x1p-2f, 0x1p+1f, 0x1p-4f}, + {"pow", 0x1.2p+3f, 0x1.54fdf4p-2f, 0x1.0a0e48p+1f}, + {"pow", 0x1.b33334p+0f, 0x1.8p+1f, 0x1.3a6e9ap+2f}, + {"pow", 0x1.fae148p-1f, 0x1p-1f, 0x1.fd6efep-1f}, + {"pow", -0x1p+1f, 0x1.8p+1f, -0x1p+3f}, + {"pow", 0x1.9p+3f, 0x1.ccccccp+0f, 0x1.7922dp+6f}, + {"hypot", 0x1.8p+1f, 0x1p+2f, 0x1.4p+2f}, + {"hypot", 0x1.99999ap-4f, 0x1.99999ap-3f, 0x1.c9f25cp-3f}, + {"hypot", -0x1.ep+2f, 0x1.0624dep-10f, 0x1.ep+2f}, +}; + +struct DoubleCase { + const char* fn; + double x; + double expected; // OpenMM2's result (within an ulp of the true value) +}; + +constexpr DoubleCase kDoubleCases[] = { + {"sin", 0x1p-1, 0x1.eaee8744b05fp-2}, + {"sin", 0x1p+0, 0x1.aed548f090ceep-1}, + {"sin", 0x1.921fb54442d18p+0, 0x1p+0}, + {"sin", 0x1.8p+1, 0x1.210386db6d55bp-3}, + {"sin", 0x1.9p+6, -0x1.03425b78c4db8p-1}, + {"sin", -0x1p-2, -0x1.faaeed4f31577p-3}, + {"cos", 0x1p-1, 0x1.c1528065b7d5p-1}, + {"cos", 0x1p+0, 0x1.14a280fb5068cp-1}, + {"cos", 0x1.921fb54442d18p+0, 0x1.1a62633145c07p-54}, + {"cos", 0x1.8p+1, -0x1.fae04be85e5d2p-1}, + {"cos", 0x1.9p+6, 0x1.b981dbf665fdfp-1}, + {"cos", -0x1p-2, 0x1.f01549f7deea1p-1}, + {"exp", 0x1p-1, 0x1.a61298e1e069cp+0}, + {"exp", -0x1.8p+1, 0x1.97db0ccceb0afp-5}, + {"exp", 0x1.4p+3, 0x1.5829dcf95056p+14}, + {"log", 0x1p-1, -0x1.62e42fefa39efp-1}, + {"log", 0x1.8p+1, 0x1.193ea7aad030bp+0}, + {"log", 0x1.2a05f2p+33, 0x1.7069e2aa2aa5bp+4}, +}; + +std::uint32_t bits(float v) { return std::bit_cast(v); } +std::uint64_t bits(double v) { return std::bit_cast(v); } + +// FNV-1a over the bits of a function's results on `count` arguments spread +// over [lo, hi] (a fixed sequence, the same on every platform). +template +std::uint64_t sweepHash(F f, float lo, float hi, int count) { + std::uint64_t h = 1469598103934665603ull; + std::uint32_t seed = 12345u; + for (int i = 0; i < count; ++i) { + seed = seed * 1664525u + 1013904223u; + const float t = static_cast(seed >> 8) * 0x1p-24f; + const float x = lo + (hi - lo) * t; + const auto result = f(x); + const std::uint64_t v = bits(result); + for (std::size_t k = 0; k < sizeof(result); ++k) { + h ^= (v >> (8 * k)) & 0xffu; + h *= 1099511628211ull; + } + } + return h; +} + +} // namespace + +TEST(Determinism, LibmFloatValues) { + for (const FloatCase& c : kFloatCases) { + const float got = evaluate(c); + EXPECT_EQ(bits(got), bits(c.expected)) + << std::format("{}({:a}, {:a}) = {:a}, expected {:a}", c.fn, c.a, c.b, got, c.expected); + } +} + +TEST(Determinism, LibmDoubleValues) { + for (const DoubleCase& c : kDoubleCases) { + const std::string fn = c.fn; + const double got = fn == "sin" ? libm::sin(c.x) + : fn == "cos" ? libm::cos(c.x) + : fn == "exp" ? libm::exp(c.x) + : libm::log(c.x); + EXPECT_EQ(bits(got), bits(c.expected)) + << std::format("{}({:a}) = {:a}, expected {:a}", c.fn, c.x, got, c.expected); + } +} + +// What the C standard (Annex F) asks at the edges. +TEST(Determinism, LibmSpecialValues) { + constexpr float inf = std::numeric_limits::infinity(); + constexpr float nan = std::numeric_limits::quiet_NaN(); + EXPECT_TRUE(std::isnan(libm::sin(nan))); + EXPECT_TRUE(std::isnan(libm::sin(inf))); + EXPECT_TRUE(std::isnan(libm::cos(-inf))); + EXPECT_TRUE(std::isnan(libm::acos(1.5f))); + EXPECT_TRUE(std::isnan(libm::log(-1.0f))); + EXPECT_TRUE(std::isnan(libm::pow(-2.0f, 0.5f))); + EXPECT_EQ(bits(libm::sin(-0.0f)), bits(-0.0f)); + EXPECT_EQ(bits(libm::atan(-0.0f)), bits(-0.0f)); + EXPECT_EQ(bits(libm::asin(-0.0f)), bits(-0.0f)); + EXPECT_EQ(libm::cos(0.0f), 1.0f); + EXPECT_EQ(libm::acos(1.0f), 0.0f); + EXPECT_EQ(bits(libm::atan2(0.0f, -0.0f)), bits(libm::atan2(0.0f, -1.0f))); + EXPECT_EQ(bits(libm::atan2(-0.0f, 0.0f)), bits(-0.0f)); + EXPECT_EQ(libm::atan2(1.0f, 0.0f), 0x1.921fb6p+0f); + EXPECT_EQ(libm::atan2(inf, -inf), 0x1.2d97c8p+1f); + EXPECT_EQ(libm::atan(inf), 0x1.921fb6p+0f); + EXPECT_EQ(libm::exp(-inf), 0.0f); + EXPECT_EQ(libm::exp(100.0f), inf); + EXPECT_EQ(libm::exp2(-200.0f), 0.0f); + EXPECT_EQ(libm::log(0.0f), -inf); + EXPECT_EQ(libm::log2(inf), inf); + EXPECT_EQ(libm::pow(nan, 0.0f), 1.0f); + EXPECT_EQ(libm::pow(1.0f, nan), 1.0f); + EXPECT_EQ(bits(libm::pow(-0.0f, 3.0f)), bits(-0.0f)); + EXPECT_EQ(libm::pow(0.0f, -1.0f), inf); + EXPECT_EQ(libm::pow(-inf, 3.0f), -inf); + EXPECT_EQ(libm::pow(0.5f, inf), 0.0f); + EXPECT_EQ(libm::pow(-1.0f, -inf), 1.0f); + EXPECT_EQ(libm::hypot(-inf, nan), inf); + // Exact cases. + for (int k = -20; k <= 20; ++k) { + const float p = std::ldexp(1.0f, k); + EXPECT_EQ(libm::exp2(static_cast(k)), p); + EXPECT_EQ(libm::log2(p), static_cast(k)); + } + EXPECT_EQ(libm::pow(3.0f, 2.0f), 9.0f); + EXPECT_EQ(libm::pow(2.0f, 0.5f), std::sqrt(2.0f)); +} + +// Many arguments over the ranges the game uses: one hash per function. +TEST(Determinism, LibmSweepHashes) { + struct Sweep { + const char* name; + std::uint64_t hash; + std::uint64_t expected; + }; + constexpr int n = 200000; + const Sweep sweeps[] = { + {"sin", sweepHash([](float x) { return libm::sin(x); }, -100.0f, 100.0f, n), + 0x9abf115c21862788ull}, + {"cos", sweepHash([](float x) { return libm::cos(x); }, -100.0f, 100.0f, n), + 0xac728cd307444a07ull}, + {"tan", sweepHash([](float x) { return libm::tan(x); }, -1.5f, 1.5f, n), + 0x68ca500dbd5464f6ull}, + {"asin", sweepHash([](float x) { return libm::asin(x); }, -1.0f, 1.0f, n), + 0x531280a1a2a4e2f9ull}, + {"acos", sweepHash([](float x) { return libm::acos(x); }, -1.0f, 1.0f, n), + 0x31dcb7a55fbda6bfull}, + {"atan", sweepHash([](float x) { return libm::atan(x); }, -50.0f, 50.0f, n), + 0x6c81cfcd4b2e9ac8ull}, + {"atan2", sweepHash([](float x) { return libm::atan2(x, 7.0f - x * 0.5f); }, -40.0f, 40.0f, n), + 0x1685eb43a3e18fa4ull}, + {"exp", sweepHash([](float x) { return libm::exp(x); }, -30.0f, 30.0f, n), + 0xc6e049beed2c73c6ull}, + {"exp2", sweepHash([](float x) { return libm::exp2(x); }, -30.0f, 30.0f, n), + 0xe15ddc1226601472ull}, + {"log", sweepHash([](float x) { return libm::log(x); }, 0.001f, 1000.0f, n), + 0x3b4b340f90dd383dull}, + {"log2", sweepHash([](float x) { return libm::log2(x); }, 0.001f, 1000.0f, n), + 0x3e01c4c6ca9d6679ull}, + {"pow", sweepHash([](float x) { return libm::pow(x, 1.7f); }, 0.0f, 50.0f, n), + 0x082dcae6ae7bb15full}, + {"hypot", sweepHash([](float x) { return libm::hypot(x, 3.0f - x); }, -50.0f, 50.0f, n), + 0x62fb5b376c9281d8ull}, + {"dsin", sweepHash([](float x) { return libm::sin(static_cast(x)); }, -10.0f, 10.0f, n), + 0xbbe99dcdc22e568aull}, + {"dcos", sweepHash([](float x) { return libm::cos(static_cast(x)); }, -10.0f, 10.0f, n), + 0x4d5fc1165c83501aull}, + }; + for (const Sweep& s : sweeps) + EXPECT_EQ(s.hash, s.expected) << std::format("{}: hash {:#018x}", s.name, s.hash); +} + +// The code a host and its clients both simulate calls OpenMM2's functions, +// never the C runtime's, whose results differ between platforms. The +// drawing, the camera, the sound and the tools may use either. +TEST(Determinism, SimulationCallsNoRuntimeTranscendentals) { +#ifndef OPENMM2_SOURCE_DIR + GTEST_SKIP() << "built without OPENMM2_SOURCE_DIR"; +#else + const std::filesystem::path root = OPENMM2_SOURCE_DIR; + if (!std::filesystem::is_directory(root / "src")) + GTEST_SKIP() << "no sources at " << root.string(); + const char* const trees[] = {"src/phys", "src/ai", "src/net", "src/game/session", + "src/game/bangers", "src/game/world", "src/game/net"}; + const char* const files[] = {"src/core/Math.cpp", "src/city/AiMap.cpp", "src/game/TrafficBodies.cpp", + "src/game/PlayerVehicle.cpp", "src/app/Controls.cpp"}; + const char* const names[] = {"sin", "cos", "tan", "asin", "acos", "atan", "atan2", + "sinh", "cosh", "tanh", "exp", "exp2", "expm1", "log", + "log2", "log10", "log1p", "pow", "hypot", "cbrt"}; + std::vector sources; + for (const char* t : trees) + for (const auto& e : std::filesystem::recursive_directory_iterator(root / t)) + if (e.path().extension() == ".cpp" || e.path().extension() == ".h") + sources.push_back(e.path()); + for (const char* f : files) + sources.push_back(root / f); + int checked = 0; + for (const auto& path : sources) { + std::ifstream in(path); + ASSERT_TRUE(in) << path.string(); + ++checked; + std::string line; + for (int n = 1; std::getline(in, line); ++n) { + line = line.substr(0, line.find("//")); + for (const char* name : names) { + // std::sin( and the C float names (sinf() at a word start. + const std::string forms[] = {"std::" + std::string(name) + "(", std::string(name) + "f("}; + for (const std::string& form : forms) { + for (auto at = line.find(form); at != std::string::npos; at = line.find(form, at + 1)) { + const char before = at == 0 ? ' ' : line[at - 1]; + const bool word = !(std::isalnum(static_cast(before)) || + before == '_' || before == ':' || before == '.'); + if (form.starts_with("std::") || word) + ADD_FAILURE() << path.string() << ":" << n << ": " << form + << " (use mm2::libm, core/Libm.h)"; + } + } + } + } + } + EXPECT_GT(checked, 50); +#endif +} From ee9450678f7ffc332fa19b2db47bd1c666b2ffb9 Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Fri, 9 Oct 2026 20:30:24 -0700 Subject: [PATCH 097/129] Build all of OpenMM2 without float contraction Only mm2_phys was built with -ffp-contract=off; the AI, the props, the session and the core maths were not. GCC defaults to -ffp-contract=fast for C++ and Clang to on, which fuse a * b + c into one rounding wherever the target has FMA: every AArch64 build, and x86-64 builds with -march=native or a distribution's x86-64-v3 flags. Such a build simulates differently from the plain x86-64 builds of the releases. The options now apply to every target (OpenMM2::options): GCC, Clang and MinGW get -ffp-contract=off -fno-fast-math, MSVC /fp:precise (which fuses nothing since Visual Studio 2022 unless /fp:contract is given, and x64 has no FMA below /arch:AVX2, which the project never sets). The plain x86-64 builds compute the same as before. Co-Authored-By: Claude Opus 5.5 --- cmake/CompilerOptions.cmake | 23 +++++++++++++++++++++++ src/phys/CMakeLists.txt | 11 +++-------- 2 files changed, 26 insertions(+), 8 deletions(-) diff --git a/cmake/CompilerOptions.cmake b/cmake/CompilerOptions.cmake index 74e821b..cea1ab4 100644 --- a/cmake/CompilerOptions.cmake +++ b/cmake/CompilerOptions.cmake @@ -6,6 +6,29 @@ add_library(OpenMM2::options ALIAS openmm2_options) target_compile_features(openmm2_options INTERFACE cxx_std_23) +# Floating point: the same results on every compiler and platform, so that a +# host and its clients (and replays, tests and the opponent sweep) simulate a +# sample alike whichever toolchain built them (docs/physics.md, "The same +# results on every platform"). Every float and double operation is rounded +# as IEEE 754 says, in the order the source writes it: +# * no contraction of a * b + c into a fused multiply-add, which rounds +# once instead of twice: GCC defaults to -ffp-contract=fast for C++ and +# Clang to on, which fuse wherever the target has FMA (-march=native, +# x86-64-v3 distribution builds, every AArch64 build). MSVC 2022 and +# later fuse only with /fp:contract or /fp:fast, and x64 has no FMA +# below /arch:AVX2, which the project never sets; +# * no fast-math reassociation or reciprocal approximations; +# * SSE2 (x86-64) or AArch64 arithmetic, where float and double +# expressions are evaluated in their own type (FLT_EVAL_METHOD 0); a +# 32-bit x87 build would round differently and is not supported. +# The C runtime's sin, cos, atan2, exp, log and pow still differ between +# platforms; the simulation uses OpenMM2's own (src/core/Libm.h). +if(MSVC) + target_compile_options(openmm2_options INTERFACE /fp:precise) +else() + target_compile_options(openmm2_options INTERFACE -ffp-contract=off -fno-fast-math) +endif() + if(MSVC) target_compile_options(openmm2_options INTERFACE /W4 /permissive- /utf-8 /Zc:__cplusplus /Zc:preprocessor /EHsc diff --git a/src/phys/CMakeLists.txt b/src/phys/CMakeLists.txt index 89b6a7b..5d35084 100644 --- a/src/phys/CMakeLists.txt +++ b/src/phys/CMakeLists.txt @@ -3,12 +3,7 @@ file(GLOB phys_sources CONFIGURE_DEPENDS "${CMAKE_CURRENT_SOURCE_DIR}/vehicle/*.cpp") add_library(mm2_phys STATIC ${phys_sources}) target_link_libraries(mm2_phys PUBLIC OpenMM2::core OpenMM2::data) -# The simulation ports x87 single-precision code operation by operation; -# keep the compiler from fusing or reassociating float operations so results -# are deterministic across builds and platforms. -if(MSVC) - target_compile_options(mm2_phys PRIVATE /fp:precise) -else() - target_compile_options(mm2_phys PRIVATE -ffp-contract=off -fno-fast-math) -endif() +# The simulation ports x87 single-precision code operation by operation; the +# whole project is built without contraction or fast-math (OpenMM2::options, +# cmake/CompilerOptions.cmake), so it rounds alike on every platform. add_library(OpenMM2::phys ALIAS mm2_phys) From 222ee3844db864b00a82b08bcad6e779b069e308 Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Fri, 9 Oct 2026 20:30:32 -0700 Subject: [PATCH 098/129] Collide a client's knocked traffic cars in id order TrafficProxies listed the cars the host knocked off their rails in an unordered map's order, then sorted them by room with an unstable sort. The physics collides a room's instances in the order they are listed, and both orders differ between standard libraries (libstdc++ hashes ints to themselves, MSVC's STL runs FNV-1a), so a client built with MSVC and one built with GCC resolved the same contacts in different orders. They are now kept by id and stably sorted by room, as TrafficBodies lists its rail cars. Two other sorts that could meet equal keys are stable now: the course's turns after a circuit's closing turn is merged, and the host's nearest players for each client (ties keep the player list's order). Co-Authored-By: Claude Opus 5.5 --- src/ai/Course.cpp | 6 ++++-- src/app/RaceScreen.cpp | 2 +- src/game/net/TrafficProxies.cpp | 7 ++++++- src/game/net/TrafficProxies.h | 5 +++-- 4 files changed, 14 insertions(+), 6 deletions(-) diff --git a/src/ai/Course.cpp b/src/ai/Course.cpp index b992db0..80916b1 100644 --- a/src/ai/Course.cpp +++ b/src/ai/Course.cpp @@ -905,8 +905,10 @@ void Course::findTurns() { first.deflection += last.deflection; first.halfWidth = std::min(first.halfWidth, last.halfWidth); m_turns.pop_back(); - std::sort(m_turns.begin(), m_turns.end(), - [](const CourseTurn& a, const CourseTurn& b) { return a.s < b.s; }); + // Stable: two turns at the same s keep their order whichever + // standard library sorts them. + std::stable_sort(m_turns.begin(), m_turns.end(), + [](const CourseTurn& a, const CourseTurn& b) { return a.s < b.s; }); } } } diff --git a/src/app/RaceScreen.cpp b/src/app/RaceScreen.cpp index 91ad5d5..0ee656c 100644 --- a/src/app/RaceScreen.cpp +++ b/src/app/RaceScreen.cpp @@ -5609,7 +5609,7 @@ class RaceScreen final : public Screen { if (d < (rv.nearIds.contains(f.id) ? kNearLeave : kNearEnter)) near.emplace_back(d, &f); } - std::ranges::sort(near, {}, &std::pair::first); + std::ranges::stable_sort(near, {}, &std::pair::first); // ties: list order if (near.size() > net::kMaxNearCars) near.resize(net::kMaxNearCars); rv.nearIds.clear(); diff --git a/src/game/net/TrafficProxies.cpp b/src/game/net/TrafficProxies.cpp index de43b53..210381e 100644 --- a/src/game/net/TrafficProxies.cpp +++ b/src/game/net/TrafficProxies.cpp @@ -75,10 +75,15 @@ void TrafficProxies::update(std::span cars) { p.room = m_level ? m_level->findRoom(p.position(), p.room) : 0; } std::erase_if(m_proxies, [](const auto& e) { return !e.second->seen; }); + // In id order within a room, as TrafficBodies lists its rail cars: the + // physics collides a room's instances in the order listed, and an + // unordered map's order (or an unstable sort's) differs between standard + // libraries, so a client built with MSVC and one built with GCC would + // resolve the same contacts in different orders. 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::ranges::stable_sort(m_byRoom, {}, &std::pair::first); } void TrafficProxies::instancesIn(int room, std::vector& out) const { diff --git a/src/game/net/TrafficProxies.h b/src/game/net/TrafficProxies.h index f2c3b52..b41b7d7 100644 --- a/src/game/net/TrafficProxies.h +++ b/src/game/net/TrafficProxies.h @@ -15,6 +15,7 @@ #include "phys/Bound.h" #include "phys/Level.h" +#include #include #include #include @@ -49,9 +50,9 @@ class TrafficProxies final : public InstanceSource { const BoundEntry& boundFor(const ai::VehicleData& data); const phys::Level* m_level; - std::unordered_map> m_proxies; + std::map> m_proxies; // by id: the order they collide in, room by room std::unordered_map m_bounds; - std::vector> m_byRoom; // sorted by room + std::vector> m_byRoom; // sorted by room, then id }; } // namespace mm2::game From afa07bbc5d897397df0ce0370beb0ad02610993c Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Fri, 9 Oct 2026 20:35:41 -0700 Subject: [PATCH 099/129] Prove in CI that the simulation is the same on every platform Determinism.ScenarioHashesMatchEveryPlatform runs a minute of a fixed scenario without game data: a made-up sports car on fixed inputs (through the steering filter) drives through made-up props, over a jump and round a block of ambient traffic at density 1 (ai::Traffic, TrafficBodies), knocking 9 cars loose and 25 props over. The car, the traffic and the props are hashed every two seconds and compared with the hashes of a Linux GCC build. A mismatch names the first checkpoint and the part that differs, and prints the platform's table and where the car was at each checkpoint, to compare with the Linux log. Determinism.RetailRaceThroughTraffic does the same for the first 40 s of London's race1 with its six racers and full traffic (game data, so not in CI), and Determinism.FloatEnvironmentIsIeee checks the evaluation type, the rounding mode and that subnormals are kept. The Linux GCC and Clang jobs and the Windows MSVC job run them with the rest of the suite. The MinGW job now installs Wine and runs the Determinism tests through it (the toolchain file makes Wine the tests' emulator). Here all of them pass under Wine 11.19 with the MinGW GCC 16.2 build, the race included, with the Linux build's hashes. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 15 +- tests/game/test_determinism.cpp | 519 ++++++++++++++++++++++++++++++ tests/game/test_opponent_race.cpp | 64 ++++ 3 files changed, 597 insertions(+), 1 deletion(-) create mode 100644 tests/game/test_determinism.cpp diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e1d4c5c..a7b2ccc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -158,9 +158,11 @@ jobs: container: archlinux:latest steps: - name: Install build dependencies + # Wine runs the determinism tests (the toolchain file makes it the + # tests' emulator when it is installed at configure time). run: | pacman -Syu --noconfirm --needed \ - base-devel git cmake ninja mingw-w64-gcc shaderc glslang + base-devel git cmake ninja mingw-w64-gcc shaderc glslang wine - uses: actions/checkout@v7 @@ -178,6 +180,17 @@ jobs: - name: Build run: cmake --build --preset mingw-cross-release + # The simulation must give the same bits as the Linux and MSVC builds + # (docs/physics.md, "The same results on every platform"). Only these + # tests: the native jobs run the rest. + - name: Test determinism under Wine + env: + WINEDEBUG: -all + WINEDLLOVERRIDES: mscoree,mshtml= + run: | + wineboot --init + ctest --preset mingw-cross-release -R '^Determinism[.]' + - name: Package run: cpack --preset mingw-portable diff --git a/tests/game/test_determinism.cpp b/tests/game/test_determinism.cpp new file mode 100644 index 0000000..bba9313 --- /dev/null +++ b/tests/game/test_determinism.cpp @@ -0,0 +1,519 @@ +// The simulation gives the same bits on every platform and compiler +// (docs/physics.md, "The same results on every platform"): a fixed scenario +// with no game data (a car on fixed inputs through props, over a jump and +// round and round a block of ambient traffic it knocks loose, for a minute) +// is hashed every two seconds of simulation and compared with hashes +// committed from a Linux GCC build. CI runs it on GCC, Clang, MinGW (under +// Wine) and MSVC; a host and a client built by two of those then simulate a +// network game's samples alike. +// +// When the simulation changes on purpose the hashes change with it: run +// test_game --gtest_filter='Determinism.*' and paste the table it prints. +// The new values must be the same on every platform (the CI jobs). +#include "ai/PlayerCar.h" +#include "ai/RoadNetwork.h" +#include "ai/Traffic.h" +#include "ai/TrafficLights.h" +#include "city/AiMap.h" +#include "core/File.h" +#include "game/CityLevel.h" +#include "game/TrafficBodies.h" +#include "game/bangers/BangerData.h" +#include "game/bangers/BangerSet.h" +#include "game/bangers/PropPlacement.h" +#include "phys/Constants.h" +#include "phys/Level.h" +#include "phys/World.h" +#include "phys/vehicle/CarSim.h" +#include "phys/vehicle/Controls.h" +#include "phys/vehicle/VehicleGeometry.h" +#include "vfs/FileSystem.h" +#include "vfs/Vfs.h" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace mm2; + +namespace { + +constexpr float kDt = phys::kFixedSampleStep; +constexpr int kSteps = 3600; // 60 s +constexpr int kCheckEvery = 120; // 2 s + +// --- Hashing ----------------------------------------------------------------------------------- + +// FNV-1a over the bits of the state, field by field (no padding). +struct Hash { + std::uint64_t h = 1469598103934665603ull; + void bytes(const void* p, std::size_t n) { + const auto* b = static_cast(p); + for (std::size_t i = 0; i < n; ++i) { + h ^= b[i]; + h *= 1099511628211ull; + } + } + void add(float v) { + const std::uint32_t u = std::bit_cast(v); + bytes(&u, sizeof u); + } + void add(std::int64_t v) { bytes(&v, sizeof v); } + void add(const Vec3& v) { + add(v.x); + add(v.y); + add(v.z); + } + void add(const Mat34& m) { + add(m.m0); + add(m.m1); + add(m.m2); + add(m.m3); + } +}; + +// --- Game files in memory ---------------------------------------------------------------------- + +class MemoryFs final : public vfs::FileSystem { +public: + void put(std::string path, std::string_view text) { m_files[std::move(path)] = std::string(text); } + std::string describe() const override { return "memory"; } + std::shared_ptr open(std::string_view path) const override { + const auto it = m_files.find(std::string(path)); + if (it == m_files.end()) + return nullptr; + std::vector data(it->second.size()); + std::memcpy(data.data(), it->second.data(), data.size()); + return std::make_shared(std::move(data)); + } + bool exists(std::string_view path) const override { return m_files.contains(std::string(path)); } + void forEachFile(const std::function& fn) const override { + for (const auto& [path, text] : m_files) + fn({path, text.size(), false}); + } + +private: + std::map m_files; +}; + +// Made-up props (no game data): a light one that breaks loose at the +// slightest knock, a heavy one, and one that splits into two parts. +std::string bangerFile(const char* size, const char* cg, const char* mass, const char* limit2, int parts) { + return std::format("type: a\ndgBangerData {{\n Size {}\n CG {}\n Mass {}\n Elasticity 0.4\n" + " Friction 0.8\n ImpulseLimit2 {}\n NumParts {}\n BirthRule {{\n" + " InitialBlast 0\n }}\n TexNumber 0\n CollisionPrim 1\n}}\n", + size, cg, mass, limit2, parts); +} + +std::shared_ptr bangerFiles() { + auto fs = std::make_shared(); + fs->put("tune/banger/cone.dgbangerdata", bangerFile("0.4 0.8 0.4", "0 0.4 0", "15", "100", 0)); + fs->put("tune/banger/crate.dgbangerdata", bangerFile("1.2 1.2 1.2", "0 0.6 0", "300", "40000", 0)); + fs->put("tune/banger/sign.dgbangerdata", bangerFile("0.3 2.5 0.3", "0 1.25 0", "60", "2500", 2)); + fs->put("tune/banger/sign_break01.dgbangerdata", bangerFile("0.3 1.2 0.3", "0 0.6 0", "30", "1e6", 0)); + fs->put("tune/banger/sign_break02.dgbangerdata", bangerFile("0.3 1.2 0.3", "0 1.85 0", "30", "1e6", 0)); + return fs; +} + +// --- The city ---------------------------------------------------------------------------------- + +// A straight two-way road from a to b, one lane each way 4 m apart, laid out +// as the retail files are (see tests/ai/test_traffic.cpp). +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 Vec3& c = p.center[static_cast(reversed ? kSections - 1 - i : i)]; + lane.push_back(c + left * (sign * 2.0f)); + } + for (int i = 0; i < kSections; ++i) { + const Vec3& c = p.center[static_cast(i)]; + walk.push_back(c + left * (sign * 5.5f)); + curb.push_back(c + left * (sign * 4.0f)); + edge.push_back(c + 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 block 200 m a side; road k runs from corner k to corner k + 1, and +// corner 1 has traffic lights. +city::AiMap block() { + 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)}}); + map.paths[0].ends[0].vehicleRule = 1; + map.paths[1].ends[1].vehicleRule = 1; + 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; +} + +// Made-up traffic cars, with vehWheelCheap's own spring and tyre values. +ai::VehicleData sedan() { + ai::VehicleData d; + d.model = "sedan"; + d.mass = 1400.0f; + d.size = {1.9f, 1.5f, 4.6f}; + d.maxAng = {3.0f, 3.0f, 3.0f}; + d.cg = {0.0f, 0.7f, 0.0f}; + d.spring = 50000.0f; + d.damping = 5000.0f; + d.rubberSpring = 40000.0f; + d.rubberDamp = 2000.0f; + d.limit = 0.07f; + d.wheelRadius = 0.33f; + d.wheels = {Vec3{-0.8f, 0.33f, -1.4f}, Vec3{0.8f, 0.33f, -1.4f}, Vec3{-0.8f, 0.33f, 1.4f}, + Vec3{0.8f, 0.33f, 1.4f}, Vec3{}, Vec3{}}; + d.wheelCount = 4; + return d; +} + +// The level as the collision manager sees it: one room with the ground, a +// wall across the far end of road 0, a ramp beside it, and the instances of +// the props and the traffic. +class ScenarioLevel final : public phys::Level { +public: + void addSource(const game::InstanceSource* s) { m_sources.push_back(s); } + int findRoom(const Vec3&, int) const override { return 1; } + int touchedNeighbors(int*, int, int, const Vec3&, float) const override { return 0; } + void collect(const int*, int, const Vec3&, float, phys::LevelBound& out) const override { + out.clear(); + const Vec3 ground[4] = {{-2000, 0, -2000}, {-2000, 0, 2000}, {2000, 0, 2000}, {2000, 0, -2000}}; + out.addPolygon(ground, 4, {0, 1, 0}, 0); + const Vec3 wall[4] = {{-30, 0, -230}, {30, 0, -230}, {30, 6, -230}, {-30, 6, -230}}; + out.addPolygon(wall, 4, {0, 0, 1}, 0); + // A jump on road 0: rising 1.2 m over 15 m. + const Vec3 ramp[4] = {{-3, 0, -180}, {3, 0, -180}, {3, 1.2f, -195}, {-3, 1.2f, -195}}; + const Vec3 n = (ramp[2] - ramp[1]).cross(ramp[0] - ramp[1]).normalized(); + out.addPolygon(ramp, 4, n, 0); + } + void instances(int room, std::vector& out) const override { + for (const game::InstanceSource* s : m_sources) + s->instancesIn(room, out); + } + +private: + std::vector m_sources; +}; + +// TrafficBodies' view of the AI: the cars and the hand-over (as ai::World's). +class TrafficSource final : public game::TrafficBodies::Source { +public: + explicit TrafficSource(ai::Traffic& traffic) : m_traffic(traffic) {} + const std::vector& cars() const override { return m_traffic.cars(); } + void impact(int carId) override { m_traffic.impact(carId, {}); } + void detach(int carId, const Mat34& pose, bool upright) override { + m_traffic.detach(carId, pose, upright); + } + void setPhysicalTransform(int carId, const Mat34& transform) override { + m_traffic.setPhysicalTransform(carId, transform); + } + +private: + ai::Traffic& m_traffic; +}; + +// The driver's inputs for sample `i`: off the line up the lane of traffic, +// through the props, over the jump towards the wall, braking into reverse, +// a handbrake turn, then circling and weaving through both lanes. +phys::PedalInput inputs(int i, float& steerTarget) { + phys::PedalInput in; + const float t = static_cast(i) * kDt; + if (t < 1.0f) + return in; + in.accelerator = 1.0f; + if (t > 12.0f && t < 15.0f) { + in.accelerator = 0.0f; + in.brake = 1.0f; // and on into reverse + } else if (t > 15.0f && t < 16.5f) { + in.handbrake = 1.0f; + steerTarget = 1.0f; + } else if (t > 16.5f && t < 38.0f) { + steerTarget = 0.25f; + } else if (t > 38.0f) { + steerTarget = -0.3f; + } + return in; +} + +// A made-up sports car on OpenMM2's default tune and body. +phys::CarSimParams sportsCar() { + phys::CarSimParams p; + p.mass = 1400.0f; + p.inertiaBox = {1.9f, 1.3f, 4.4f}; + p.engine.maxHorsePower = 450.0f; + p.trans.high = 120.0f; + return p; +} + +struct Checkpoint { + int step = 0; + std::uint64_t car = 0; + std::uint64_t traffic = 0; + std::uint64_t props = 0; +}; + +struct ScenarioRun { + std::vector checkpoints; + std::vector notes; // per checkpoint: where the car is, what it hit + std::set knockedCars; // traffic cars that took a body + int movedProps = 0; // props that left their place +}; + +ScenarioRun runScenario() { + ScenarioRun run; + // Props (from memory) and the physics world. + vfs::Vfs files; + files.mount(bangerFiles()); + game::bangers::BangerDataLibrary bangerData(files); + ScenarioLevel level; + phys::World world; + world.setLevel(&level); + world.seedRandom(1); + + // The traffic on its block, and its cars in the physics world. + const city::AiMap map = block(); + const ai::RoadNetwork net = ai::RoadNetwork::build(map, {}); + ai::TrafficLights lights; + lights.build(net); + ai::TrafficSettings settings; + settings.density = 1.0f; + ai::Traffic traffic(net, lights, {sedan()}, settings, 7); + traffic.populateAll(); + TrafficSource source(traffic); + game::TrafficBodies trafficBodies(source, world); + level.addSource(&trafficBodies); + + // Props along road 0 ahead of the car. + game::bangers::BangerSet props(bangerData); + std::vector placed; + auto place = [&](const char* model, float x, float z, float turn) { + Mat34 m = Mat34::rotationY(turn); + m.m3 = {x, 0.0f, z}; + placed.push_back({model, m, 1, game::bangers::PlacedProp::Source::Instance, true}); + }; + for (int k = 0; k < 10; ++k) + place("cone", 2.0f + (k % 2 == 0 ? -0.8f : 0.8f), -30.0f - 6.0f * static_cast(k), + 0.3f * static_cast(k)); + place("crate", 2.3f, -95.0f, 0.4f); + place("sign", 0.8f, -110.0f, 0.0f); + place("sign", 3.2f, -140.0f, 1.0f); + place("crate", 1.5f, -170.0f, -0.2f); + props.add(placed); + level.addSource(&props); + props.setWorld(&world); + + phys::CarSim car; + car.init(sportsCar(), phys::VehicleGeometry::placeholder()); + car.setPolygonalBound(true); + Mat34 start = Mat34::identity(); + start.m3 = {2.0f, 0.5f, -6.0f}; + car.reset(start); + world.add(&car.body); + phys::ArcadeControls controls; + phys::SteeringFilter steering; + + for (int i = 0; i < kSteps; ++i) { + // The AI at its own 30 Hz, before the physics (as RaceScreen). + if (i % 2 == 0) { + ai::PlayerCar player; + player.transform = car.body.ics.matrix; + player.velocity = car.body.ics.linearVelocity; + lights.update(ai::kAiStepSeconds); + traffic.step(ai::kAiStepSeconds, player, 0); + } + float target = 0.0f; + phys::PedalInput in = inputs(i, target); + in.steering = steering.filter(target, kDt); + steering.setSpeed(car.speed()); + controls.apply(car, in); + trafficBodies.beforeStep(); + world.step(kDt); + props.update(kDt); + trafficBodies.afterStep(); + for (const ai::AmbientCar& a : traffic.cars()) + if (trafficBodies.transformOf(a.id)) + run.knockedCars.insert(a.id); + + if ((i + 1) % kCheckEvery != 0) + continue; + Checkpoint c; + c.step = i + 1; + Hash hc; + hc.add(car.body.ics.matrix); + hc.add(car.body.ics.linearVelocity); + hc.add(car.body.ics.angularVelocity); + for (const phys::Wheel& w : car.wheels) { + hc.add(w.rotationSpeed); + hc.add(w.suspension); + } + hc.add(car.engine.rpm); + hc.add(static_cast(car.trans.currentGear)); + hc.add(car.damage.currentDamage); + hc.add(static_cast(*world.randomSeed())); + c.car = hc.h; + Hash ht; + for (const ai::AmbientCar& a : traffic.cars()) { + ht.add(static_cast(a.id)); + ht.add(a.transform); + ht.add(a.speed); + if (const Mat34* m = trafficBodies.transformOf(a.id)) + ht.add(*m); + } + c.traffic = ht.h; + Hash hp; + int moved = 0; + for (const auto& p : props.instances()) { + hp.add(p.matrix); + hp.add(static_cast(p.state)); + moved += p.state != game::bangers::BangerSet::State::Unhit; + } + run.movedProps = std::max(run.movedProps, moved); + c.props = hp.h; + run.checkpoints.push_back(c); + const Vec3& p = car.body.ics.matrix.m3; + run.notes.push_back(std::format("{:2.0f} s: car ({:.2f} {:.2f} {:.2f}) {:.1f} m/s damage {:.0f}, " + "x {:a}; {} cars knocked, {} props moved", + static_cast(c.step) * kDt, p.x, p.y, p.z, car.speed(), + car.damage.currentDamage, p.x, run.knockedCars.size(), moved)); + } + return run; +} + +// From a Linux GCC build; every platform must match them. +constexpr Checkpoint kExpected[] = { + {120, 0x3a3c70fe586627f4, 0x232b43b2bd3790f5, 0xb09b3c26cd8491f7}, + {240, 0xf44a3b1523419836, 0x07c07745035920ff, 0x1f857ed1cc92639c}, + {360, 0x6636072d75e5e70b, 0x9c83a9eb6e1d0701, 0x4625eefff3380c42}, + {480, 0xcc99546d20f06ace, 0x014e85b48382d19b, 0xd1a0dda3b99b98c2}, + {600, 0xc93d8ea3e4d97515, 0xa45510819c5caa3f, 0x9b20f4fcb7081d7b}, + {720, 0xcbff89324d9fc812, 0x3d9829c47749bf73, 0xcda29e43be99bfc7}, + {840, 0x8857e8bd0f88da1a, 0x1b987cda49fc2236, 0xfbfbd6f4d19d434d}, + {960, 0x9bce1e35dd3b7d09, 0xcaa7d5466fc9d822, 0xfbfbd6f4d19d434d}, + {1080, 0x0f34a38e28017b62, 0x95cd177ee51c5cdf, 0xfbfbd6f4d19d434d}, + {1200, 0xf1cf1f60a2b3cdb7, 0x29d554768d7bae0a, 0xfbfbd6f4d19d434d}, + {1320, 0x9baa47913191c83d, 0x0f2c14141a6658b6, 0xfbfbd6f4d19d434d}, + {1440, 0x62032f6974458ffe, 0x87709e323c4d7f0a, 0xfbfbd6f4d19d434d}, + {1560, 0x8ac89bb2fdc0dca4, 0x1f6a24acb4aa99cb, 0xfbfbd6f4d19d434d}, + {1680, 0xcbabc0f348538d48, 0xaf5f5de0861f667f, 0xfbfbd6f4d19d434d}, + {1800, 0x73e4a52494f9f6e2, 0x5f46e4c1ecfb7e2d, 0xfbfbd6f4d19d434d}, + {1920, 0x13867b1c2b431420, 0xe2f3b1b45a0015f1, 0xfbfbd6f4d19d434d}, + {2040, 0xfcec09922ecc3586, 0x51b5aea0fb69eb7a, 0xfbfbd6f4d19d434d}, + {2160, 0x92995741f1f6e8b2, 0x754b679d69746280, 0xfbfbd6f4d19d434d}, + {2280, 0x9272324f6533ab08, 0xf6e2a9f52d9ac74f, 0xfbfbd6f4d19d434d}, + {2400, 0xf4a8159c6c341e8a, 0x771d45cc7d7d83e6, 0xfbfbd6f4d19d434d}, + {2520, 0xc85c1127eb6826c6, 0x5211769ee6ca9265, 0xfbfbd6f4d19d434d}, + {2640, 0x5b41151bd126696a, 0x3b69c0ce31e6426c, 0xfbfbd6f4d19d434d}, + {2760, 0x4084751e2380d5a7, 0x06921e31cb806c4b, 0xfbfbd6f4d19d434d}, + {2880, 0xa453ea47f6c395c0, 0x23cc0dbda0264c29, 0xfbfbd6f4d19d434d}, + {3000, 0x89728248115b48ff, 0x7528abb5441d5154, 0xfbfbd6f4d19d434d}, + {3120, 0xf5ea3c8def065bb4, 0xad25ecafb12c28e0, 0xfbfbd6f4d19d434d}, + {3240, 0x3c6a79fc019cdc90, 0x241b6275021a6397, 0xfbfbd6f4d19d434d}, + {3360, 0xa3ae9659ea926806, 0x1b566fd8551815e8, 0xfbfbd6f4d19d434d}, + {3480, 0x1efab959e2778136, 0x87dbb2fe9ab88ca4, 0xfbfbd6f4d19d434d}, + {3600, 0xfa6b5022efc7f63a, 0x0a248681704e70f9, 0xfbfbd6f4d19d434d}, +}; + +// The hashes as source, then what happened by each checkpoint (to compare +// with another platform's log). +std::string table(const ScenarioRun& run) { + std::string s = "constexpr Checkpoint kExpected[] = {\n"; + for (const Checkpoint& c : run.checkpoints) + s += std::format(" {{{}, {:#018x}, {:#018x}, {:#018x}}},\n", c.step, c.car, c.traffic, c.props); + s += "};\n"; + for (const std::string& note : run.notes) + s += note + "\n"; + return s; +} + +} // namespace + +// Floats and doubles are evaluated in their own type (SSE2, AArch64; an x87 +// build would round its intermediates differently), rounded to nearest, with +// subnormals kept (nothing set flush-to-zero). +TEST(Determinism, FloatEnvironmentIsIeee) { +#ifdef FLT_EVAL_METHOD + EXPECT_EQ(FLT_EVAL_METHOD, 0); +#endif + EXPECT_EQ(std::fegetround(), FE_TONEAREST); + volatile float a = 1.0f, b = 0x1p-24f; + EXPECT_EQ(a + b - a, 0.0f); // rounded to float before the subtraction + volatile float tiny = 0x1p-140f; + EXPECT_EQ(tiny * 0.5f, 0x1p-141f); +} + +TEST(Determinism, ScenarioHashesMatchEveryPlatform) { + const ScenarioRun run = runScenario(); + ASSERT_EQ(run.checkpoints.size(), static_cast(kSteps / kCheckEvery)); + std::printf("determinism: at the end %s\n", run.notes.back().c_str()); + // The scenario does what it is for. + EXPECT_GT(run.knockedCars.size(), 2u); + EXPECT_GT(run.movedProps, 5); + bool same = std::size(kExpected) == run.checkpoints.size(); + for (std::size_t i = 0; same && i < run.checkpoints.size(); ++i) { + const Checkpoint& got = run.checkpoints[i]; + const Checkpoint& want = kExpected[i]; + if (got.step == want.step && got.car == want.car && got.traffic == want.traffic && + got.props == want.props) + continue; + same = false; + ADD_FAILURE() << std::format("first difference at step {} ({:.0f} s): car {}, traffic {}, props {}", + got.step, static_cast(got.step) * kDt, + got.car == want.car ? "same" : "differs", + got.traffic == want.traffic ? "same" : "differs", + got.props == want.props ? "same" : "differs"); + } + if (!same) + ADD_FAILURE() << "this platform's hashes:\n" << table(run); +} diff --git a/tests/game/test_opponent_race.cpp b/tests/game/test_opponent_race.cpp index 9fe176a..e9b8b96 100644 --- a/tests/game/test_opponent_race.cpp +++ b/tests/game/test_opponent_race.cpp @@ -32,7 +32,9 @@ #include #include +#include #include +#include #include #include #include @@ -980,3 +982,65 @@ TEST(OpponentRace, EveryRaceSweep) { std::printf("sweep: %d/%d opponents finished\n", finished, total); std::printf("sweep: %d/%d crossed the race's finish line (aiRouteRacer::Finished)\n", crossed, total); } + +// The same race on every platform (docs/physics.md, "The same results on +// every platform"; the data-free scenario is game/test_determinism): the +// first 40 s of London's race1 with its six racers and full traffic, +// hashed. The racers drive physics cars with the AI's atan2 / sin / cos +// steering and pow throttle, through traffic they knock loose and props. +// The hashes come from a Linux GCC build; every platform must match them +// (CI has no game data: run it on Windows with OPENMM2_GAME_DATA set). +TEST(Determinism, RetailRaceThroughTraffic) { + MM2_REQUIRE_GAME_DATA(); + const vfs::Vfs& vfs = *test::gameData(); + auto cw = CityWorld::load(vfs, "london", 1.0f); + ASSERT_TRUE(cw); + const auto setup = loadSetup(*cw->city, vfs, game::GameMode::Checkpoint, 1, 6, 0); + ASSERT_TRUE(setup); + const RaceRun run = runRace(*cw, vfs, *setup, 40.0f); + ASSERT_EQ(run.racers.size(), 6u); + auto fnv = [](std::uint64_t& h, float v) { + const auto u = std::bit_cast(v); + for (int k = 0; k < 4; ++k) + h = (h ^ ((u >> (8 * k)) & 0xffu)) * 1099511628211ull; + }; + auto fnvVec = [&](std::uint64_t& h, const Vec3& v) { + fnv(h, v.x); + fnv(h, v.y); + fnv(h, v.z); + }; + // The racers' trails (every third frame) and where they ended; the + // traffic; the props. + std::uint64_t racers = 1469598103934665603ull, traffic = racers, props = racers; + for (const auto& r : run.racers) { + for (const Vec3& p : r.trail) + fnvVec(racers, p); + const phys::CarSim& sim = r.vehicle->sim(); + const phys::InertialCS& ics = sim.body.ics; + for (const Vec3& row : + {ics.matrix.m0, ics.matrix.m1, ics.matrix.m2, ics.matrix.m3, ics.linearVelocity, ics.angularVelocity}) + fnvVec(racers, row); + fnv(racers, sim.damage.currentDamage); + fnv(racers, sim.engine.rpm); + } + for (const ai::AmbientCar& c : cw->ai->cars()) { + fnv(traffic, static_cast(c.id)); + fnvVec(traffic, c.transform.m3); + fnvVec(traffic, c.transform.m2); + fnv(traffic, c.speed); + } + int moved = 0; + for (const auto& p : cw->bangers->instances()) { + fnvVec(props, p.matrix.m3); + fnvVec(props, p.matrix.m2); + moved += p.state != game::bangers::BangerSet::State::Unhit; + } + const Vec3 lead = run.racers.front().vehicle->sim().body.ics.matrix.m3; + std::printf("determinism: racers %016llx traffic %016llx props %016llx (%d props moved); " + "car 1 at (%a, %a, %a)\n", + static_cast(racers), static_cast(traffic), + static_cast(props), moved, lead.x, lead.y, lead.z); + EXPECT_EQ(racers, 0x65eb8feebb02f101ull) << "the racers drove differently"; + EXPECT_EQ(traffic, 0xb8f1c125aa480ce2ull) << "the traffic drove differently"; + EXPECT_EQ(props, 0xfc1321f4c3363732ull) << "the props moved differently"; +} From 8641ea36f56dd5a1c3c240d62684fb729a71a0ed Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Fri, 9 Oct 2026 20:51:52 -0700 Subject: [PATCH 100/129] Put an AI racer and cobbles in the determinism scenario Built from integration's sources, where the simulation still called the C runtime's functions, the first scenario gave the same hashes on Linux and under Wine: the car's few steering angles and the traffic's paths never met an argument where glibc and MinGW's runtime round differently. It could not tell a platform-dependent build from a deterministic one. The ground is cobbled now (the wheels' road bumps, a sine of every sample's phase), the steering always moves a little, and an ai::Opponent laps the block among the traffic (aiVehiclePhysics' steering: atan2, the turns' arcs, pow). On integration's sources the Linux build and the MinGW build under Wine now differ from the first checkpoint (the racer, 2 s in) and end with the cars 49 m apart; with OpenMM2's maths both give the committed hashes at all 30 checkpoints. Co-Authored-By: Claude Opus 5.5 --- tests/game/test_determinism.cpp | 159 +++++++++++++++++++++--------- tests/game/test_opponent_race.cpp | 4 +- 2 files changed, 117 insertions(+), 46 deletions(-) diff --git a/tests/game/test_determinism.cpp b/tests/game/test_determinism.cpp index bba9313..49c0902 100644 --- a/tests/game/test_determinism.cpp +++ b/tests/game/test_determinism.cpp @@ -1,15 +1,22 @@ // The simulation gives the same bits on every platform and compiler // (docs/physics.md, "The same results on every platform"): a fixed scenario // with no game data (a car on fixed inputs through props, over a jump and -// round and round a block of ambient traffic it knocks loose, for a minute) -// is hashed every two seconds of simulation and compared with hashes -// committed from a Linux GCC build. CI runs it on GCC, Clang, MinGW (under -// Wine) and MSVC; a host and a client built by two of those then simulate a -// network game's samples alike. +// round and round a block of ambient traffic it knocks loose, on cobbles, +// with an AI racer lapping the block, for a minute) is hashed every two +// seconds of simulation and compared with hashes committed from a Linux GCC +// build. CI runs it on GCC, Clang, MinGW (under Wine) and MSVC; a host and a +// client built by two of those then simulate a network game's samples +// alike. (Built against the C runtime's sin, cos and atan2, as before +// core/Libm.h, the Linux and the MinGW builds differ from the first +// checkpoint on.) // // When the simulation changes on purpose the hashes change with it: run // test_game --gtest_filter='Determinism.*' and paste the table it prints. // The new values must be the same on every platform (the CI jobs). +#include "ai/Course.h" +#include "ai/Driving.h" +#include "ai/MapView.h" +#include "ai/Opponent.h" #include "ai/PlayerCar.h" #include "ai/RoadNetwork.h" #include "ai/Traffic.h" @@ -23,6 +30,7 @@ #include "game/bangers/PropPlacement.h" #include "phys/Constants.h" #include "phys/Level.h" +#include "phys/Material.h" #include "phys/World.h" #include "phys/vehicle/CarSim.h" #include "phys/vehicle/Controls.h" @@ -36,6 +44,7 @@ #include #include #include +#include #include #include #include @@ -43,6 +52,7 @@ #include #include #include +#include #include #include #include @@ -219,18 +229,30 @@ ai::VehicleData sedan() { return d; } -// The level as the collision manager sees it: one room with the ground, a -// wall across the far end of road 0, a ramp beside it, and the instances of +// Cobbles: the wheels bump along them (vehWheel's road bumps). +phys::MaterialTable materials() { + phys::MaterialTable table; + phys::Material cobbles; + cobbles.name = "cobbles"; + cobbles.width = 0.45f; + cobbles.height = 0.03f; + table.add(cobbles); + return table; +} + +// The level as the collision manager sees it: one room with cobbled ground, +// a wall across the far end of road 0, a jump on it, and the instances of // the props and the traffic. class ScenarioLevel final : public phys::Level { public: + explicit ScenarioLevel(int ground) : m_ground(static_cast(ground)) {} void addSource(const game::InstanceSource* s) { m_sources.push_back(s); } int findRoom(const Vec3&, int) const override { return 1; } int touchedNeighbors(int*, int, int, const Vec3&, float) const override { return 0; } void collect(const int*, int, const Vec3&, float, phys::LevelBound& out) const override { out.clear(); const Vec3 ground[4] = {{-2000, 0, -2000}, {-2000, 0, 2000}, {2000, 0, 2000}, {2000, 0, -2000}}; - out.addPolygon(ground, 4, {0, 1, 0}, 0); + out.addPolygon(ground, 4, {0, 1, 0}, m_ground); const Vec3 wall[4] = {{-30, 0, -230}, {30, 0, -230}, {30, 6, -230}, {-30, 6, -230}}; out.addPolygon(wall, 4, {0, 0, 1}, 0); // A jump on road 0: rising 1.2 m over 15 m. @@ -244,6 +266,7 @@ class ScenarioLevel final : public phys::Level { } private: + std::uint8_t m_ground; std::vector m_sources; }; @@ -267,12 +290,18 @@ class TrafficSource final : public game::TrafficBodies::Source { // The driver's inputs for sample `i`: off the line up the lane of traffic, // through the props, over the jump towards the wall, braking into reverse, // a handbrake turn, then circling and weaving through both lanes. +// The steering always moves a little (a triangle wave), so the wheels' angles +// take ever new values. phys::PedalInput inputs(int i, float& steerTarget) { phys::PedalInput in; const float t = static_cast(i) * kDt; if (t < 1.0f) return in; + float phase = t / 3.0f; + phase -= std::floor(phase); + const float wobble = 0.08f * (4.0f * std::abs(phase - 0.5f) - 1.0f); in.accelerator = 1.0f; + steerTarget = wobble * 0.5f; if (t > 12.0f && t < 15.0f) { in.accelerator = 0.0f; in.brake = 1.0f; // and on into reverse @@ -280,9 +309,9 @@ phys::PedalInput inputs(int i, float& steerTarget) { in.handbrake = 1.0f; steerTarget = 1.0f; } else if (t > 16.5f && t < 38.0f) { - steerTarget = 0.25f; + steerTarget = 0.25f + wobble; } else if (t > 38.0f) { - steerTarget = -0.3f; + steerTarget = -0.3f + wobble; } return in; } @@ -309,6 +338,7 @@ struct ScenarioRun { std::vector notes; // per checkpoint: where the car is, what it hit std::set knockedCars; // traffic cars that took a body int movedProps = 0; // props that left their place + float racerProgress = 0.0f; // metres the racer drove along its course }; ScenarioRun runScenario() { @@ -317,8 +347,9 @@ ScenarioRun runScenario() { vfs::Vfs files; files.mount(bangerFiles()); game::bangers::BangerDataLibrary bangerData(files); - ScenarioLevel level; - phys::World world; + const phys::MaterialTable table = materials(); + ScenarioLevel level(table.find("cobbles")); + phys::World world(table); world.setLevel(&level); world.seedRandom(1); @@ -364,6 +395,30 @@ ScenarioRun runScenario() { phys::ArcadeControls controls; phys::SteeringFilter steering; + // An AI racer lapping the block ahead of it (ai::Opponent: the + // aiVehiclePhysics steering, its atan2s, the turns' arcs, pow). + const ai::MapView mapView(net); + phys::CarSim racerCar; + racerCar.init(sportsCar(), phys::VehicleGeometry::placeholder()); + Mat34 grid = Mat34::identity(); + grid.m3 = {2.0f, 0.5f, -24.0f}; + racerCar.reset(grid); + world.add(&racerCar.body); + const std::vector corners{0, 1, 2, 3}; + std::optional course = ai::Course::build(net, corners, grid.m3, std::nullopt, true); + if (!course) + return run; + ai::OpponentSettings racing; + racing.laps = 5; + racing.world = &world; + ai::RouteRegistration route; + route.wayPoints = {1, 2, 3, 0}; + route.laps = 5; + route.destination = grid.m3; + ai::Opponent racer(racerCar, std::move(*course), racing, 2, &mapView, route, 0, "sedan"); + racer.reset(); + std::vector tracked; + for (int i = 0; i < kSteps; ++i) { // The AI at its own 30 Hz, before the physics (as RaceScreen). if (i % 2 == 0) { @@ -372,6 +427,13 @@ ScenarioRun runScenario() { player.velocity = car.body.ics.linearVelocity; lights.update(ai::kAiStepSeconds); traffic.step(ai::kAiStepSeconds, player, 0); + tracked.clear(); + tracked.push_back(ai::trackedCar(car, 1, true)); + tracked.push_back(ai::trackedCar(racerCar, 2, false)); + racer.describe(tracked.back()); + for (const ai::AmbientCar& a : traffic.cars()) + tracked.push_back(ai::trackedAmbient(a, 100 + a.id)); + racer.update(ai::kAiStepSeconds, tracked); } float target = 0.0f; phys::PedalInput in = inputs(i, target); @@ -402,6 +464,10 @@ ScenarioRun runScenario() { hc.add(static_cast(car.trans.currentGear)); hc.add(car.damage.currentDamage); hc.add(static_cast(*world.randomSeed())); + hc.add(racerCar.body.ics.matrix); + hc.add(racerCar.body.ics.linearVelocity); + hc.add(racerCar.steering); + hc.add(racer.progress()); c.car = hc.h; Hash ht; for (const ai::AmbientCar& a : traffic.cars()) { @@ -423,46 +489,50 @@ ScenarioRun runScenario() { c.props = hp.h; run.checkpoints.push_back(c); const Vec3& p = car.body.ics.matrix.m3; + const Vec3& q = racerCar.body.ics.matrix.m3; run.notes.push_back(std::format("{:2.0f} s: car ({:.2f} {:.2f} {:.2f}) {:.1f} m/s damage {:.0f}, " - "x {:a}; {} cars knocked, {} props moved", + "x {:a}; racer ({:.2f} {:.2f}) {:.0f} m, x {:a}; {} cars knocked, " + "{} props moved", static_cast(c.step) * kDt, p.x, p.y, p.z, car.speed(), - car.damage.currentDamage, p.x, run.knockedCars.size(), moved)); + car.damage.currentDamage, p.x, q.x, q.z, racer.progress(), q.x, + run.knockedCars.size(), moved)); } + run.racerProgress = racer.progress(); return run; } // From a Linux GCC build; every platform must match them. constexpr Checkpoint kExpected[] = { - {120, 0x3a3c70fe586627f4, 0x232b43b2bd3790f5, 0xb09b3c26cd8491f7}, - {240, 0xf44a3b1523419836, 0x07c07745035920ff, 0x1f857ed1cc92639c}, - {360, 0x6636072d75e5e70b, 0x9c83a9eb6e1d0701, 0x4625eefff3380c42}, - {480, 0xcc99546d20f06ace, 0x014e85b48382d19b, 0xd1a0dda3b99b98c2}, - {600, 0xc93d8ea3e4d97515, 0xa45510819c5caa3f, 0x9b20f4fcb7081d7b}, - {720, 0xcbff89324d9fc812, 0x3d9829c47749bf73, 0xcda29e43be99bfc7}, - {840, 0x8857e8bd0f88da1a, 0x1b987cda49fc2236, 0xfbfbd6f4d19d434d}, - {960, 0x9bce1e35dd3b7d09, 0xcaa7d5466fc9d822, 0xfbfbd6f4d19d434d}, - {1080, 0x0f34a38e28017b62, 0x95cd177ee51c5cdf, 0xfbfbd6f4d19d434d}, - {1200, 0xf1cf1f60a2b3cdb7, 0x29d554768d7bae0a, 0xfbfbd6f4d19d434d}, - {1320, 0x9baa47913191c83d, 0x0f2c14141a6658b6, 0xfbfbd6f4d19d434d}, - {1440, 0x62032f6974458ffe, 0x87709e323c4d7f0a, 0xfbfbd6f4d19d434d}, - {1560, 0x8ac89bb2fdc0dca4, 0x1f6a24acb4aa99cb, 0xfbfbd6f4d19d434d}, - {1680, 0xcbabc0f348538d48, 0xaf5f5de0861f667f, 0xfbfbd6f4d19d434d}, - {1800, 0x73e4a52494f9f6e2, 0x5f46e4c1ecfb7e2d, 0xfbfbd6f4d19d434d}, - {1920, 0x13867b1c2b431420, 0xe2f3b1b45a0015f1, 0xfbfbd6f4d19d434d}, - {2040, 0xfcec09922ecc3586, 0x51b5aea0fb69eb7a, 0xfbfbd6f4d19d434d}, - {2160, 0x92995741f1f6e8b2, 0x754b679d69746280, 0xfbfbd6f4d19d434d}, - {2280, 0x9272324f6533ab08, 0xf6e2a9f52d9ac74f, 0xfbfbd6f4d19d434d}, - {2400, 0xf4a8159c6c341e8a, 0x771d45cc7d7d83e6, 0xfbfbd6f4d19d434d}, - {2520, 0xc85c1127eb6826c6, 0x5211769ee6ca9265, 0xfbfbd6f4d19d434d}, - {2640, 0x5b41151bd126696a, 0x3b69c0ce31e6426c, 0xfbfbd6f4d19d434d}, - {2760, 0x4084751e2380d5a7, 0x06921e31cb806c4b, 0xfbfbd6f4d19d434d}, - {2880, 0xa453ea47f6c395c0, 0x23cc0dbda0264c29, 0xfbfbd6f4d19d434d}, - {3000, 0x89728248115b48ff, 0x7528abb5441d5154, 0xfbfbd6f4d19d434d}, - {3120, 0xf5ea3c8def065bb4, 0xad25ecafb12c28e0, 0xfbfbd6f4d19d434d}, - {3240, 0x3c6a79fc019cdc90, 0x241b6275021a6397, 0xfbfbd6f4d19d434d}, - {3360, 0xa3ae9659ea926806, 0x1b566fd8551815e8, 0xfbfbd6f4d19d434d}, - {3480, 0x1efab959e2778136, 0x87dbb2fe9ab88ca4, 0xfbfbd6f4d19d434d}, - {3600, 0xfa6b5022efc7f63a, 0x0a248681704e70f9, 0xfbfbd6f4d19d434d}, + {120, 0xb9fc61f0a349f9a2, 0x232b43b2bd3790f5, 0xb09b3c26cd8491f7}, + {240, 0x540635521cb728b9, 0x94ea2e7cecd6e0b1, 0x38bbe27461086c06}, + {360, 0x9d88dcfac2167282, 0x87fcafeb972a5d2e, 0xee1a772f55dba64c}, + {480, 0x4ad116bf3f84eccb, 0x2a44589a2785d6de, 0xb49fc0ce5fa328a8}, + {600, 0x9a2618aa5ca19c89, 0x9fe15e6e0faf3f8b, 0x98e4a602fc2a3a3d}, + {720, 0xcaf38f1e322e09d5, 0x6593abed1f14f562, 0x825ebd85413cbceb}, + {840, 0x4ec50b1bb7eb0997, 0xd5c1f523d9fef2cb, 0x825ebd85413cbceb}, + {960, 0x98640d6e04e2cd73, 0x2bd57ac11eb5f922, 0x825ebd85413cbceb}, + {1080, 0x6629ce8f18289c1b, 0x3ee2929d8ed8bdb8, 0x825ebd85413cbceb}, + {1200, 0xf1cd29b774301ec0, 0x80e6e7725b647276, 0x825ebd85413cbceb}, + {1320, 0x417af2277cda9976, 0x18f48d9125d2e856, 0x825ebd85413cbceb}, + {1440, 0x31dfdfbc465fa77a, 0x9749166afe81a188, 0xd2f8bb6a6d0de584}, + {1560, 0x19fa78139f0916e7, 0x293aaf0252491628, 0x29a824b688c46e98}, + {1680, 0x83dcad5ac9abe483, 0x673abcc24932fcae, 0x29a824b688c46e98}, + {1800, 0x24047868e2aceabf, 0xb2004c59bf00fd17, 0x29a824b688c46e98}, + {1920, 0x1458eb203c105cba, 0xf4c7e34f98db9e61, 0x29a824b688c46e98}, + {2040, 0x3a3be016a6f2b4c2, 0x432d39bb327cc92f, 0x29a824b688c46e98}, + {2160, 0x07584b4d86718441, 0x6b1b48c1058db9a2, 0x29a824b688c46e98}, + {2280, 0x49d5b9cf9d3a9bed, 0x5eb4b10aa963a647, 0x29a824b688c46e98}, + {2400, 0x1c3a3ef27aa6fc67, 0x22ab742e790c0f59, 0x29a824b688c46e98}, + {2520, 0xd007fe6c977a5624, 0xfa1d2016d85455eb, 0x29a824b688c46e98}, + {2640, 0x42a8cf32a2b8627d, 0xc26c7072a5acbf74, 0x29a824b688c46e98}, + {2760, 0xfaa8eec83e11fb6a, 0x5d65603cd99f89e5, 0x29a824b688c46e98}, + {2880, 0x6c226673a88890f1, 0x50bbcaa345a9e4a2, 0x29a824b688c46e98}, + {3000, 0x923d35ac6bf4743f, 0x9e5a43637794371b, 0x29a824b688c46e98}, + {3120, 0x23b52875cad0fced, 0xbe022c8fe6c62e22, 0x29a824b688c46e98}, + {3240, 0xfa4cd7322a8856da, 0xea31d22a306d04e9, 0x29a824b688c46e98}, + {3360, 0x50a8561a2e4725a0, 0x46abe62bcd50568b, 0x29a824b688c46e98}, + {3480, 0x2a08b6dba7881b61, 0x653350ae42714fad, 0x29a824b688c46e98}, + {3600, 0x10dd98a5bd986876, 0xce46f813fcd23be4, 0x29a824b688c46e98}, }; // The hashes as source, then what happened by each checkpoint (to compare @@ -500,6 +570,7 @@ TEST(Determinism, ScenarioHashesMatchEveryPlatform) { // The scenario does what it is for. EXPECT_GT(run.knockedCars.size(), 2u); EXPECT_GT(run.movedProps, 5); + EXPECT_GT(run.racerProgress, 200.0f); bool same = std::size(kExpected) == run.checkpoints.size(); for (std::size_t i = 0; same && i < run.checkpoints.size(); ++i) { const Checkpoint& got = run.checkpoints[i]; diff --git a/tests/game/test_opponent_race.cpp b/tests/game/test_opponent_race.cpp index e9b8b96..c63f3b8 100644 --- a/tests/game/test_opponent_race.cpp +++ b/tests/game/test_opponent_race.cpp @@ -1017,8 +1017,8 @@ TEST(Determinism, RetailRaceThroughTraffic) { fnvVec(racers, p); const phys::CarSim& sim = r.vehicle->sim(); const phys::InertialCS& ics = sim.body.ics; - for (const Vec3& row : - {ics.matrix.m0, ics.matrix.m1, ics.matrix.m2, ics.matrix.m3, ics.linearVelocity, ics.angularVelocity}) + const Mat34& m = ics.matrix; + for (const Vec3& row : {m.m0, m.m1, m.m2, m.m3, ics.linearVelocity, ics.angularVelocity}) fnvVec(racers, row); fnv(racers, sim.damage.currentDamage); fnv(racers, sim.engine.rpm); From 4eafbbab8f3745f8bcb6c433d0226aea3ea09447 Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Fri, 9 Oct 2026 20:52:46 -0700 Subject: [PATCH 101/129] Record the determinism review docs/review/multiplayer-determinism.md: the C runtimes' differences as measured (glibc against MinGW's under Wine), the accuracy of OpenMM2's own functions, the before and after hashes of the scenario and of London's race1 on both builds, the findings (D1-D4), what was checked and found deterministic, the opponent sweep's move from 516/508 to 515/507 with the variants that show it is the steering's last bit, the tests and what CI should show, and what is left open. docs/physics.md gains "The same results on every platform"; the players' cars record's O6 is closed; the libm header states its accuracy as measured. Co-Authored-By: Claude Opus 5.5 --- docs/physics.md | 49 +++++- docs/review/multiplayer-desync-cars.md | 2 +- docs/review/multiplayer-determinism.md | 222 +++++++++++++++++++++++++ src/core/Libm.h | 16 +- 4 files changed, 279 insertions(+), 10 deletions(-) create mode 100644 docs/review/multiplayer-determinism.md diff --git a/docs/physics.md b/docs/physics.md index 4023527..71063f0 100644 --- a/docs/physics.md +++ b/docs/physics.md @@ -8,8 +8,8 @@ MM2Recomp reference, see CLAUDE.md): `phInertialCS`, `vehCarSim`, `vehWheel`, `vehGyro`, `vehStuck`, `vehTrailer`, `dgTrailerJoint`, `phJoint` and parts of `vehCar`, `vehCarDamage`, `phColliderJointed` and `dgPhysManager`. The code follows the original's operation order and 32-bit float arithmetic (the game -runs the x87 in single precision); `mm2_phys` is built with -`-ffp-contract=off`. +runs the x87 in single precision), and gives the same bits on every +platform (see "The same results on every platform"). ## Evidence levels @@ -63,6 +63,51 @@ times `vehCar::Reset` put the car somewhere) tells the drawing not to blend across a reset, and nothing in the simulation reads it. The AI's fixed 1/30 s steps (`ai::World`) are drawn the same way. +## The same results on every platform + +A network game's host and clients run the same samples (each client +predicts its own car and replays its inputs on every host state), so a +sample must give the same bits whichever compiler and C runtime built the +machine. OpenMM2 makes sure of three things +([review](review/multiplayer-determinism.md)): + +* **Arithmetic.** Every target is built without contraction and without + fast-math (`cmake/CompilerOptions.cmake`: `-ffp-contract=off + -fno-fast-math`, MSVC `/fp:precise`), on SSE2 or AArch64 where float and + double expressions are evaluated in their own type. The basic operations + (+ - * / and sqrt) then round as IEEE 754 says, in the order the source + writes them, on GCC, Clang, MinGW and MSVC alike. A 32-bit x87 build is + not supported. +* **Elementary functions.** The C runtimes' sin, cos, atan2, exp, log and + pow differ in the last bit (glibc's own sinf even depends on whether the + CPU has FMA). The simulation calls OpenMM2's (`core/Libm.h`: sin, cos, + tan, asin, acos, atan, atan2, exp, exp2, log, log2, pow, hypot), written + with the basic operations only. Their float versions are correctly rounded + (exhaustive sweeps of the float arguments in the game's ranges found 0 to + 5 exceptions per function out of 1 to 2 billion), which is what MM2's x87 + code got: fsin, fcos, fpatan, fyl2x and f2xm1 compute in extended + precision whatever the precision control, and the result is stored to a + float. Exact operations (sqrt, fmod, remainder, floor, round, abs) stay + the runtime's. A test (`Determinism.SimulationCallsNoRuntimeTranscendentals`) + fails when a simulation source calls the runtime's again; the drawing, + the cameras and the sound may. +* **Order.** Nothing the simulation iterates depends on an unordered + container's order, an unstable sort's order of equal keys or a pointer's + value, and nothing runs on other threads. + +`Determinism.ScenarioHashesMatchEveryPlatform` (tests/game) proves it in +CI: a car on fixed inputs drives a minute through props, over a jump and +round a block of ambient traffic it knocks loose, on cobbles, with an AI +racer lapping the block, without game data, and the cars', the traffic's +and the props' states are hashed every two seconds against hashes +committed from a Linux GCC build (built against the C runtime's functions +instead, a Linux and a MinGW build differ from the first checkpoint on). +GCC, Clang, MSVC and MinGW +(under Wine) run it; `Determinism.RetailRaceThroughTraffic` does the same +for the first 40 s of London's race1 with game data (locally). When the +simulation changes on purpose, the failing test prints the new table; the +values must be the same on every platform. + ## Rigid body (phInertialCS) — MM2 * Every physics entity's update first adds its weight, Mass * -19.6 on y diff --git a/docs/review/multiplayer-desync-cars.md b/docs/review/multiplayer-desync-cars.md index 293a6d0..3501697 100644 --- a/docs/review/multiplayer-desync-cars.md +++ b/docs/review/multiplayer-desync-cars.md @@ -511,7 +511,7 @@ apart, as in the first round, about 11 and 78 KB/s. | O3 | minor | rules | Checkpoints, laps, finishes and Cops and Robbers' gold were decided on each player's own machine and relayed (a client could claim them); only the limits were the host's. | fixed (protocol 10, docs/review/multiplayer-desync-rules.md): the host runs every car's waypoints after every sample (`game::session::RaceReferee`), the finish exchange, timeout and standings, and Cops and Robbers' rules for every car (`CopsAndRobbers::updateHost`), and tells each player (`game::NetRules`); a client predicts its checkpoints and a pickup, which the host confirms or corrects, and the host refuses a player's own rule events | | O4 | minor | commands | The host checks a client's resets only against the city's box and four a second, not against the race's checkpoints and the water. | fixed (78886cf): `game::ResetRules` (the water, the fall, the race's checkpoints, a wreck's or Cops and Robbers' repair) on the host's simulation of the car; in a race a respawn only at the start or at a checkpoint the host's referee counted for that car (docs/review/multiplayer-desync-rules.md) | | O5 | minor | `CarStatesMsg` | The own car's state is 270 bytes at 20 Hz to every client (78 KB/s for the host with eight players); since protocol 9 about 310 (370 in a contact), and up to two near cars in full: at most about 160 KB/s for the host with eight players all near each other. | open: it could go only when it changed beyond the tolerance, a near car only while the two can meet | -| O6 | minor | MSVC against GCC | Not testable here; the noise test suggests corrections in crashes, eased away. | open | +| O6 | minor | MSVC against GCC | Not testable here; the noise test suggests corrections in crashes, eased away. | fixed (multiplayer-determinism.md): the simulation's maths are OpenMM2's own and every build rounds alike; a MinGW build under Wine gives the Linux build's hashes, and CI checks MSVC and Clang | | O7 | minor | replays | The shared traffic's cars still on their rails are held where the frame placed them while a client runs its samples again. | fixed (e51e9bf, 60ddfd0): where each sample met them (corrections over 10 cm driving through the traffic 3 and 60 against 194 and 205; `poseAt` 34 and 89) | | C6 | visible | `RaceScreen` (the players' cars' movers) | Each machine had its own car first among the world's movers, so two cars collided in a different order on the host and on a client. | fixed (94d542a): player order everywhere | | C7 | visible | `net::OwnCarState` | The full state left out what a sample hands the next (the force and torque set for it, the tyres' rolling resistance, a contact's impulses and pushes): a car rebuilt from it drifted from the host's in a contact. | fixed (38fcf08), protocol 9 | diff --git a/docs/review/multiplayer-determinism.md b/docs/review/multiplayer-determinism.md new file mode 100644 index 0000000..3ac10a5 --- /dev/null +++ b/docs/review/multiplayer-determinism.md @@ -0,0 +1,222 @@ +# Multiplayer determinism review: the same results on every platform + +Reviewed on 2026-10-09 on top of integration at 782f469 (protocol 10, the +host simulating every player's car). The question: does a sample of the +simulation give the same bits on a Windows build (MSVC) as on a Linux build +(GCC or Clang)? Every client predicts its own car (and the nearest players' +cars) and replays its unacknowledged inputs on every host state +(`game::PlayerCars`, `phys::World::replaySample`); the traffic agent is +moving the police and the knocked bodies to the same scheme. Any difference +between the host's machine and a client's becomes a correction. The players' +cars record had it as O6 ("MSVC against GCC: not testable here; the noise +test suggests corrections in crashes"). + +No wire change: the protocol stays as integration has it. + +## Summary + +| | Before | After | +| --- | --- | --- | +| Elementary functions in the simulation | the C runtime's: glibc against MinGW's (Wine's ucrtbase) differ for 21 % of atan2f, 14 % of tanf, 8 % of acosf, 2 % of atanf and about 1 % of sinf / cosf results (a million arguments each in the game's ranges) | OpenMM2's own (`core/Libm.h`), the same bits on every platform; float results correctly rounded, as MM2's x87 got them | +| Float contraction (a * b + c fused) | off for `mm2_phys` only; GCC (`fast` for C++) and Clang (`on`) fuse the AI, props, session and core maths on FMA targets (AArch64, `-march=native`, x86-64-v3) | off for every target, explicit on every compiler | +| A client's knocked traffic cars | collided in an unordered map's order, unstably sorted by room (differs between libstdc++ and MSVC's STL) | room, then id | +| Proof | none | `Determinism.*`: the maths (tests/core/test_libm.cpp) and a data-free minute of a car through props and traffic (tests/game/test_determinism.cpp) hashed against committed values on GCC, Clang, MinGW under Wine and MSVC in CI | +| Data-free scenario, Linux GCC against MinGW under Wine | differs from the first checkpoint (see "Before and after") | identical, all 30 checkpoints | +| London race1 with full traffic, 40 s, Linux GCC against MinGW under Wine | see "Before and after" | identical | +| Single-player results on Linux | | changed where glibc's own sinf, cosf, expf, logf and powf were not correctly rounded; the opponent sweep 516/517 finished and 508/517 across the line became 515/517 and 507/517 (see "Single player") | + +## How it was measured + +* The C runtimes: `crt_compare` (scratch) hashes the runtime's float + functions and OpenMM2's over the same million arguments, built natively + (glibc 2.44, GCC 16.2) and with MinGW-w64 GCC 16.2 run under Wine 11.19. + OpenMM2's hashes agree on both; the runtimes' differ: + + | Function (range) | glibc vs OpenMM2 | Wine (MinGW) vs OpenMM2 | + | --- | --- | --- | + | sinf, cosf (-100 .. 100) | 1.28 %, 1.29 % | 0.13 %, 0.14 % | + | tanf (-1.5 .. 1.5) | 0 | 14.0 % | + | atanf (-50 .. 50) | 0 | 2.4 % | + | atan2f | 0 | 20.6 % | + | acosf | 0 | 7.6 % | + | expf, exp2f, logf, log2f, powf | 0.02-0.06 % | the same as glibc | + | sin, cos in double, rounded to float | 0 | 0 | + | hypotf | 0 | 0 | + + (glibc 2.41 and later round atanf, atan2f, acosf, asinf, tanf and hypotf + correctly; its sinf, cosf, expf, logf and powf come with an FMA variant + chosen by CPU, so even two Linux machines could differ.) +* OpenMM2's functions against glibc's long double functions, every float + argument in the game's ranges (`accuracy`, scratch): sin and cos on + 0 .. 10000 (1.18 billion arguments each), 1 and 0 results not correctly + rounded; tan 0; atan 1 of 1.9 billion; acos 2, asin 0; exp 0, exp2 2; + log 5 and log2 0 of 2.1 billion; pow 0 to 7 per exponent outside the + subnormal results. The double versions are within 0.8 ulp (sin, cos, + exp, log), 2-3 ulp (tan, atan, atan2, asin, acos) and, for pow, about + |y log x| ulp. +* The scenario and race hashes: `test_game --gtest_filter='Determinism.*'` + in the Linux GCC build and in the MinGW cross build under Wine + (`ctest --preset mingw-cross-release -R '^Determinism[.]'`, as CI runs + it; the race with `OPENMM2_GAME_DATA` set). "Before" is the same tests + with `core/Libm.h` made a thin wrapper of the C runtime's functions, which + is what the call sites computed before. +* Uninitialised values: the three `Determinism` tests of `test_game` under + valgrind memcheck: no errors. +* The opponent sweep (`OPENMM2_AI_SWEEP=1`, OpponentRace.EveryRaceSweep) + before and after, and with variants (see "Single player"). + +## Before and after + +The same two tests built from integration's sources (the C runtime's +functions) and from this branch, each natively (glibc 2.44, GCC 16.2) and +with MinGW-w64 GCC 16.2 under Wine 11.19: + +| Test | Before, Linux | Before, MinGW under Wine | After, both | +| --- | --- | --- | --- | +| Data-free scenario: first checkpoint where the two builds differ | 2 s (the AI racer's position, in the last bits) | | none of the 30 | +| ... the player's car at 60 s | (33.95, -177.65), 19 traffic cars knocked loose | (-14.17, -170.70), 20 cars: 48.6 m from the Linux one | (86.81, -274.64), 16 cars, on both | +| ... the AI racer at 60 s | (186.81, -16.14), 590 m along its course | (188.90, -66.28), 589 m: 50.2 m from the Linux one | (192.34, -84.44), 555 m, on both | +| London race1, 40 s, racers / traffic / props hashes | 03267684e640fe14 / 83a8e316fdd11846 / 328635f549c7579e | c09e3e7a5134bda1 / a0c821ec47d8b1a8 / 68f56a0e9133cd51 | 65eb8feebb02f101 / b8f1c125aa480ce2 / fc1321f4c3363732 | +| ... the leading racer at 40 s | (941.06, -34.23) | (941.07, -31.53): 2.7 m from the Linux one | (941.07, -33.45) on both | + +The first version of the scenario (no racer, smooth ground, a few fixed +steering angles) gave the same hashes on both builds before the change too: +glibc and Wine's runtime agree on most arguments, and that scenario never +met one where they do not. The racer, the cobbles and the moving steering +were added so that the test fails on a build whose maths differ. + +## Findings + +### Fixed + +| # | Severity | Location | What | Fix | +| --- | --- | --- | --- | --- | +| D1 | visible | 27 files: `phys` (`vehicle/Wheel.cpp` MakeRotateY and the road bumps, `TrailerJoint`, `vehicle/Transmission` and `vehicle/Controls` pow, `CarSim`, `Trailer`, `AgeMath`), `core/Math.cpp`, `ai` (Driving, DrivingTargets, DrivingRoute, PathGeometry, Traffic, Police, Opponent, Course, AmbientRoute, Pedestrians), `city/AiMap.cpp` (the intersection's road order), `game/session` (RaceSetup, Gate), `game/net` (TrafficPrediction, TrafficSync, PlayerCars, Autopilot), `net/VehicleDamage.h`, `app/Controls.cpp` | The simulation called the C runtime's sin, cos, tan, acos, atan2, exp, exp2, log2, pow and hypot, whose last bits differ between glibc, MSVC's runtime and MinGW's. The steered wheels' rotation, the AI's every steering angle and the damage encoding differed with them. | a0e118e: `core/Libm.h`, used at every call; a test fails when a simulation source calls the runtime again | +| D2 | latent | `src/phys/CMakeLists.txt`, `cmake/CompilerOptions.cmake` | Only `mm2_phys` was built with `-ffp-contract=off`. On FMA targets GCC and Clang fused the AI's, the props', the session's and the core maths' a * b + c, rounding once where the other builds round twice. The release x86-64 builds have no FMA, so it did not show there. | 6d37388: `-ffp-contract=off -fno-fast-math` (MSVC `/fp:precise`) for every target | +| D3 | minor | `game/net/TrafficProxies.cpp` | A client's traffic cars that the host knocked loose were listed in an `unordered_map`'s order and sorted by room with `std::sort`; the physics collides a room's instances in the order listed, and both orders differ between standard libraries. | 16f0e5d: by id, stably sorted by room (as `TrafficBodies`) | +| D4 | latent | `ai/Course.cpp` (`findTurns`), `RaceScreen.cpp` (the host's nearest players for a client) | `std::sort` on keys that can tie (a circuit's turns at the same distance; two players at the same distance): equal keys come out in an order that differs between standard libraries. | 16f0e5d: `stable_sort` | + +### Checked and found deterministic + +* **Evaluation in the operands' type.** x86-64 (SSE2) and AArch64 evaluate + float and double expressions in their own type (`FLT_EVAL_METHOD` 0) on + every CI compiler; no `long double`, no x87 code. A 32-bit x87 build would + differ and is not supported (a test checks `FLT_EVAL_METHOD`, the + rounding mode and that subnormals are kept). +* **Mixed float and double.** C++ fixes the type of every operation; with + no contraction and evaluation in the operands' type the compilers agree. + No unqualified `sin`/`abs`/`sqrt` call whose overload could differ + between headers. +* **Exact functions.** `std::sqrt`, `fmod`, `remainder`, `floor`, `ceil`, + `trunc`, `round`, `lround`, `abs` are exact (or correctly rounded) by + IEEE 754 on every runtime and stay. +* **Random numbers.** MM2's irand / frand on `std::uint32_t` (wrapping is + defined); the cars' own streams (`CarSim::ownRandom`). `` + distributions (which differ between standard libraries) appear only in + the music's variations and in session, port and file names. +* **Unordered containers.** Iterated only in `TrafficProxies` (D3). The + others are looked up only: `TrafficBodies` and `BangerSet` bound caches, + `TrafficSync`'s sets of ids sent, `NetTrafficCars`, `TrafficPrediction` + (its iteration only clears flags), `MapView`'s player tracks, the race + screen's sets, `CityLevel`'s load-time caches, the renderers'. +* **Sorting.** The other sorts in the simulation have no ties: the polygon + soup's ids, the AI's ambient model names, the route search's + (cost, node) pairs, `TrafficBodies`' stable sort of rail cars. +* **Pointer order.** No container or sort keyed on a pointer's value. +* **Threads.** The simulation runs on the main thread only (the music, the + port mapping, the intro movie and the setup screen have their own). +* **Order of evaluation.** No expression draws twice from one random + stream (the comma-separated declarations that do are sequenced). Function + arguments are evaluated in an unspecified order (GCC and MSVC from the + right, Clang from the left), so the Clang CI job is the check that + nothing else depends on it. +* **Uninitialised values.** None under valgrind in the scenario and the + 40 s race. +* **Data.** Numbers are parsed with `std::from_chars` (correctly rounded in + libstdc++ and MSVC's STL); the game files are listed sorted + (`vfs::Vfs::list`). + +## Single player + +The full suite passes unchanged (1009 tests, plus the 8 new ones): no +expected value moved. The single-player simulation on Linux changed where +glibc's own float functions were not correctly rounded: sinf and cosf for +about 1 % of the arguments the AI and the wheels use, expf, logf and powf +for 0.02-0.06 %. Everything else gives the same bits as before (glibc's +atan2f, acosf, tanf and the double sin and cos rounded to float, which +`phys::age` used, are already correctly rounded). + +The opponent sweep went from 516/517 finished and 508/517 across the line +to 515/517 and 507/517: one racer of sf race7 (professional) is wrecked at +92 % of its course; 81 of the 88 races' other figures (times, backups, +resets) changed too. Variants show that this is the sweep's sensitivity to +the last bit of the steering, not a fault: + +| Variant | Finished | Across the line | Races whose line changed | +| --- | --- | --- | --- | +| Before (integration) | 516/517 | 508/517 | | +| After | 515/517 | 507/517 | 81 of 88 | +| After, the runtime's sinf and cosf kept everywhere | 516/517 | 508/517 | 6 of 88 (no count changed) | +| After, the runtime's sinf and cosf kept everywhere but the car's MakeRotateY and road bumps | 514/517 | 507/517 | 81 of 88 | + +MM2 computed these with fsin and fcos, whose results are correctly rounded +when stored to a float; the new values are MM2's, glibc's sinf was not +(and was not the same on every CPU). + +## The determinism tests + +* `Determinism.LibmFloatValues`, `LibmDoubleValues`, `LibmSpecialValues`, + `LibmSweepHashes` (test_core): known values, the C standard's special + cases, and one hash per function over 200 000 arguments. +* `Determinism.SimulationCallsNoRuntimeTranscendentals` (test_core): the + simulation's sources call no runtime sin, cos, atan2, exp, log, pow or + the like (it reads the source tree; skipped without it). +* `Determinism.FloatEnvironmentIsIeee` (test_game). +* `Determinism.ScenarioHashesMatchEveryPlatform` (test_game, no game data): + a made-up sports car (`CarSimParams` defaults with 450 hp) on fixed, + always slightly moving inputs through the steering filter (pow), on + cobbled ground (the wheels' road bumps), along a block of four 200 m roads + with ambient traffic at density 1 (`ai::Traffic` at 30 Hz, + `TrafficBodies`), through 14 made-up props (cones that break loose, + crates, a sign that splits), over a jump, braking into reverse, a + handbrake turn and then circling and weaving through the traffic for the + rest of the minute, while an `ai::Opponent` (the same sports car) laps the + block: 16 traffic cars knocked loose, 27 props moved, the racer 555 m + along its course. The cars, the traffic and the props are hashed every + two seconds. A mismatch reports the first checkpoint that differs and + which of the three, then the platform's table and where the cars were at + each checkpoint (in hex too), to compare with the Linux log. It takes + about 50 ms. +* `Determinism.RetailRaceThroughTraffic` (test_game, game data): the first + 40 s of London's race1 with its six racers and full traffic, hashed + (the racers' trails and states, the traffic, the props). CI has no game + data; it runs locally and on Windows with `OPENMM2_GAME_DATA`. + +CI runs them on all four builds: the Linux GCC and Clang jobs and the +Windows MSVC job with the rest of the suite, the MinGW job under Wine +(`wine` installed in its container, `wineboot --init`, then `ctest -R +'^Determinism[.]'`). + +### What CI should show + +* Linux GCC: everything passes (the hashes are this build's). +* MinGW under Wine: passes; checked here with the same Wine (11.19) and + MinGW GCC (16.2) as the Arch container installs. +* Linux Clang: should pass. Clang evaluates function arguments left to + right where GCC goes right to left; if some simulation code depends on + that order, `ScenarioHashesMatchEveryPlatform` fails and names the first + checkpoint and part (car, traffic, props) that differ. +* Windows MSVC: should pass. MSVC's `/fp:precise` on x64 neither fuses nor + widens, and the maths no longer touches its runtime. A failure there + would point at a remaining source (the report says where to look first: + the checkpoint and part; then the libm tests, which isolate the maths). + +## Open + +| # | Severity | What | Needs | +| --- | --- | --- | --- | +| O1 | unknown | Clang and MSVC could not be run here; CI is the proof. | The PR's CI run. | +| O2 | minor | Where MM2's compiled code keeps an fsin or fpatan result in an x87 register for the next operation (e.g. `sin(a) * r` multiplied before the store), OpenMM2 rounds the function's result to float first. A parity question per call, as before this change. | The parity audits, call by call. | +| O3 | minor | Nothing resets the floating-point environment at run time: a library that set flush-to-zero or another rounding mode on the main thread would change the results (no known one does; the test checks the defaults). | A guard in the frame loop if it is ever seen. | +| O4 | minor | `libm::pow` is accurate to about \|y log x\| ulp in double (float results correctly rounded but for 1 in 10^7 or so); the game uses it for the gearing, the steering curve and the AI's per-frame factors. | Nothing unless a use needs double accuracy. | +| O5 | info | The opponent sweep moved by one car (see "Single player"). | Nothing; the new values are MM2's rounding. | diff --git a/src/core/Libm.h b/src/core/Libm.h index 1908127..7417083 100644 --- a/src/core/Libm.h +++ b/src/core/Libm.h @@ -14,13 +14,15 @@ // and the exact ones (sqrt, floor, the exponent bits), so they give the same // bits on every platform and compiler. // -// Accuracy. The double versions are within about one unit in the last place. -// The float versions are the double result rounded to float: that is what -// MM2's code got from the x87, whose fsin, fcos, fpatan, fyl2x and f2xm1 -// work in extended precision whatever the precision control, the result -// being stored to a float. They are correctly rounded except in the rare -// cases a double result lies within its error of a float's rounding -// boundary, which no test of the game's ranges met (docs/physics.md). +// Accuracy. The double versions are within an ulp (sin, cos, exp, log), two +// or three (tan, the inverse functions) or about |y log x| ulps (pow). The +// float versions are the double result rounded to float: that is what MM2's +// code got from the x87, whose fsin, fcos, fpatan, fyl2x and f2xm1 work in +// extended precision whatever the precision control, the result being +// stored to a float. They are correctly rounded except where a double +// result lies within its error of a float's rounding boundary: 0 to 5 +// arguments per function in exhaustive sweeps of the game's ranges +// (docs/review/multiplayer-determinism.md). // // Not here, because IEEE 754 makes their results exact (or correctly // rounded) everywhere: std::sqrt, std::fmod, std::remainder, std::floor, From f8b0e73018d9da3c3bf3b8f8cc1dde8394c97b80 Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Fri, 9 Oct 2026 20:55:53 -0700 Subject: [PATCH 102/129] Give the CI Wine prefix to the container's own user The MinGW job runs as root in its container, where HOME is the runner's mounted home; Wine refuses a prefix its user does not own. The step uses /tmp/wineprefix. (Locally the same ctest command passes all eight Determinism tests under Wine 11.19 after wineboot --init.) Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a7b2ccc..0cbb3d4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -185,6 +185,9 @@ jobs: # tests: the native jobs run the rest. - name: Test determinism under Wine env: + # A prefix of the container's own user (Wine refuses one it does + # not own), without the Mono and Gecko installers. + WINEPREFIX: /tmp/wineprefix WINEDEBUG: -all WINEDLLOVERRIDES: mscoree,mshtml= run: | From 993c285da1c95de6dd52f95b9c0743b5611728b0 Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Fri, 9 Oct 2026 21:02:50 -0700 Subject: [PATCH 103/129] Smooth the props' rough edges: each client its own area, near cars' knocks Protocol 13. Each client now gets its own PropState: every occupied ring slot, complete and ascending (a slot after the one before it costs a bit), with the states it needs most first, within 1100 bytes: the moving slots within 150 m of its car every time, then its changed ones, its creeping ones, the changed ones elsewhere, the moving ones elsewhere every fourth message, its resting ones every tenth and the others every fortieth; a state that does not fit waits, and slots that waited go first. The knocks still go to everyone, reliably. In a network race the ring of knocked-over props grows by 40 slots, up to 40 a player (at least 80, at most 256), instead of wrapping onto a prop that still moves or was knocked less than 10 s ago (MM2's 40 make a pile-up's props vanish in mid-flight); single player is unchanged. A client predicts the knocks of the other players' cars it simulates (NearCarState) as it does its own car's: those cars drove through props that fell 140-400 ms later, 3-8.5 m behind them. A missed prediction is undone as soon as the host's messages have passed it by the host's usual lag (one by another player's car also once the host had that car away from the prop and this machine's car is not about to hit it), not after 2 s; a mirror its cars pushed goes back to the host's as soon as the host's push would have shown. A client's own pieces knock no placed prop any more: such chain knocks were confirmed 4 times in 9 and once left the client's car tangled in pieces the host never had. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/bangers.md | 11 +- docs/multiplayer.md | 100 ++++++--- docs/parity/openmm2-only.md | 5 +- src/app/RaceScreen.cpp | 53 ++++- src/game/bangers/BangerSet.cpp | 45 ++-- src/game/bangers/BangerSet.h | 22 +- src/game/net/NetProps.cpp | 53 +++-- src/game/net/NetProps.h | 21 +- src/game/net/PropSync.cpp | 282 +++++++++++++++++++------ src/game/net/PropSync.h | 109 +++++++--- src/net/PropState.cpp | 4 +- src/net/PropState.h | 49 +++-- src/net/Protocol.h | 5 +- tests/game/test_prop_sync.cpp | 361 ++++++++++++++++++++++++++++++++- tests/net/test_fuzz.cpp | 16 +- tests/net/test_prop_state.cpp | 74 ++++++- 16 files changed, 1006 insertions(+), 204 deletions(-) diff --git a/docs/bangers.md b/docs/bangers.md index b33d1db..b2d114f 100644 --- a/docs/bangers.md +++ b/docs/bangers.md @@ -195,12 +195,15 @@ needs, all of it inert in a single-player race: the same order everywhere); a hit instance remembers the placed prop it came from (`Instance::source`, with `part` for a BREAKnn piece) or the tag its thrower gave a car part (`Instance::tag`). The ring's slots count how often - they were handed out (`generation`). + they were handed out (`generation`). In a network race the ring grows by + 40 slots, up to 40 a player, when it is about to wrap onto a prop that + still moves or was knocked less than 10 s ago (`setRingGrowth`; MM2's + ring of 40 makes such props disappear in a pile-up). * `takeKnocks` (with `recordKnocks(true)`): the placed props that broke loose (`dgUnhitBangerInstance::Impact`) and what hit them. -* A client (`setReplica`): only its own car may touch its props, and the - props it simulates may knock a placed prop for their first 0.5 s - (`phys::Instance::acceptsContact`); `breakPlaced` takes a +* A client (`setReplica`): only its own car and the other players' cars it + simulates may touch its props, and the props it simulates one another but + no placed prop (`phys::Instance::acceptsContact`); `breakPlaced` takes a prop out of its room as Impact does but without a body; `restoreStanding` undoes a knock its car predicted that the host did not make (Reset for one prop); `showMirror` / `hideMirror` show the host's ring slots in instances diff --git a/docs/multiplayer.md b/docs/multiplayer.md index 1625bb9..3381cd5 100644 --- a/docs/multiplayer.md +++ b/docs/multiplayer.md @@ -215,7 +215,9 @@ cars a message and no traffic hit reports, version 7 the host's props client's in full in `CarStates` and, in every car's full state, what a sample hands the next, version 10 the host's rules (the rules event; a player's own checkpoint, lap, finish and gold events refused; 8 on its own -branch). +branch), version 13 each client its own `PropState` (the states near its car +first), a ring of up to 256 slots and a slot's number after the one before +it in a bit. ### Handshake @@ -267,7 +269,7 @@ attempt within `connectTimeoutMs` (8 s). | 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") | -| PropState | H→C | the host's ring of knocked-over props and thrown car parts (see "Props") | +| PropState | H→C | the host's ring of knocked-over props and thrown car parts, the states near the client's car first (see "Props") | `SessionSettings` holds the session name, city, mode (Cruise, Checkpoint, Circuit, Blitz, Cops & Robbers, Crash Course), race id, laps, time of day, @@ -332,7 +334,7 @@ packet to the game or discovery port, and whatever answers as the router. host only, keeps at most 96 cars' records with at most 1024 patches each, lets at most 64 events wait for their time per car (older ones go into the record at once) and waits at most 2 s for an entry's time. -* **Props.** A `PropState` holds at most 64 slots (ranged: slot 0-63, +* **Props.** A `PropState` holds at most 256 slots, ascending (ranged: slot 0-255, generation 0-15, placed prop 0-32767, piece 0-15, car part 0-19, owner 0-63, paint job 0-15), quantized positions (±8 km), velocities (±128 m/s) and spin (±64 rad/s); a client keeps at most 16 unread, takes them only @@ -956,45 +958,73 @@ them, and dent them, through single player's code. (`dgBangerManager`): the whole prop, one of its BREAKnn pieces, or a part thrown off a car, named by the car (a player's id, or a shared car's catalog index), the part (`damagePartIndex`) and the paint job. +* **The ring grows in a pile-up.** When the host's ring is about to wrap onto + a prop that still moves or was knocked less than 10 s ago (more knocks than + MM2's 40 slots hold, which would make props knocked moments ago disappear + in mid-flight), it grows by 40 slots instead, up to 40 a player (at least + 80, at most 256; `BangerSet::setRingGrowth`, inferred: a player's knocks + then last about as long as in single player). Otherwise it wraps as MM2's + does; a single-player ring never grows. * **Knocks** (`PropKnocks`, reliable, to everyone): the placed props the host broke loose, each with its session time. A machine that reports the race loaded gets every knock so far at once, so props knocked earlier, anywhere, are down when it gets there. -* **The ring** (`PropState`, unreliable, the same message to every client): - every occupied slot, complete (a slot left out is empty from that message's - time), 20 times a second while a prop flies or a slot changed, 5 while props - only creep (slower than 0.5 m/s: a meter sliding down a hill, which phSleep - may never stop), 2 otherwise: +* **The ring** (`PropState`, unreliable, each client its own): every + occupied slot, complete and ascending (a slot left out is empty from that + message's time), with the states that client needs most. Its area is + within 150 m of its car (inferred: well past where a prop still shows as + more than a few pixels): a message 20 times a second while a prop there + flies or a slot there changed (or came into it), 5 while props there only + creep (slower than 0.5 m/s: a meter sliding down a hill, which phSleep may + never stop) or props elsewhere move, 2 otherwise. The states go in by need + until the message would pass 1100 bytes (one unfragmented datagram): + the moving slots of its area every time, then its changed ones, its + creeping ones every fourth message, the changed ones elsewhere, the moving + ones elsewhere every fourth message, its slots at rest every tenth, the + others at rest every fortieth; within each, the nearest first. A state + that does not fit waits for a later message (a changed slot stays changed + until it has gone three times). The knocks go to everyone whatever the + area, so no client misses one: | Field | Encoding | | --- | --- | | header | u32 session time, u32 placement checksum, count | - | slot, generation | 6 + 4 bits; without a state only these travel (still there, at rest) | + | slot, generation | 1 bit (the slot after the one before) or 1 + 8 bits, then 4 bits; without a state only these travel (still there, at rest) | | what | 2 bits; a prop 15, a piece 15 + 4, a car part 1 + 6 + 5 + 4 (owner, part, paint) | | moving | 1 bit | | frame at the CG | 3 × 22 bits over ±8 km (4 mm), smallest-three quaternion (32 bits) | | motion (moving only) | velocity 3 × 13 bits (±128 m/s), spin 3 × 12 bits (±64 rad/s) | - A flying piece takes about 206 bits, a slot at rest 110, a slot only still - there 11. A slot carries its state while it moves (a creeping one every - fourth message), in the three messages after it changed, and in every - tenth otherwise. + A flying piece takes about 204 bits, a slot at rest 108, a slot only still + there 6 (a ring of 256 listed in 192 bytes). * **A client** shows the host's props as the host had them a playout delay in the past (what the messages needed to arrive over the last 3 s plus a send interval, 50-500 ms, as the other players' cars): a knock when it shows that time, the ring in mirror slots interpolated on their velocities - (`net::SnapshotBuffer`). Only its own car may touch its props - (`phys::Instance::acceptsContact`), and the pieces it simulates itself may - knock a standing prop for their first 0.5 s: the other cars pass through - them, since the host decides what they do. Its car hits a - prop at once, as in single player (the impulse, the damage, the prop - breaking loose): a prediction. When the host's knock comes (from its copy - of the car, a playout delay later) the piece simulated here stands in for - the host's until both rest, then hands over to the host's, blended over - 0.4 s; a knock the host has not made 2 s later is undone (the prop stands - again). A mirror its car touches is simulated here from the host's motion - (so a flying cone hits its car as on the host) and stays where it stopped - until the host's has moved and rests again, or 2 s. A piece simulated here + (`net::SnapshotBuffer`). Only its own car and the other players' cars it + simulates along with it (the near ones the host sends in full, + `NearCarState`) may touch its props (`phys::Instance::acceptsContact`), and + the pieces it simulates itself one another but no standing prop (the + host's knocks bring those its pieces knock): the other cars pass through + them, since the host decides what they do. Those cars hit a prop at once, + as in single player (the impulse, the damage, the prop breaking loose): a + prediction (before protocol 13 another player's car drove through a prop + that fell 150-400 ms later). When the + host's knock comes (from its copy of the car, a playout delay later) the + piece simulated here stands in for the host's until both rest, then hands + over to the host's, blended over 0.4 s. A knock the host has not made is + undone (the prop stands again): one by its own car once the host's + messages have passed its time by the host's usual lag behind its knocks + (the most of the last 16) and 0.3 s, at least 0.6 s; one by another + player's car (which it runs ahead on that player's last input, and which + may reach a prop seconds before the real one) once the host's states had + that car more than 6 m from the prop by then as well, and its own car is + more than 15 m from it (about to knock it itself, which the host's knock + would then confirm); any after 2 s. A mirror its cars touch is simulated + here from the host's motion (so a flying cone hits its car as on the host) + and stays where it stopped until the host's has moved and rests again, or + as long as the host's push would take to show here (the lag above and + the playout delay, at most 2 s). A piece simulated here that the host's ring does not hold disappears 2 s after it was made once at rest. The parts the host's damage records take off a car (its own included) come off without a throw: the host's ring shows the parts it @@ -1033,13 +1063,15 @@ correction of its car, and a client's corrections over 30 cm just after a prop hit (nothing else around) went from 31 in eight runs to 7. The host sent 0.1-5 KB/s to each client (10 s averages; a big crash in the test, 27 props knocked by one car, 5 KB/s for a few seconds, at most 425 bytes a -message); the ring bounds a message at 40 slots, about 1 KB. +message); a message stays within 1100 bytes, the states nearest the +client first. **Deviations.** MM2 lets each machine knock its own props; OpenMM2 shows the host's everywhere, as it does the traffic. The ring's wrapping (the oldest knocked-over prop disappears when the 41st is knocked) is the host's on every -machine. A client's predicted knock that the host does not make stands up -again, and a client's pieces move to the host's place when they rest. +machine, and in a pile-up the ring grows instead. A client's predicted knock +that the host does not make stands up again, and a client's pieces move to +the host's place when they rest. ### Rules @@ -1357,8 +1389,9 @@ When `config.multiplayer && ctx.netGame`: resets (`DamageRecorder`: `own()`, `player(id)`, `police(id)`), sent with `NetDamage::send` after the frame's effects. 8. **Props** (`game::NetProps`, see "Props"): set up once the props are - placed (`setup`, with this machine's car as the only thing that may touch - a client's props); a client's `beforeStep` before the physics step (the + placed (`setup`, with this machine's car and the other players' cars it + simulates as the only things that may touch a client's props, and on the + host where each client's car is); a client's `beforeStep` before the physics step (the host's messages and knocks, the mirrors placed for the session time the simulation will reach), every machine's `afterStep` after the props' update (`BangerSet::update`: the host sends, a client takes its own @@ -1527,8 +1560,11 @@ client in one process through a lossy link: the host's knocks and pieces, a predicted knock handed over and one undone, other cars passing through a client's props, a replayed car meeting a prop as a real sample does (through the knock, and from 1-8 samples after it), a replay moving no prop -for real, loss and reordering, a big crash, the catch-up, thrown car parts, refused input, -and on retail data the same placement on every machine). +for real, loss and reordering, a big crash, a pile-up growing the ring, two +clients far apart each getting the states of its own area first within a +small datagram, a near car's knock predicted and one undone, the catch-up, +thrown car parts, refused input, and on retail data the same placement on +every machine). ### Diagnosing replication diff --git a/docs/parity/openmm2-only.md b/docs/parity/openmm2-only.md index 66f3259..c541ecb 100644 --- a/docs/parity/openmm2-only.md +++ b/docs/parity/openmm2-only.md @@ -165,8 +165,9 @@ in the summary above. | OpenMM2 | MM2 | Verdict | Notes | | --- | --- | --- | --- | -| `game::PropHost`, `PropClient`, `NetProps`, `net::PropStateMsg`, `PropKnocksEvent` | (none) | openmm2 | The host simulates the props for everyone and sends the knocks (reliable, all of them to a machine that has loaded the race) and its ring of knocked-over props (`dgBangerManager`'s 40 slots); a client shows them a playout delay in the past, predicts its own car's knocks and hands them over (docs/multiplayer.md, "Props"). | -| `BangerSet::setReplica`, `phys::Instance::acceptsContact` | `dgPhysManager::CollideInstances` | deviation | A client's props take contacts from its own car and its own simulated props only; MM2's take them from every mover. | +| `game::PropHost`, `PropClient`, `NetProps`, `net::PropStateMsg`, `PropKnocksEvent` | (none) | openmm2 | The host simulates the props for everyone and sends the knocks (reliable, all of them to a machine that has loaded the race) and its ring of knocked-over props (`dgBangerManager`'s 40 slots); a client shows them a playout delay in the past, predicts its own car's knocks and hands them over (docs/multiplayer.md, "Props"). Since protocol 13 each client gets its own message: every occupied slot, the states near its car (150 m) first within 1100 bytes, the others less often; a client also predicts the knocks of the other players' cars it simulates (`NearCarState`), and undoes a missed one as soon as the host's messages (and, for another player's car, the host's state of it) show it missed. | +| `BangerSet::setReplica`, `phys::Instance::acceptsContact` | `dgPhysManager::CollideInstances` | deviation | A client's props take contacts from its own car and the other players' cars it simulates along with it (`NearCarState`), and its own simulated props one another (no placed prop: the host's knocks bring the chain knocks); MM2's take them from every mover. | +| `BangerSet::setRingGrowth` (network races) | `dgBangerManager::GetBanger` (the ring of 40, `dgBangerManager::Init(40)`) | deviation | When the ring is about to wrap onto a prop that still moves or was handed out less than 10 s ago, it grows by 40 slots instead, up to 40 a player (at least 80, at most 256); MM2 wraps and the oldest knocked-over prop disappears, in a pile-up while still flying. Off in single player. Test: `PropSync.APileUpGrowsTheRing`. | | `BangerSet::breakPlaced`, `restoreStanding`, `showMirror`, `hideMirror` | `dgUnhitBangerInstance::Impact`, `Reset`, `dgHitBangerInstance` | openmm2 | A client's placed prop leaves its room as Impact makes it leave (without a body), stands again as Reset makes it stand (a prediction the host did not confirm), and the host's pieces are hit instances of their own. | | `BangerSet::attachEntity` on a mirror | `dgBangerActive::Attach` | deviation | MM2 starts an active at rest (a placed or resting prop is); a client's mirror of a host piece in flight starts from the host's motion. | | `phys::World::collideHeld` with `Instance::heldInertia`, `World::beginReplay`, `BangerSet::replayGhost` | (none: MM2 does not replay) | openmm2 | A client's replayed car meets a prop with the body a real hit gives it (`dgBangerActive::Attach`'s), which moves through the replay only as the hits push it (no gravity or city: inferred stand-in for its flight); a prop the car broke loose in the last second stands again for the replayed samples up to the one that broke it loose, so the replay takes the same knock, and the later ones meet its pieces where they were (`World::replayTime`), a simulated body moved on over the sample by its velocity, as a real sample moves it before the collisions. | diff --git a/src/app/RaceScreen.cpp b/src/app/RaceScreen.cpp index 91ad5d5..6f53d93 100644 --- a/src/app/RaceScreen.cpp +++ b/src/app/RaceScreen.cpp @@ -3681,8 +3681,17 @@ class RaceScreen final : public Screen { // OpenMM2: a network race's props are the host's (game/net/NetProps). if (multiplayer(ctx)) { m_netLocalId = ctx.netGame->localId(); - m_netProps.setup(*ctx.netGame, *m_bangers, - [this](const phys::Instance& other) { return ownCarBody(&other); }); + m_netProps.setup( + *ctx.netGame, *m_bangers, + [this](const phys::Instance& other) { return propMover(&other); }, + [this](std::uint8_t id) { return netCarAt(id); }); + m_netProps.setClientCars([this](const phys::Instance* b) { return propMoverCar(b); }, + [this](int id) { return hostCarState(id); }, + [this]() -> std::optional { + if (!m_player) + return std::nullopt; + return m_player->sim().body.ics.matrix.m3; + }); } } // aiMap::Init ends with the police cars (aiPoliceOfficer::Init -> @@ -5910,6 +5919,46 @@ class RaceScreen final : public Screen { return trailer && other == &trailer->body; } + // A client: what may set its props moving (the host decides what the + // others do): its own car, and the other players' cars it simulates + // along with it (predictNearCars), whose knocks it predicts as its own. + bool propMover(const phys::Instance* other) const { + if (ownCarBody(other)) + return true; + for (const auto& [id, rv] : m_remotes) + if (rv.predicted && rv.sim && other == &rv.sim->sim().body) + return true; + return false; + } + + // A client: which player's car set a prop moving (-1: none of them). + int propMoverCar(const phys::Instance* other) const { + if (!other) + return -1; + if (ownCarBody(other)) + return m_netLocalId; + for (const auto& [id, rv] : m_remotes) + if (rv.predicted && rv.sim && other == &rv.sim->sim().body) + return id; + return -1; + } + // A client: where the host had player `id`'s car, and when (its states + // as this machine shows them). + std::optional> hostCarState(int id) const { + for (const auto& rc : m_remoteCars) + if (rc.id == id && rc.hasState) + return std::pair{rc.time, rc.transform.m3}; + return std::nullopt; + } + + // Host: where a client's car is (what its props messages carry first). + std::optional netCarAt(std::uint8_t id) const { + const auto it = m_remotes.find(id); + if (it == m_remotes.end() || !it->second.simulated || !it->second.placed || !it->second.sim) + return std::nullopt; + return it->second.sim->sim().body.ics.matrix.m3; + } + // What set a prop moving, for OPENMM2_NET_TRACE's props lines. std::string propToucher(const phys::Instance* by) const { if (!by) diff --git a/src/game/bangers/BangerSet.cpp b/src/game/bangers/BangerSet.cpp index 142be46..087a329 100644 --- a/src/game/bangers/BangerSet.cpp +++ b/src/game/bangers/BangerSet.cpp @@ -547,7 +547,12 @@ std::size_t BangerSet::getBanger() { // so after each wrap slot 0 is handed out twice in a row (MM2's // behaviour, kept). std::size_t slot; - if (m_ringNext == kMaxHit) { + if (m_ringNext == m_ringSize && m_ringSize < m_ringLimit && ringSlotBusy(0)) { + // OpenMM2 (network games): the oldest prop is still busy: the ring + // grows rather than wrapping onto it (setRingGrowth). + m_ringSize = std::min(m_ringSize + kMaxHit, m_ringLimit); + slot = static_cast(m_ringNext++); + } else if (m_ringNext == m_ringSize) { slot = 0; m_ringNext = 0; } else { @@ -563,8 +568,10 @@ std::size_t BangerSet::getBanger() { m_instances[i].state = State::Gone; m_ring.push_back(i); m_ringGeneration.push_back(0); + m_ringHandedAt.push_back(0.0); } ++m_ringGeneration[slot]; // OpenMM2: a new prop in the slot (network games) + m_ringHandedAt[slot] = m_world ? m_world->time() : 0.0; const std::size_t i = m_ring[slot]; // Its previous prop disappears. if (Active* a = activeOf(i)) @@ -575,6 +582,22 @@ std::size_t BangerSet::getBanger() { return i; } +void BangerSet::setRingGrowth(int limit, double busySeconds) { + m_ringLimit = std::max(limit, m_ringSize); + m_ringBusySeconds = busySeconds; +} + +bool BangerSet::ringSlotBusy(std::size_t slot) const { + if (slot >= m_ring.size()) + return false; + const std::size_t i = m_ring[slot]; + if (m_instances[i].state == State::Gone) + return false; + if (m_instances[i].active >= 0) + return true; // still moving + return m_world && m_world->time() - m_ringHandedAt[slot] < m_ringBusySeconds; +} + // --- Actives --------------------------------------------------------------------------------- BangerSet::Active* BangerSet::activeOf(std::size_t i) { @@ -1205,18 +1228,16 @@ bool BangerSet::acceptsFrom(std::size_t i, const phys::Instance& other) const { return true; if (m_localToucher && m_localToucher(other)) return true; - // The props this machine simulates (its predictions) go on knocking - // the placed props as they would while they are young (later the - // simulation here has drifted from the host's, and such a knock is - // mostly undone); the host's pieces move only for this machine's car - // (two of them lying together would otherwise keep each other moving - // here while the host's rest). - if (m_instances[i].mirror) + // The props this machine simulates (its predictions) meet one another, + // but knock no placed prop: the host's pieces of the same knock fly + // differently, and such a knock was more often undone than not (5 of 9 + // in the measured runs; one left the client's car tangled in pieces the + // host never had). The host's knocks bring those it makes. The host's + // pieces move only for this machine's cars (two of them lying together + // would otherwise keep each other moving here while the host's rest). + if (m_instances[i].mirror || !m_instances[i].everHit) return false; - for (const auto& a : m_active) - if (&a->body == &other) - return a->age <= kPredictedChainSeconds; - return false; + return isActiveBody(&other); } void BangerSet::breakPlaced(std::size_t i) { diff --git a/src/game/bangers/BangerSet.h b/src/game/bangers/BangerSet.h index aeccd3f..5f2f218 100644 --- a/src/game/bangers/BangerSet.h +++ b/src/game/bangers/BangerSet.h @@ -216,6 +216,13 @@ class BangerSet final : public InstanceSource { std::uint32_t generation(std::size_t slot) const { return slot < m_ringGeneration.size() ? m_ringGeneration[slot] : 0; } + // OpenMM2 (network games): when the ring is about to wrap onto a slot + // whose prop still moves or was handed out less than `busySeconds` ago + // (a pile-up of more knocks than the ring holds), it grows by kMaxHit + // slots instead, up to `limit` (MM2's ring of 40 makes those props + // disappear in mid-flight). Off (the limit kMaxHit) in single player. + void setRingGrowth(int limit, double busySeconds); + int ringSize() const { return m_ringSize; } // Whether instance i has an active whose body is moving in the world. bool moving(std::size_t i) const; // Whether `i` is the body of one of this set's actives. @@ -233,14 +240,12 @@ class BangerSet final : public InstanceSource { bool replayGhost(std::size_t i) const; bool replayGhostPiece(std::size_t i) const; static constexpr double kGhostSeconds = 1.0; - // A client's own simulated prop knocks others for this long after it - // was set moving (a prediction of a prediction; inferred). - static constexpr float kPredictedChainSeconds = 0.5f; - // A network client: only this machine's own car (`localToucher`) and the - // props this set simulates itself may touch a prop (phys::Instance:: + // A network client: only this machine's cars (`localToucher`: its own + // and the other players' it simulates) may touch a prop, and the props + // this set simulates itself one another (phys::Instance:: // acceptsContact); everything else passes through them, since the host - // decides what the other cars do to them. + // decides what the other cars do to them (and what its pieces knock). void setReplica(std::function localToucher); // Back to simulating every contact (a client that cannot follow the // host's props). @@ -329,7 +334,12 @@ class BangerSet final : public InstanceSource { std::vector m_activeList; // the attached ones (the list's head), for drawing std::vector m_ring; // dgBangerManager's hit instances by slot std::vector m_ringGeneration; // times each slot was handed out + std::vector m_ringHandedAt; // world time each slot was last handed out int m_ringNext = 0; + int m_ringSize = kMaxHit; // OpenMM2: grows in network games (setRingGrowth) + int m_ringLimit = kMaxHit; + double m_ringBusySeconds = 0.0; + bool ringSlotBusy(std::size_t slot) const; // OpenMM2 network games. std::vector m_knocks; bool m_recordKnocks = false; diff --git a/src/game/net/NetProps.cpp b/src/game/net/NetProps.cpp index e2a0139..cff9c88 100644 --- a/src/game/net/NetProps.cpp +++ b/src/game/net/NetProps.cpp @@ -7,11 +7,18 @@ #include "game/net/NetGame.h" #include "net/PropState.h" +#include #include #include #include namespace mm2::game { +namespace { + +// The ring grows rather than wrap onto a prop knocked this recently. +constexpr double kRingBusySeconds = 10.0; + +} // namespace // --- Trace --------------------------------------------------------------------------------------- @@ -84,8 +91,9 @@ NetProps::NetProps() = default; NetProps::~NetProps() = default; void NetProps::setup(NetGame& net, bangers::BangerSet& set, - std::function localToucher) { + std::function localToucher, CarOf carOf) { m_set = &set; + m_carOf = std::move(carOf); m_catalog = propCatalog(set); m_trace = PropTrace::of(net); if (m_trace) @@ -97,6 +105,13 @@ void NetProps::setup(NetGame& net, bangers::BangerSet& set, placed, m_catalog); return; } + // OpenMM2: a pile-up of more knocks than MM2's ring of 40 holds (the + // oldest still moving, or knocked in the last 10 s) grows it, up to 40 a + // player (inferred: a player's knocks then last about as long as in + // single player). + const int players = static_cast(std::max(2, net.players().size())); + set.setRingGrowth(std::min(bangers::BangerSet::kMaxHit * players, static_cast(net::kMaxPropSlots)), + kRingBusySeconds); if (net.isHost()) { m_host.emplace(); } else { @@ -120,7 +135,7 @@ void NetProps::beforeStep(NetGame& net, std::span events, do if (const auto knocks = ev.as()) m_client->receiveKnocks(*knocks); } - m_client->update(*m_set, now, resolve); + m_client->update(*m_set, now, resolve, m_hostCar, m_ownCarAt ? m_ownCarAt() : std::nullopt); } void NetProps::sendCatchUps(NetGame& net, std::uint32_t time) { @@ -153,19 +168,19 @@ void NetProps::afterStep(NetGame& net, double now, std::uint64_t nowMs, const Cl net.sendEvent(net::kPropKnocksEvent, std::move(payload)); } sendCatchUps(net, time); - if (const auto msg = m_host->build(*m_set, time, nowMs, m_catalog)) { - for (const auto& p : net.players()) { - if (p.id == net.localId() || !net.playerLoaded(p.id)) - continue; - const std::size_t bytes = net.sendPropState(p.id, *msg); - m_sentBytes += bytes; - m_host->stats().bytes += bytes; - ++m_sentMessages; - } + std::vector viewers; + for (const auto& p : net.players()) + if (p.id != net.localId() && net.playerLoaded(p.id)) + viewers.push_back({p.id, m_carOf ? m_carOf(p.id) : std::nullopt}); + for (const auto& [id, msg] : m_host->build(*m_set, time, nowMs, m_catalog, viewers)) { + const std::size_t bytes = net.sendPropState(id, msg); + m_sentBytes += bytes; + m_host->stats().bytes += bytes; + ++m_sentMessages; } } if (m_client) { - m_client->predicted(knocks, now); + m_client->predicted(knocks, now, net.localId(), m_carOfToucher); for (const auto& [prop, undone] : m_client->takeApplied()) { if (!m_trace) continue; @@ -209,21 +224,25 @@ void NetProps::logStats(NetGame& net, std::uint64_t nowMs) { const std::size_t clients = std::max(1, net.players().size() - 1); if (s.messages > 0 || m_sentEvents > 0) log::info("netprops: host sent {:.1f} messages/s ({:.0f} bytes/s to each of {} clients, {:.1f} " - "slots and {:.1f} states a message), {} knocks in {} events ({} bytes)", - static_cast(s.messages) / seconds, + "slots and {:.1f} states a message, {:.0f}% of them near; {} deferred), ring {}, {} " + "knocks in {} events ({} bytes)", + static_cast(s.messages) / seconds / static_cast(clients), static_cast(m_sentBytes) / seconds / static_cast(clients), clients, s.messages ? static_cast(s.slots) / static_cast(s.messages) : 0.0, s.messages ? static_cast(s.fullStates) / static_cast(s.messages) : 0.0, - s.knocks, m_sentEvents, m_eventBytes); + s.fullStates + ? 100.0 * static_cast(s.nearStates) / static_cast(s.fullStates) + : 0.0, + s.deferred, m_set->ringSize(), s.knocks, m_sentEvents, m_eventBytes); s = {}; m_sentBytes = m_sentMessages = m_sentEvents = m_eventBytes = 0; } if (m_client) { const auto& s = m_client->stats(); log::info("netprops: client {} messages ({} late, {} refused), host knocks {}, predicted {} " - "(confirmed {}, undone {}), hand-overs {}, orphans {}, delay {:.0f} ms", + "(confirmed {}, undone {}, {} of them early), hand-overs {}, orphans {}, delay {:.0f} ms", s.messages, s.outdated, s.refused, s.knocks, s.predicted, s.confirmed, s.undone, - s.handovers, s.orphans, m_client->delayMs()); + s.undoneEarly, s.handovers, s.orphans, m_client->delayMs()); } } diff --git a/src/game/net/NetProps.h b/src/game/net/NetProps.h index 447d08c..c57acdf 100644 --- a/src/game/net/NetProps.h +++ b/src/game/net/NetProps.h @@ -75,8 +75,23 @@ class NetProps { // `localToucher` (its own car). OPENMM2_NETPROPS=local leaves every // machine with its own props, as MM2 and OpenMM2 0.3 did (for // comparison). + // `carOf` (host): where a client's car is, for what its messages carry + // first (none: anywhere). In a network race the ring of knocked-over + // props grows in a pile-up, up to kMaxHit a player (BangerSet:: + // setRingGrowth). + using CarOf = std::function(std::uint8_t)>; void setup(NetGame& net, bangers::BangerSet& set, - std::function localToucher); + std::function localToucher, CarOf carOf = {}); + // Client: which player's car touched a prop (its predictions), where + // the host had a player's car when, and where this machine's car is (its + // missed predictions undone). + using OwnCarAt = std::function()>; + void setClientCars(PropClient::CarOfToucher carOfToucher, PropClient::HostCar hostCar, + OwnCarAt ownCarAt) { + m_carOfToucher = std::move(carOfToucher); + m_hostCar = std::move(hostCar); + m_ownCarAt = std::move(ownCarAt); + } bool host() const { return m_host.has_value(); } bool client() const { return m_client.has_value(); } // A client takes the cars' thrown parts from the host's ring (except @@ -111,6 +126,10 @@ class NetProps { std::optional m_client; std::uint32_t m_catalog = 0; std::set m_caughtUp; // host: players sent every knock so far + CarOf m_carOf; + PropClient::CarOfToucher m_carOfToucher; + PropClient::HostCar m_hostCar; + OwnCarAt m_ownCarAt; std::unique_ptr m_trace; bool m_traced = false; std::uint64_t m_statsAt = 0; diff --git a/src/game/net/PropSync.cpp b/src/game/net/PropSync.cpp index 05c9475..273366c 100644 --- a/src/game/net/PropSync.cpp +++ b/src/game/net/PropSync.cpp @@ -42,6 +42,17 @@ Mat34 blendMatrix(const Mat34& from, const Mat34& to, float t) { constexpr std::uint32_t kTagMarker = 0x80000000u; +// The bits of a slot without its state (PropHost::build): the one after +// the slot before it, and any other. +net::PropSlot bareSlot(std::uint8_t slot) { + net::PropSlot p; + p.slot = slot; + p.hasState = false; + return p; +} +const std::size_t kNextBits = net::propSlotBits(bareSlot(1), 0); +const std::size_t kJumpBits = net::propSlotBits(bareSlot(2), 0); + // What a slot of the host's ring holds this frame (PropHost::build). struct RingSlot { std::optional what; @@ -177,11 +188,21 @@ std::vector> PropHost::catchUp(const BangerSet& set, std: std::optional PropHost::build(const BangerSet& set, std::uint32_t time, std::uint64_t nowMs, std::uint32_t catalog) { + const Viewer one{1, std::nullopt}; + auto msgs = build(set, time, nowMs, catalog, std::span(&one, 1)); + if (msgs.empty()) + return std::nullopt; + return std::move(msgs.front().second); +} + +std::vector> PropHost::build(const BangerSet& set, + std::uint32_t time, + std::uint64_t nowMs, + std::uint32_t catalog, + std::span viewers) { + // The ring this frame, the same for every client. const auto& ring = set.ring(); const std::size_t slots = std::min(ring.size(), net::kMaxPropSlots); - if (m_seen.size() < slots) - m_seen.resize(slots); - bool busy = !m_sentAny, creeping = false; std::vector current(slots); for (std::size_t k = 0; k < slots; ++k) { const auto& inst = set.instances()[ring[k]]; @@ -193,66 +214,156 @@ std::optional PropHost::build(const BangerSet& set, std::uint if (const phys::Body* body = set.body(ring[k])) c.slow = body->ics.linearVelocity.mag2() < m_options.slowSpeed * m_options.slowSpeed && body->ics.angularVelocity.mag2() < m_options.slowSpin * m_options.slowSpin; - SlotSeen& seen = m_seen[k]; - const bool occupied = c.what.has_value(); - const std::uint32_t generation = set.generation(k); - if (seen.generation != generation || seen.occupied != occupied || seen.moving != c.moving) { - seen.generation = generation; - seen.occupied = occupied; - seen.moving = c.moving; - seen.fresh = m_options.freshMessages; - } - if ((c.moving && !c.slow) || (occupied && seen.fresh > 0)) - busy = true; - creeping = creeping || c.slow; } - const std::uint32_t interval = busy ? m_options.intervalMs - : creeping ? m_options.slowIntervalMs - : m_options.idleIntervalMs; - if (m_sentAny && nowMs - m_lastSent < interval) - return std::nullopt; - - net::PropStateMsg msg; - msg.time = time; - msg.catalog = catalog; - const int rotation = std::max(1, m_options.restRotation); - for (std::size_t k = 0; k < slots; ++k) { - const RingSlot& c = current[k]; - if (!c.what) + const float near2 = m_options.nearRadius * m_options.nearRadius; + const auto rotation = static_cast(std::max(1, m_options.restRotation)); + const auto farRotation = static_cast(std::max(1, m_options.farRotation)); + const auto farRest = static_cast(std::max(1, m_options.farRestRotation)); + std::vector> out; + for (const Viewer& v : viewers) { + ViewerState& view = m_viewers[v.id]; + if (view.seen.size() < slots) + view.seen.resize(slots); + // What changed for this client: a slot's prop, its motion, or the + // slot coming into its area. + bool busy = !view.sentAny, creeping = false, farMoving = false; + std::vector distance2(slots, 0.0f); + for (std::size_t k = 0; k < slots; ++k) { + const RingSlot& c = current[k]; + SlotSeen& seen = view.seen[k]; + const bool occupied = c.what.has_value(); + if (occupied && v.at) + distance2[k] = set.instances()[ring[k]].matrix.m3.dist2(*v.at); + const bool near = !v.at || distance2[k] < near2; + const std::uint32_t generation = set.generation(k); + if (seen.generation != generation || seen.occupied != occupied || seen.moving != c.moving || + (near && !seen.near)) { + seen.generation = generation; + seen.occupied = occupied; + seen.moving = c.moving; + seen.fresh = m_options.freshMessages; + } + seen.near = near; + if (!occupied) + continue; + if (near) { + if ((c.moving && !c.slow) || seen.fresh > 0) + busy = true; + creeping = creeping || c.slow; + } else { + farMoving = farMoving || c.moving || seen.fresh > 0; + } + } + const std::uint32_t interval = busy ? m_options.intervalMs + : creeping || farMoving ? m_options.slowIntervalMs + : m_options.idleIntervalMs; + if (view.sentAny && nowMs - view.lastSent < interval) continue; - SlotSeen& seen = m_seen[k]; - const auto& inst = set.instances()[ring[k]]; - net::PropSlot p; - p.slot = static_cast(k); - p.generation = static_cast(seen.generation % net::kPropGenerations); - // A creeping prop's state every fourth message while others move - // fast (every one at the slow interval). - const bool slowDue = !busy || (k + m_sequence) % 4 == 0; - p.hasState = (c.moving && (!c.slow || slowDue)) || seen.fresh > 0 || - static_cast(k % static_cast(rotation)) == - static_cast(m_sequence % static_cast(rotation)); - if (seen.fresh > 0) - --seen.fresh; - if (p.hasState) { - p.what = *c.what; - p.moving = c.moving; - p.position = inst.matrix.m3; - p.orientation = Quat::fromMatrix(inst.matrix); + + // Every occupied slot, in order; the states by priority within the + // datagram: near and moving, near and changed, near and creeping, + // far and changed, far and moving, near at rest, far at rest. + net::PropStateMsg msg; + msg.time = time; + msg.catalog = catalog; + std::vector listed; + for (std::size_t k = 0; k < slots; ++k) { + if (!current[k].what) + continue; + net::PropSlot p; + p.slot = static_cast(k); + p.generation = static_cast(view.seen[k].generation % net::kPropGenerations); + p.hasState = false; + msg.slots.push_back(p); + listed.push_back(k); + } + const std::uint32_t seq = view.sequence; + struct Due { + int priority = 0; + float distance2 = 0.0f; + std::size_t index = 0; // in msg.slots + }; + std::vector due; + for (std::size_t n = 0; n < listed.size(); ++n) { + const std::size_t k = listed[n]; + const RingSlot& c = current[k]; + const SlotSeen& seen = view.seen[k]; + const auto key = static_cast(k); + int priority = -1; + if (seen.near) { + const bool slowDue = !busy || (key + seq) % 4 == 0; + if (c.moving && !c.slow) + priority = 0; + else if (seen.fresh > 0) + priority = 1; + else if (c.moving && slowDue) + priority = 2; + else if (key % rotation == seq % rotation) + priority = 5; + } else { + if (seen.fresh > 0) + priority = 3; + else if (c.moving && (!busy || (key + seq) % farRotation == 0)) + priority = 4; + else if (key % farRest == seq % farRest) + priority = 6; + } + // The nearest first, one that has waited longer as if nearer (a + // pile-up with more pieces flying than a datagram holds gets + // them all in turn). + const auto waited = static_cast(seq - seen.lastState); + if (priority >= 0) + due.push_back({priority, (distance2[k] + 1.0f) / ((1.0f + waited) * (1.0f + waited)), n}); + } + std::ranges::sort(due, [](const Due& a, const Due& b) { + return a.priority != b.priority ? a.priority < b.priority : a.distance2 < b.distance2; + }); + // The listing's bits, then each state's on top while they fit. + std::size_t bits = 8 * (1 + 4 + 4 + 5); // type, time, catalog, count (at most) + for (std::size_t n = 0; n < msg.slots.size(); ++n) + bits += n > 0 && msg.slots[n].slot == msg.slots[n - 1].slot + 1 ? kNextBits : kJumpBits; + const std::size_t budget = m_options.maxBytes * 8; + for (const Due& d : due) { + net::PropSlot& p = msg.slots[d.index]; + const std::size_t k = listed[d.index]; + const RingSlot& c = current[k]; + const auto& inst = set.instances()[ring[k]]; + const std::int32_t previous = d.index == 0 ? -1 : msg.slots[d.index - 1].slot; + net::PropSlot full = p; + full.hasState = true; + full.what = *c.what; + full.moving = c.moving; + full.position = inst.matrix.m3; + full.orientation = Quat::fromMatrix(inst.matrix); if (c.moving) if (const phys::Body* body = set.body(ring[k])) { - p.velocity = body->ics.linearVelocity; - p.angularVelocity = body->ics.angularVelocity; + full.velocity = body->ics.linearVelocity; + full.angularVelocity = body->ics.angularVelocity; } + const bool next = previous >= 0 && p.slot == previous + 1; + const std::size_t extra = net::propSlotBits(full, previous) - (next ? kNextBits : kJumpBits); + if (bits + extra > budget) { + ++m_stats.deferred; + continue; // a later message (it stays fresh, or comes round again) + } + bits += extra; + p = full; + SlotSeen& seen = view.seen[k]; + if (seen.fresh > 0) + --seen.fresh; + seen.lastState = seq; ++m_stats.fullStates; + if (seen.near) + ++m_stats.nearStates; } - msg.slots.push_back(p); + view.lastSent = nowMs; + view.sentAny = true; + ++view.sequence; + ++m_stats.messages; + m_stats.slots += msg.slots.size(); + out.emplace_back(v.id, std::move(msg)); } - m_lastSent = nowMs; - m_sentAny = true; - ++m_sequence; - ++m_stats.messages; - m_stats.slots += msg.slots.size(); - return msg; + return out; } // --- Client -------------------------------------------------------------------------------------- @@ -344,17 +455,37 @@ void PropClient::receiveKnocks(const net::PropKnocksEvent& event) { for (const auto& k : event.knocks) { if (m_knocks.size() >= net::kMaxPropIds) m_knocks.pop_front(); - m_knocks.push_back({k.prop, event.time + k.delay, event.catchUp}); + const std::uint32_t time = event.time + k.delay; + m_knocks.push_back({k.prop, time, event.catchUp}); + // How far behind this machine's prediction the host made it. + if (const auto it = m_predicted.find(k.prop); + it != m_predicted.end() && it->second.ownCar && !event.catchUp) { + const double lag = static_cast(time) - it->second.at; + if (lag >= 0.0 && lag < m_options.predictTimeoutMs) { + m_confirmLags.push_back(lag); + while (m_confirmLags.size() > 16) + m_confirmLags.pop_front(); + } + } } } -void PropClient::predicted(std::span knocks, double now) { - // This machine's car broke props loose: shown at once, until the host +double PropClient::undoAfterMs() const { + double worst = 0.0; + for (const double lag : m_confirmLags) + worst = std::max(worst, lag); + return std::max(m_options.minUndoMs, worst + m_options.undoMarginMs); +} + +void PropClient::predicted(std::span knocks, double now, int ownCar, + const CarOfToucher& carOf) { + // This machine's cars broke props loose: shown at once, until the host // confirms or corrects it. for (const auto& k : knocks) { if (m_mismatch || m_hostBroken.contains(k.prop)) continue; - if (m_predicted.emplace(k.prop, Prediction{now, false}).second) + const int car = carOf ? carOf(k.by) : -1; + if (m_predicted.emplace(k.prop, Prediction{now, false, car >= 0 && car == ownCar, car}).second) ++m_stats.predicted; } } @@ -441,7 +572,8 @@ void PropClient::applyKnock(BangerSet& set, std::size_t prop) { } } -void PropClient::update(BangerSet& set, double now, const CarPartResolver& carParts) { +void PropClient::update(BangerSet& set, double now, const CarPartResolver& carParts, const HostCar& hostCar, + std::optional ownCarAt) { updateDelay(now); const double render = now - m_delay; const double drawn = render - m_options.drawBehindMs; @@ -479,9 +611,26 @@ void PropClient::update(BangerSet& set, double now, const CarPartResolver& carPa ++it; continue; } - if (!it->second.confirmed && now - it->second.at >= m_options.predictTimeoutMs) { + // The host's messages have passed the knock's time by more than the + // host's lag behind this machine (and neither the knock nor its + // pieces came): the host's car went elsewhere (another player's car: + // the host had it away from the prop by then). + const Prediction& p = it->second; + const double after = p.at + undoAfterMs(); + bool passed = !p.confirmed && m_any && static_cast(m_latest) >= after; + if (passed && !p.ownCar) { + const auto car = p.car >= 0 && hostCar ? hostCar(p.car) : std::nullopt; + const float left2 = m_options.leftPropM * m_options.leftPropM; + const float own2 = m_options.ownNearM * m_options.ownNearM; + const Vec3& prop = set.instances()[it->first].matrix.m3; + passed = car && car->first >= after && car->second.dist2(prop) > left2 && + (!ownCarAt || ownCarAt->dist2(prop) > own2); + } + if (!it->second.confirmed && (passed || now - it->second.at >= m_options.predictTimeoutMs)) { set.restoreStanding(it->first); ++m_stats.undone; + if (passed && now - it->second.at < m_options.predictTimeoutMs) + ++m_stats.undoneEarly; m_applied.emplace_back(it->first, true); log::info("netprops: the host did not knock prop {} ({}): standing again", it->first, set.instances()[it->first].model); @@ -572,11 +721,16 @@ void PropClient::update(BangerSet& set, double now, const CarPartResolver& carPa slot.blendStart = now; ++m_stats.handovers; } else if (mine.active >= 0) { - if (!slot.local) + if (!slot.local) { slot.localSince = now; + slot.hostMoved = false; + } slot.local = true; - slot.holdUntil = now + m_options.predictTimeoutMs; - slot.hostMoved = false; + if (!hostResting) + slot.hostMoved = true; + // The host's push of it, if its copy of the car pushed it too, + // shows here its lag and the delay later. + slot.holdUntil = now + std::min(m_options.predictTimeoutMs, undoAfterMs() + m_delay); continue; } if (slot.local) { diff --git a/src/game/net/PropSync.h b/src/game/net/PropSync.h index 19af0bc..883412d 100644 --- a/src/game/net/PropSync.h +++ b/src/game/net/PropSync.h @@ -9,15 +9,17 @@ // screen to screen. In OpenMM2 the host is the authority: // // PropHost host side: the placed props that broke loose (reliable -// events, and every one so far to a machine that has just -// loaded the race) and the ring of knocked-over props, sent to -// every client about 20 times a second while any moves +// events to everyone, and every one so far to a machine that +// has just loaded the race) and the ring of knocked-over props, +// each client its own message about 20 times a second while +// any near its car moves, the states near it first // PropClient client side: the host's knocks applied when the props are // shown at their time, the host's ring shown in mirror slots // (interpolated like the other players' cars), and this -// machine's own predictions: a prop its car knocks moves at -// once and hands over to the host's when it comes to rest, and a -// knock the host never confirms is undone +// machine's own predictions: a prop its car (or another +// player's car it simulates) knocks moves at once and hands +// over to the host's when it comes to rest, and a knock the +// host never confirms is undone // // Nothing here touches the network: the race screen sends and feeds in the // messages. @@ -61,19 +63,36 @@ std::optional describeHit(const bangers::BangerSet::Instanc class PropHost { public: struct Options { - // A message at most this often, while any prop moves fast or a slot - // changed; every slowIntervalMs while props only creep (slower than - // slowSpeed and slowSpin: a prop sliding down a hill, which MM2's - // phSleep may never put to sleep); else every idleIntervalMs. + // A client's message at most this often while a prop near its car + // moves fast or a slot changed; every slowIntervalMs while props near + // it only creep (slower than slowSpeed and slowSpin: a prop sliding + // down a hill, which MM2's phSleep may never put to sleep) or props + // far from it move; else every idleIntervalMs. std::uint32_t intervalMs = 50; std::uint32_t slowIntervalMs = 200; std::uint32_t idleIntervalMs = 500; float slowSpeed = 0.5f; // m/s float slowSpin = 1.0f; // rad/s - // A slot at rest carries its state in the first messages after it - // changed, then in every tenth one (by slot number). + // A slot carries its state in the first messages after it changed + // (or came near), then at rest in every tenth one (by slot number). int freshMessages = 3; int restRotation = 10; + // OpenMM2's interest: a client's area is within nearRadius of its + // car (inferred: well past where a prop is still drawn larger than + // a few pixels). Outside it a moving slot's state goes in every + // farRotation-th message and one at rest in every farRestRotation-th. + // The states of a message stay within maxBytes (one unfragmented + // datagram), the nearest first; the knocks always go to everyone. + float nearRadius = 150.0f; + int farRotation = 4; + int farRestRotation = 40; + std::size_t maxBytes = 1100; + }; + // A client the host sends to, and where its car is (none yet: all of the + // city is its area). + struct Viewer { + std::uint8_t id = 0; + std::optional at; }; PropHost() : PropHost(Options{}) {} @@ -91,13 +110,22 @@ class PropHost { static std::vector> catchUp(const bangers::BangerSet& set, std::uint32_t time); // The ring at session time `time` (the state after this frame's - // simulation steps), when a message is due at monotonic time `nowMs`. - // The same message goes to every client. + // simulation steps): the messages due at monotonic time `nowMs`, each + // client its own. + std::vector> build(const bangers::BangerSet& set, + std::uint32_t time, std::uint64_t nowMs, + std::uint32_t catalog, + std::span viewers); + // One client whose car is nowhere yet (tests). std::optional build(const bangers::BangerSet& set, std::uint32_t time, std::uint64_t nowMs, std::uint32_t catalog); + // A client that left. + void forget(std::uint8_t id) { m_viewers.erase(id); } struct Stats { std::uint64_t messages = 0, slots = 0, fullStates = 0, bytes = 0, knocks = 0; + std::uint64_t nearStates = 0; // of fullStates, in the client's area + std::uint64_t deferred = 0; // states due that the datagram had no room for }; Stats& stats() { return m_stats; } @@ -106,15 +134,20 @@ class PropHost { std::uint32_t generation = 0; bool moving = false; bool occupied = false; + bool near = false; int fresh = 0; + std::uint32_t lastState = 0; // the message that last carried its state + }; + struct ViewerState { + std::vector seen; + std::uint64_t lastSent = 0; + bool sentAny = false; + std::uint32_t sequence = 0; }; Options m_options; std::vector m_pending; std::uint32_t m_pendingTime = 0; - std::vector m_seen; - std::uint64_t m_lastSent = 0; - bool m_sentAny = false; - std::uint32_t m_sequence = 0; + std::map m_viewers; Stats m_stats; }; @@ -128,8 +161,20 @@ class PropClient { double maxDelayMs = 500.0; double initialDelayMs = 150.0; // A knock this machine predicted is undone when the host has not - // made it by then. + // made it by then; sooner once the host's messages have passed its + // time by the host's usual lag behind this machine's knocks (the most + // of the last confirmed ones) plus undoMarginMs, at least minUndoMs: + // one by its own car (the host runs it on the same inputs) at once, + // one by another player's car (run here ahead on its last input, it + // may reach a prop seconds before or after the real one) once the + // host had that car farther than leftPropM from the prop by then and + // this machine's own car is farther than ownNearM (it may be about + // to knock the prop itself, and the host with it). double predictTimeoutMs = 2000.0; + double undoMarginMs = 300.0; + double minUndoMs = 600.0; + float leftPropM = 6.0f; + float ownNearM = 15.0f; // A piece simulated here hands over to the host's when it rests, or // after this long; the hand-over blends over blendMs. double handoverMs = 4000.0; @@ -152,10 +197,14 @@ class PropClient { void receive(const net::PropStateMsg& msg, double arrival); // A knocks event from the host. void receiveKnocks(const net::PropKnocksEvent& event); - // The placed props this machine's own car broke loose - // (BangerSet::takeKnocks after its physics steps), at session time - // `now`: predictions the host confirms or corrects. - void predicted(std::span knocks, double now); + // Which player's car a knock's toucher is (-1: none, a piece). + using CarOfToucher = std::function; + // The placed props this machine's cars broke loose (its own, player + // `ownCar`, and the other players' it simulates; BangerSet::takeKnocks + // after its physics steps), at session time `now`: predictions the host + // confirms or corrects. + void predicted(std::span knocks, double now, int ownCar = -1, + const CarOfToucher& carOf = {}); // What update() did to the placed props since the last call: the host's // knocks applied (false) and this machine's predictions undone (true). std::vector> takeApplied() { return std::exchange(m_applied, {}); } @@ -165,10 +214,15 @@ class PropClient { // cannot (the part is then not shown). using CarPartResolver = std::function(const net::PropDescriptor&)>; + // Where the host had player `car`'s car and when (session time), as its + // states have come here; nullopt when unknown. + using HostCar = std::function>(int car)>; // Once a frame before the physics steps: the props as the host had them // at session time `now` less the delay. `now` is the session time the - // simulation will have reached after this frame's steps. - void update(bangers::BangerSet& set, double now, const CarPartResolver& resolve); + // simulation will have reached after this frame's steps. `ownCarAt`: + // where this machine's car is. + void update(bangers::BangerSet& set, double now, const CarPartResolver& resolve, + const HostCar& hostCar = {}, std::optional ownCarAt = std::nullopt); double delayMs() const { return m_delay; } bool catalogMismatch() const { return m_mismatch; } @@ -181,6 +235,7 @@ class PropClient { std::uint64_t predicted = 0; // knocks this machine's car made first std::uint64_t confirmed = 0; // ... that the host made too std::uint64_t undone = 0; // ... that it did not (the prop stood again) + std::uint64_t undoneEarly = 0; // ... of them, once the host's messages had passed them std::uint64_t handovers = 0; // pieces simulated here handed to the host's std::uint64_t orphans = 0; // pieces simulated here that the host's ring did not hold }; @@ -219,7 +274,10 @@ class PropClient { struct Prediction { double at = 0.0; bool confirmed = false; + bool ownCar = false; // by this machine's own car + int car = -1; // the player whose car made it (-1: a piece) }; + double undoAfterMs() const; struct LocalPiece { std::uint32_t generation = 0; double since = 0.0; @@ -255,6 +313,7 @@ class PropClient { std::deque m_knocks; // the host's, waiting for their time std::set m_hostBroken; std::map m_predicted; + std::deque m_confirmLags; // the host's knock time less this machine's, the last ones std::map m_pieces; // this machine's ring slots, by slot std::vector> m_applied; Stats m_stats; diff --git a/src/net/PropState.cpp b/src/net/PropState.cpp index 4c86fe1..02ab1a1 100644 --- a/src/net/PropState.cpp +++ b/src/net/PropState.cpp @@ -2,10 +2,10 @@ namespace mm2::net { -std::size_t propSlotBits(const PropSlot& p) { +std::size_t propSlotBits(const PropSlot& p, std::int32_t previous) { WriteStream s; PropSlot copy = p; - serializePropSlot(s, copy); + serializePropSlot(s, copy, previous); return s.writer().bitCount(); } diff --git a/src/net/PropState.h b/src/net/PropState.h index 207e386..65494e6 100644 --- a/src/net/PropState.h +++ b/src/net/PropState.h @@ -12,19 +12,23 @@ // index (`prop`) names it everywhere; a checksum of the // placement travels with every message. // a hit instance one of the host's ring of knocked-over props -// (dgBangerManager, 40 slots): a placed prop broken loose, -// one of its BREAKnn pieces, or a car part thrown off +// (dgBangerManager, 40 slots, which a network game lets +// grow in a pile-up): a placed prop broken loose, one of +// its BREAKnn pieces, or a car part thrown off // (vehBreakableMgr::Eject). A slot's generation changes // whenever the host hands it out again. // // Two messages carry them: // -// PropStateMsg host -> client, unreliable, about 20 a second while -// anything moves (every 0.5 s otherwise): every occupied -// ring slot, complete, so a slot missing from a newer -// message is empty from that message's time. A moving -// slot carries its state every time, one at rest now and -// then (in between only its slot and generation travel). +// PropStateMsg host -> each client its own, unreliable, about 20 a +// second while anything near it moves (less often +// otherwise): every occupied ring slot, complete and in +// ascending order, so a slot missing from a newer message +// is empty from that message's time. The states are the +// client's: a moving slot near its car carries its state +// every time, one far away less often, one at rest now and +// then (in between only its slot and generation travel), +// the nearest first within a datagram. // PropKnocksEvent host -> everyone, a reliable game event: the placed props // that broke loose and when. Every machine thus knows every // knocked prop of the race, wherever it is; a machine that @@ -38,7 +42,7 @@ namespace mm2::net { inline constexpr std::uint32_t kMaxPropIds = 32768; // placed props 0..32767 -inline constexpr std::uint32_t kMaxPropSlots = 64; // ring slots 0..63 (MM2's ring has 40) +inline constexpr std::uint32_t kMaxPropSlots = 256; // ring slots 0..255 (MM2's ring has 40) inline constexpr std::uint32_t kPropGenerations = 16; // a slot's generation, modulo inline constexpr std::uint32_t kMaxPropParts = 16; // a prop's BREAK01..BREAK16 inline constexpr std::uint32_t kMaxPropOwners = 64; // a car part's owner id @@ -128,10 +132,22 @@ bool serializePropDescriptor(S& s, PropDescriptor& d) { return s.ok(); } +// `previous`: the slot before it in the message (-1 for the first); the +// slots ascend, the next one in a single bit. template -bool serializePropSlot(S& s, PropSlot& p) { +bool serializePropSlot(S& s, PropSlot& p, std::int32_t previous = -1) { std::int32_t slot = p.slot, generation = p.generation; - s.ranged(slot, 0, static_cast(kMaxPropSlots) - 1); + bool next = previous >= 0 && slot == previous + 1; + s.boolean(next); + if (next) { + slot = previous + 1; + if (slot >= static_cast(kMaxPropSlots)) + return s.fail(); + } else { + s.ranged(slot, 0, static_cast(kMaxPropSlots) - 1); + if (slot <= previous) + return s.fail(); // not ascending + } s.ranged(generation, 0, static_cast(kPropGenerations) - 1); p.slot = static_cast(slot); p.generation = static_cast(generation); @@ -163,14 +179,17 @@ bool serialize(S& s, PropStateMsg& m) { return s.fail(); if constexpr (S::kReading) m.slots.resize(count); - for (std::uint32_t i = 0; i < count; ++i) - if (!serializePropSlot(s, m.slots[i])) + std::int32_t previous = -1; + for (std::uint32_t i = 0; i < count; ++i) { + if (!serializePropSlot(s, m.slots[i], previous)) return s.fail(); + previous = m.slots[i].slot; + } return s.ok(); } -// Bits one slot takes on the wire. -std::size_t propSlotBits(const PropSlot& p); +// Bits one slot takes on the wire (after `previous`, as serializePropSlot). +std::size_t propSlotBits(const PropSlot& p, std::int32_t previous = -1); // Host -> everyone, a game event (reliable, ordered): placed props that broke // loose on the host. Each knock's time is `time` plus its delay; a catch-up diff --git a/src/net/Protocol.h b/src/net/Protocol.h index 83f0148..0714ea7 100644 --- a/src/net/Protocol.h +++ b/src/net/Protocol.h @@ -42,7 +42,10 @@ inline constexpr std::uint16_t kProtocolMagic = 0x4D32; // "M2" // 10: the host's rules (net/RulesState.h: the rules message; a player's own // word on a checkpoint, a finish or the gold is refused). (8 was its number // on its branch.) -inline constexpr std::uint16_t kProtocolVersion = 10; +// 13: the props' messages each client's own (the states near its car +// first), the ring up to 256 slots, a slot's number after the one before +// it in a bit. +inline constexpr std::uint16_t kProtocolVersion = 13; inline constexpr std::uint32_t kConnectData = (std::uint32_t{kProtocolMagic} << 16) | kProtocolVersion; // ENet channels. diff --git a/tests/game/test_prop_sync.cpp b/tests/game/test_prop_sync.cpp index 58baba0..2fe7fb2 100644 --- a/tests/game/test_prop_sync.cpp +++ b/tests/game/test_prop_sync.cpp @@ -229,12 +229,16 @@ struct Race { double now = 10000.0; // session ms PropClient::CarPartResolver resolver; const phys::Instance* clientCar = nullptr; + const phys::Instance* nearCar = nullptr; // another player's, simulated on the client too + const phys::Body* hostNearCar = nullptr; // ... and the host's (its states reach the client) + const phys::Body* clientBody = nullptr; // the client's own car, where the client's props see it Race() { host.place(layout()); client.place(layout()); propClient.emplace(propCatalog(client.set)); - client.set.setReplica([this](const phys::Instance& other) { return &other == clientCar; }); + client.set.setReplica( + [this](const phys::Instance& other) { return &other == clientCar || &other == nearCar; }); } // One frame on both machines, in the race screen's order. void frame() { @@ -261,9 +265,17 @@ struct Race { propClient->receive(m, now); } }); - propClient->update(client.set, now, resolver); + propClient->update(client.set, now, resolver, [this](int car) { + // The host's state of the near car, a link's latency ago. + using State = std::optional>; + if (car != 2 || !hostNearCar) + return State{}; + return State{{now - net.latencyMs, hostNearCar->ics.matrix.m3}}; + }, clientBody ? std::optional{clientBody->ics.matrix.m3} : std::nullopt); client.step(); - propClient->predicted(client.set.takeKnocks(), now); + propClient->predicted(client.set.takeKnocks(), now, 1, [this](const phys::Instance* by) { + return by == clientCar ? 1 : by == nearCar ? 2 : -1; + }); } void run(double seconds) { for (int i = 0; i < static_cast(seconds * 60.0); ++i) @@ -396,19 +408,29 @@ TEST(PropSync, ClientKnockIsPredictedThenHandedOver) { } // A knock the host never makes (its copy of the car missed the prop) is -// undone: the prop stands again on the client. +// undone: the prop stands again on the client, as soon as the host's +// messages have passed the knock's time by more than the host's lag. TEST(PropSync, UnconfirmedKnockIsUndone) { Race r; Car mine({0, 1, 3.0f}, {0, 0, -10}); r.clientCar = &mine.body; r.client.world.add(&mine.body); - r.run(1.0); + while (r.client.set.standing(0) && r.now < 12000.0) + r.frame(); + const double knockedAt = r.now; + ASSERT_FALSE(r.client.set.standing(0)); // predicted + r.run(0.4); r.client.world.remove(&mine.body); - EXPECT_FALSE(r.client.set.standing(0)); // predicted - r.run(2.5); + EXPECT_FALSE(r.client.set.standing(0)); // still: the host may yet knock it + while (!r.client.set.standing(0) && r.now < knockedAt + 3000.0) + r.frame(); EXPECT_TRUE(r.client.set.standing(0)); + // The host's next message after it (2 a second with nothing moving) + // passed it, well before the 2 s. + EXPECT_LT(r.now - knockedAt, 1300.0); EXPECT_TRUE(piecesOf(r.client.set, 0).empty()); EXPECT_EQ(r.propClient->stats().undone, 1u); + EXPECT_EQ(r.propClient->stats().undoneEarly, 1u); EXPECT_TRUE(r.host.set.standing(0)); } @@ -674,6 +696,331 @@ TEST(PropSync, ABigCrashStaysWithinADatagram) { knocked, messages, largest, bytes / 8, events); } +// A piece a client's own car knocked flies into the prop behind: in single +// player it knocks that one too; on a client it passes through (the host's +// piece of the same knock flies differently; the host's knocks bring the +// ones it makes). +TEST(PropSync, APredictedPieceKnocksNoPlacedProp) { + for (const bool replica : {false, true}) { + TempBangers files; + bangers::BangerDataLibrary lib(files.vfs); + Machine m(lib); + m.place({{"light", Mat34::translation({0, 0, 0}), 1, PlacedProp::Source::Instance, true}, + {"light", Mat34::translation({0, 0, -1.2f}), 1, PlacedProp::Source::Instance, true}}); + Car car({0, 1, 3.0f}, {0, 0, -25}); + if (replica) + m.set.setReplica([&car](const phys::Instance& other) { return &other == &car.body; }); + m.world.add(&car.body); + while (m.set.standing(0) && m.world.time() < 1.0) + m.step(); + m.world.remove(&car.body); // only the piece goes on + ASSERT_FALSE(m.set.standing(0)) << replica; + for (int i = 0; i < 60; ++i) + m.step(); + EXPECT_EQ(m.set.standing(1), replica) << "the piece knocked the prop behind"; + } +} + +// A pile-up of more knocks than MM2's ring of 40 holds: in a network race +// the ring grows rather than make the props knocked moments ago disappear +// (BangerSet::setRingGrowth); a single-player ring wraps as MM2's does. +TEST(PropSync, APileUpGrowsTheRing) { + for (const bool network : {false, true}) { + TempBangers files; + bangers::BangerDataLibrary lib(files.vfs); + Machine m(lib); + std::vector column; + for (int i = 0; i < 60; ++i) + column.push_back({"light", Mat34::translation({0.0f, 0.0f, -static_cast(i) * 1.5f}), 1, + PlacedProp::Source::Instance, true}); + m.place(column); + if (network) + m.set.setRingGrowth(160, 10.0); + Car car({0, 1, 4.0f}, {0, 0, -12}); + m.world.add(&car.body); + for (int i = 0; i < 9 * 60; ++i) { + // Driven straight on at 12 m/s whatever it hits (the 32 actives + // come free as the first pieces come to rest). + car.body.place(Mat34::translation({0, 1, car.body.ics.matrix.m3.z})); + car.body.ics.linearVelocity = {0, 0, -12}; + car.body.ics.linearMomentum = car.body.ics.linearVelocity * car.body.ics.mass; + car.body.ics.angularVelocity = {}; + car.body.ics.angularMomentum = {}; + m.step(); + } + m.world.remove(&car.body); + std::size_t knocked = 0, shown = 0; + for (std::size_t i = 0; i < 60; ++i) + knocked += m.set.standing(i) ? 0 : 1; + for (const std::size_t i : m.set.ring()) + shown += m.set.instances()[i].state != BangerSet::State::Gone ? 1 : 0; + EXPECT_GT(knocked, 45u) << network; + if (network) { + EXPECT_EQ(m.set.ringSize(), 80); + EXPECT_EQ(shown, knocked); // every knocked prop still there + } else { + EXPECT_EQ(m.set.ringSize(), BangerSet::kMaxHit); + EXPECT_EQ(shown, static_cast(BangerSet::kMaxHit)); + } + } +} + +// Two clients far apart, each with props flying around its car: each one's +// messages carry the states near its car every time, within the datagram +// budget, and the far ones as room allows; both end with every piece where +// the host's rests. +TEST(PropSync, EachClientGetsItsAreaFirst) { + TempBangers files; + bangers::BangerDataLibrary lib(files.vfs); + Machine host(lib), a(lib), b(lib); + std::vector field; + for (const float x : {0.0f, 300.0f}) + for (int i = 0; i < 10; ++i) + field.push_back({"light", Mat34::translation({x, 0.0f, -static_cast(i) * 1.5f}), 1, + PlacedProp::Source::Instance, true}); + host.place(field); + a.place(field); + b.place(field); + a.set.setReplica([](const phys::Instance&) { return false; }); + b.set.setReplica([](const phys::Instance&) { return false; }); + PropHost::Options options; + options.maxBytes = 300; + PropHost propHost(options); + PropClient clientA(propCatalog(a.set)), clientB(propCatalog(b.set)); + Car carA({0, 1, 4.0f}, {0, 0, -20}), carB({300, 1, 4.0f}, {0, 0, -20}); + host.world.add(&carA.body); + host.world.add(&carB.body); + const PropHost::Viewer viewers[] = {{1, Vec3{0, 1, -5}}, {2, Vec3{300, 1, -5}}}; + double now = 10000.0; + std::size_t nearMoving = 0, nearMovingSent = 0, farSent = 0, largest = 0; + for (int i = 0; i < 10 * 60; ++i) { + if (i == 3 * 60) { + host.world.remove(&carA.body); + host.world.remove(&carB.body); + } + now += kStepMs; + host.step(); + const auto t = static_cast(now); + propHost.knocked(host.set.takeKnocks(), t); + for (auto& e : propHost.takeKnockEvents()) { + net::PropKnocksEvent k; + ASSERT_TRUE(net::decodePayload(e, k)); + clientA.receiveKnocks(k); + clientB.receiveKnocks(k); + } + const auto msgs = + propHost.build(host.set, t, static_cast(now), propCatalog(host.set), viewers); + for (const auto& [id, msg] : msgs) { + const auto bytes = net::encodeMessage(msg); + largest = std::max(largest, bytes.size()); + net::PropStateMsg back; + ASSERT_TRUE(net::decodeMessage(bytes, back)); + const Vec3 at = *viewers[id - 1].at; + for (const auto& p : back.slots) { + const std::size_t inst = host.set.ring()[p.slot]; + const bool near = host.set.instances()[inst].matrix.m3.dist(at) < options.nearRadius; + if (near && host.set.moving(inst)) { + ++nearMoving; + nearMovingSent += p.hasState ? 1 : 0; + } else if (!near && p.hasState) { + ++farSent; + } + } + (id == 1 ? clientA : clientB).receive(back, now + 40.0); + } + clientA.update(a.set, now, nullptr); + clientB.update(b.set, now, nullptr); + a.step(); + b.step(); + } + EXPECT_LE(largest, options.maxBytes); + EXPECT_GT(nearMoving, 100u); + EXPECT_EQ(nearMovingSent, nearMoving); // every one, every time + EXPECT_GT(farSent, 0u); + EXPECT_GT(propHost.stats().deferred, 0u); // the budget held some far ones back + std::size_t knocked = 0; + for (std::size_t prop = 0; prop < field.size(); ++prop) { + if (host.set.standing(prop)) + continue; + ++knocked; + const Vec3 rest = piecesOf(host.set, prop).at(-1); + for (const BangerSet* client : {&a.set, &b.set}) { + EXPECT_FALSE(client->standing(prop)) << prop; + const auto shown = piecesOf(*client, prop); + ASSERT_TRUE(shown.contains(-1)) << prop; + EXPECT_LT(shown.at(-1).dist(rest), 0.01f) << prop; + } + } + EXPECT_GT(knocked, 10u); + std::printf("areas: %zu props knocked, near moving states %zu of %zu, far states %zu, %llu deferred, " + "largest %zu bytes\n", + knocked, nearMovingSent, nearMoving, farSent, + static_cast(propHost.stats().deferred), largest); +} + +// More pieces flying near a client than a message's datagram holds: they +// take turns, none waiting long for its state. +TEST(PropSync, APileUpBeyondADatagramTakesTurns) { + TempBangers files; + bangers::BangerDataLibrary lib(files.vfs); + Machine host(lib); + std::vector field; + for (int i = 0; i < 24; ++i) + field.push_back({"light", Mat34::translation({-1.5f + static_cast(i % 3) * 1.5f, 0.0f, + -static_cast(i / 3) * 2.0f}), + 1, PlacedProp::Source::Instance, true}); + host.place(field); + PropHost::Options options; + options.maxBytes = 200; // a few flying states a message + PropHost propHost(options); + Car car({0, 1, 4.0f}, {0, 0, -25}); + host.world.add(&car.body); + const PropHost::Viewer viewer[] = {{1, Vec3{0, 1, -8}}}; + std::map lastState; // slot -> message + int message = 0, longest = 0, flying = 0; + double now = 10000.0; + for (int i = 0; i < 3 * 60; ++i) { + now += kStepMs; + host.step(); + host.set.takeKnocks(); + const auto msgs = propHost.build(host.set, static_cast(now), + static_cast(now), propCatalog(host.set), viewer); + for (const auto& [id, msg] : msgs) { + EXPECT_LE(net::encodeMessage(msg).size(), options.maxBytes); + ++message; + for (const auto& p : msg.slots) { + const std::size_t inst = host.set.ring()[p.slot]; + if (!host.set.moving(inst)) { + lastState.erase(p.slot); + continue; + } + ++flying; + if (p.hasState) { + lastState[p.slot] = message; + } else { + const auto it = lastState.try_emplace(p.slot, message).first; + longest = std::max(longest, message - it->second); + } + } + } + } + EXPECT_GT(propHost.stats().deferred, 0u); + EXPECT_GT(flying, 200); + EXPECT_LE(longest, 8); // messages a flying piece waited at most for its state + std::printf("pile-up: %d flying slots listed, %llu states deferred, longest wait %d messages\n", flying, + static_cast(propHost.stats().deferred), longest); +} + +// A client predicts the knocks of the other players' cars it simulates with +// its own (net::NearCarState) as it does its own car's: at once, then the +// host's knock confirms it and its pieces take over. +TEST(PropSync, ANearCarsKnockIsPredicted) { + Race r; + Car near({6, 1, 3.0f}, {0, 0, -10}); // another player's car as the client simulates it + r.nearCar = &near.body; + r.client.world.add(&near.body); + Car copy({6, 1, 3.0f}, {0, 0, -10}); // the host's, which knocks the same prop + r.host.world.add(©.body); + double hostAt = 0, clientAt = 0; + for (int i = 0; i < 7 * 60; ++i) { + r.frame(); + if (hostAt == 0 && !r.host.set.standing(1)) + hostAt = r.now; + if (clientAt == 0 && !r.client.set.standing(1)) + clientAt = r.now; + if (i == 60) { + r.client.world.remove(&near.body); + r.host.world.remove(©.body); + } + } + ASSERT_GT(hostAt, 0); + EXPECT_LE(clientAt, hostAt); // as it met the car here, not a delay later + const auto& s = r.propClient->stats(); + EXPECT_EQ(s.predicted, 1u); + EXPECT_EQ(s.confirmed, 1u); + EXPECT_EQ(s.undone, 0u); + expectSamePieces(r, 1, 0.01f); +} + +// One the host's car did not make (its player turned away) stands again, +// once the host's states had that car away from the prop. +TEST(PropSync, ANearCarsMissedKnockIsUndone) { + Race r; + Car near({6, 1, 3.0f}, {0, 0, -10}); + r.nearCar = &near.body; + r.client.world.add(&near.body); + Car copy({40, 1, 3.0f}, {0, 0, -10}); // elsewhere on the host + r.hostNearCar = ©.body; + r.host.world.add(©.body); + r.run(0.5); + r.client.world.remove(&near.body); + EXPECT_FALSE(r.client.set.standing(1)); // predicted + r.run(1.0); + EXPECT_TRUE(r.client.set.standing(1)); // before the 2 s + EXPECT_TRUE(r.host.set.standing(1)); + EXPECT_EQ(r.propClient->stats().undone, 1u); + EXPECT_EQ(r.propClient->stats().undoneEarly, 1u); + r.host.world.remove(©.body); +} + +// One the host's near car did not make while this machine's own car is on +// its way to the prop: it stands (the host's knock comes from the client's +// car), rather than standing up and falling again a moment later. +TEST(PropSync, ANearCarsKnockStandsWhileThisCarIsComing) { + Race r; + Car near({6, 1, 3.0f}, {0, 0, -10}); + r.nearCar = &near.body; + r.client.world.add(&near.body); + Car away({40, 1, 3.0f}, {0, 0, -10}); // the near car elsewhere on the host + r.hostNearCar = &away.body; + r.host.world.add(&away.body); + Car mine({6, 1, 12.0f}, {0, 0, -8}); // the client's own car, 1.5 s from the prop + r.clientCar = &mine.body; + r.clientBody = &mine.body; + r.client.world.add(&mine.body); + Car copy({6, 1, 12.0f}, {0, 0, -8}); // and the host's copy of it + r.host.world.add(©.body); + r.run(0.4); + r.client.world.remove(&near.body); + ASSERT_FALSE(r.client.set.standing(1)); // the near car's knock, predicted + for (int i = 0; i < 3 * 60; ++i) { + r.frame(); + EXPECT_FALSE(r.client.set.standing(1)) << i; // never up again + } + EXPECT_FALSE(r.host.set.standing(1)); // the client's car knocked it there + EXPECT_EQ(r.propClient->stats().undone, 0u); + EXPECT_EQ(r.propClient->stats().confirmed, 1u); + r.client.world.remove(&mine.body); + r.host.world.remove(©.body); + r.host.world.remove(&away.body); +} + +// A near car this machine runs ahead reaches a prop well before the real +// one (its player braked short of it): while the host has that car near the +// prop the knock stands, and the host's comes in time. +TEST(PropSync, ANearCarsEarlyKnockWaitsForTheHost) { + Race r; + Car near({6, 1, 3.0f}, {0, 0, -10}); + r.nearCar = &near.body; + r.client.world.add(&near.body); + Car copy({6, 1, 5.5f}, {0, 0, 0}); // stopped short of the prop on the host + r.hostNearCar = ©.body; + r.host.world.add(©.body); + r.run(0.5); + r.client.world.remove(&near.body); + EXPECT_FALSE(r.client.set.standing(1)); // predicted + r.run(1.0); + EXPECT_FALSE(r.client.set.standing(1)); // the host's car is still by it + copy.body.ics.linearVelocity = {0, 0, -10}; // and drives on into it + copy.body.ics.linearMomentum = copy.body.ics.linearVelocity * copy.body.ics.mass; + r.run(1.5); + EXPECT_FALSE(r.host.set.standing(1)); + EXPECT_FALSE(r.client.set.standing(1)); + EXPECT_EQ(r.propClient->stats().confirmed, 1u); + EXPECT_EQ(r.propClient->stats().undone, 0u); + r.host.world.remove(©.body); +} + // A machine that loads the race late (or a prop knocked far from it) gets // every knock so far at once: PropHost::catchUp. TEST(PropSync, LateMachineCatchesUp) { diff --git a/tests/net/test_fuzz.cpp b/tests/net/test_fuzz.cpp index 42f40ac..4b3cdca 100644 --- a/tests/net/test_fuzz.cpp +++ b/tests/net/test_fuzz.cpp @@ -160,8 +160,8 @@ std::vector corpus() { car.gear = 3; ambient.entities.push_back(car); c.push_back(encodeMessage(ambient)); - // The host's props: a piece flying, a prop at rest, a slot only still - // there, a car's thrown part; and their knocks. + // The host's props: a car's thrown part, a piece flying, a prop at rest, + // a slot of a grown ring only still there (ascending); and their knocks. PropStateMsg props; props.time = 6000; props.catalog = 0xF5672068u; @@ -176,6 +176,10 @@ std::vector corpus() { piece.orientation = Quat::fromAxisAngle(Vec3{1.0f, 0.0f, 0.0f}, 0.7f); piece.velocity = {0.5f, -3.0f, 6.0f}; piece.angularVelocity = {2.0f, 0.0f, -1.0f}; + PropSlot part = piece; + part.slot = 0; + part.what = {PropSource::CarPart, 0, 12, PropOwner::Catalog, 7, 3}; + props.slots.push_back(part); props.slots.push_back(piece); PropSlot rest = piece; rest.slot = 4; @@ -183,14 +187,10 @@ std::vector corpus() { rest.moving = false; props.slots.push_back(rest); PropSlot still; - still.slot = 39; + still.slot = 200; still.generation = 2; still.hasState = false; props.slots.push_back(still); - PropSlot part = piece; - part.slot = 0; - part.what = {PropSource::CarPart, 0, 12, PropOwner::Catalog, 7, 3}; - props.slots.push_back(part); c.push_back(encodeMessage(props)); PropKnocksEvent knocks; knocks.time = 6100; @@ -362,6 +362,8 @@ void checkAmbient(const AmbientStateMsg& m) { void checkProps(const PropStateMsg& m) { ASSERT_LE(m.slots.size(), kMaxPropSlots); + for (std::size_t i = 1; i < m.slots.size(); ++i) + ASSERT_GT(m.slots[i].slot, m.slots[i - 1].slot); // ascending for (const PropSlot& p : m.slots) { ASSERT_LT(p.slot, kMaxPropSlots); ASSERT_LT(p.generation, kPropGenerations); diff --git a/tests/net/test_prop_state.cpp b/tests/net/test_prop_state.cpp index bd1340a..f716e3b 100644 --- a/tests/net/test_prop_state.cpp +++ b/tests/net/test_prop_state.cpp @@ -39,14 +39,14 @@ TEST(PropState, RoundTripsEverySourceAndState) { rest.moving = false; rest.velocity = rest.angularVelocity = {}; m.slots.push_back(rest); - PropSlot still; - still.slot = 63; - still.generation = 15; - still.hasState = false; - m.slots.push_back(still); PropSlot part = flying(2); part.what = {PropSource::CarPart, 0, 19, PropOwner::Catalog, 63, 15}; m.slots.push_back(part); + PropSlot still; // after a gap, the ring's last slot + still.slot = 255; + still.generation = 15; + still.hasState = false; + m.slots.push_back(still); const auto bytes = encodeMessage(m); PropStateMsg back; @@ -89,11 +89,71 @@ TEST(PropState, AFullRingFitsOneDatagram) { m.slots.push_back(flying(k)); EXPECT_LT(encodeMessage(m).size(), 1100u); std::size_t bits = 0; - for (const auto& p : m.slots) - bits += propSlotBits(p); + std::int32_t previous = -1; + for (const auto& p : m.slots) { + bits += propSlotBits(p, previous); + previous = p.slot; + } EXPECT_LE(bits, 40u * 8u * 26u); // 26 bytes a flying piece at most } +// A grown ring (a pile-up, net::kMaxPropSlots) listed in full: a slot after +// the one before it costs a bit for its number. +TEST(PropState, AGrownRingListsCheaply) { + PropStateMsg m; + for (int k = 0; k < static_cast(kMaxPropSlots); ++k) { + PropSlot p; + p.slot = static_cast(k); + p.generation = static_cast(k % kPropGenerations); + p.hasState = false; + m.slots.push_back(p); + } + const auto bytes = encodeMessage(m); + EXPECT_LE(bytes.size(), 14u + kMaxPropSlots * 6u / 8u + 1u); // 6 bits a slot + PropStateMsg back; + ASSERT_TRUE(decodeMessage(bytes, back)); + ASSERT_EQ(back.slots.size(), kMaxPropSlots); + EXPECT_EQ(back.slots[255].slot, 255); + EXPECT_EQ(back.slots[255].generation, 255 % kPropGenerations); + EXPECT_EQ(propSlotBits(m.slots[1], 0), 6u); + EXPECT_EQ(propSlotBits(m.slots[1]), 14u); +} + +// The slots ascend: a repeated or earlier slot is refused (a writer cannot +// write one either). +TEST(PropState, RefusesSlotsOutOfOrder) { + PropStateMsg m; + m.slots.push_back(flying(5)); + m.slots.push_back(flying(3)); + { + WriteStream s; + std::uint8_t type = static_cast(MsgType::PropState); + s.u8(type); + EXPECT_FALSE(serialize(s, m)); + } + // By hand: slot 5, then slot 5 again (not "the next one"). + WriteStream s; + std::uint8_t type = static_cast(MsgType::PropState); + std::uint32_t time = 1, catalog = 2, count = 2; + s.u8(type); + s.u32(time); + s.u32(catalog); + s.varU32(count); + for (int i = 0; i < 2; ++i) { + PropSlot p; + p.slot = 5; + p.hasState = false; + bool next = false; + std::int32_t slot = 5, generation = 0; + s.boolean(next); + s.ranged(slot, 0, static_cast(kMaxPropSlots) - 1); + s.ranged(generation, 0, static_cast(kPropGenerations) - 1); + s.boolean(p.hasState); + } + PropStateMsg back; + EXPECT_FALSE(decodeMessage(s.writer().take(), back)); +} + TEST(PropState, RefusesMalformedMessages) { PropStateMsg m; m.slots.push_back(flying(0)); From aeb303afaf90d3e86e493be2f4e274d613090255 Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Fri, 9 Oct 2026 21:09:10 -0700 Subject: [PATCH 104/129] Send in full only the knocked cars the host's world runs A knocked car whose body the host's physics did not run (its table of 32 movers full: MM2 leaves the car where it is) went to the clients in full with the 10 m/s its body had when it froze; a client ran it and showed it 1.8 m from the host's for 90 s. Such a body now stays the host's messages' (standing, its velocity shared as zero). Co-Authored-By: Claude Opus 5.5 (1M context) --- src/game/TrafficBodies.cpp | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/game/TrafficBodies.cpp b/src/game/TrafficBodies.cpp index 30d5f66..5f5772a 100644 --- a/src/game/TrafficBodies.cpp +++ b/src/game/TrafficBodies.cpp @@ -775,7 +775,11 @@ void TrafficBodies::instancesIn(int room, std::vector& out) con std::optional TrafficBodies::bodyState(int carId) const { const RailCar* r = findRailCar(carId); - if (!r || !r->active) + // A body the world's last step did not run (dgPhysManager's table of 32 + // movers was full: MM2 then leaves it where it is) is not simulated: a + // client must not run it either (it once ran one at the 10 m/s its + // body had when it froze, 1.8 m from the host's for 90 s). + if (!r || !r->active || !m_world.isActive(&r->active->body)) return std::nullopt; const Active& a = *r->active; const phys::InertialCS& ics = a.body.ics; From 4cb28e1aab5871d5466b562dfe6b335f910fad3e Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Fri, 9 Oct 2026 21:13:12 -0700 Subject: [PATCH 105/129] Send in full only the police cars the host's world runs As the knocked cars: a police car MM2 does not declare (or one beyond the 32 movers) neither moves nor collides on the host, whatever its velocity says, so a client must not run it from its state either. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/app/RaceScreen.cpp | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/app/RaceScreen.cpp b/src/app/RaceScreen.cpp index d439124..51b058e 100644 --- a/src/app/RaceScreen.cpp +++ b/src/app/RaceScreen.cpp @@ -4219,7 +4219,13 @@ class RaceScreen final : public Screen { for (std::size_t i = 0; i < m_cops.size(); ++i) { const Cop& cop = m_cops[i]; const int id = kNetPoliceId + static_cast(i); - if (!cop.sim || id >= static_cast(net::kMaxAmbientIds)) + // Not one the world's last step did not run (MM2 neither + // updates nor collides a cop it does not declare, nor a body + // beyond its 32 movers): it stands where it is, whatever its + // velocity, and a client must not run it (it once showed one + // standing 1 m off for a minute). + if (!cop.sim || id >= static_cast(net::kMaxAmbientIds) || !m_world || + !m_world->isActive(&cop.sim->sim().body)) continue; const phys::CarSim& sim = cop.sim->sim(); const float d = sim.body.ics.matrix.m3.dist(at); From 93dca06d91d94ea8444acc43f1f87f91601dfd45 Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Fri, 9 Oct 2026 21:13:47 -0700 Subject: [PATCH 106/129] Record the Clang and MinGW runs of the determinism tests A Clang 23.1.1 build in the CI job's own Arch container and the MinGW build under Wine (with the CI step's commands) give the GCC build's hashes in all eight Determinism tests, the London race included. "Before" in the record is integration's sources with the new tests; the commit hashes are the branch's. Co-Authored-By: Claude Opus 5.5 --- docs/review/multiplayer-determinism.md | 59 ++++++++++++++------------ 1 file changed, 33 insertions(+), 26 deletions(-) diff --git a/docs/review/multiplayer-determinism.md b/docs/review/multiplayer-determinism.md index 3ac10a5..b3f8fc6 100644 --- a/docs/review/multiplayer-determinism.md +++ b/docs/review/multiplayer-determinism.md @@ -20,9 +20,9 @@ No wire change: the protocol stays as integration has it. | Elementary functions in the simulation | the C runtime's: glibc against MinGW's (Wine's ucrtbase) differ for 21 % of atan2f, 14 % of tanf, 8 % of acosf, 2 % of atanf and about 1 % of sinf / cosf results (a million arguments each in the game's ranges) | OpenMM2's own (`core/Libm.h`), the same bits on every platform; float results correctly rounded, as MM2's x87 got them | | Float contraction (a * b + c fused) | off for `mm2_phys` only; GCC (`fast` for C++) and Clang (`on`) fuse the AI, props, session and core maths on FMA targets (AArch64, `-march=native`, x86-64-v3) | off for every target, explicit on every compiler | | A client's knocked traffic cars | collided in an unordered map's order, unstably sorted by room (differs between libstdc++ and MSVC's STL) | room, then id | -| Proof | none | `Determinism.*`: the maths (tests/core/test_libm.cpp) and a data-free minute of a car through props and traffic (tests/game/test_determinism.cpp) hashed against committed values on GCC, Clang, MinGW under Wine and MSVC in CI | -| Data-free scenario, Linux GCC against MinGW under Wine | differs from the first checkpoint (see "Before and after") | identical, all 30 checkpoints | -| London race1 with full traffic, 40 s, Linux GCC against MinGW under Wine | see "Before and after" | identical | +| Proof | none | `Determinism.*`: the maths (tests/core/test_libm.cpp) and a data-free minute of a car and an AI racer among props and traffic (tests/game/test_determinism.cpp) hashed against committed values on GCC, Clang, MinGW under Wine and MSVC in CI; run here on GCC, Clang (in the CI's container) and MinGW under Wine | +| Data-free scenario: Linux GCC, Linux Clang, MinGW under Wine | GCC and MinGW differ from the first checkpoint (see "Before and after") | identical, all 30 checkpoints | +| London race1 with full traffic, 40 s: the same three builds | GCC and MinGW differ (see "Before and after") | identical | | Single-player results on Linux | | changed where glibc's own sinf, cosf, expf, logf and powf were not correctly rounded; the opponent sweep 516/517 finished and 508/517 across the line became 515/517 and 507/517 (see "Single player") | ## How it was measured @@ -55,11 +55,12 @@ No wire change: the protocol stays as integration has it. exp, log), 2-3 ulp (tan, atan, atan2, asin, acos) and, for pow, about |y log x| ulp. * The scenario and race hashes: `test_game --gtest_filter='Determinism.*'` - in the Linux GCC build and in the MinGW cross build under Wine - (`ctest --preset mingw-cross-release -R '^Determinism[.]'`, as CI runs - it; the race with `OPENMM2_GAME_DATA` set). "Before" is the same tests - with `core/Libm.h` made a thin wrapper of the C runtime's functions, which - is what the call sites computed before. + in the Linux GCC build, in a Clang build inside the CI job's container + (`archlinux:latest` under podman, Clang 23.1.1) and in the MinGW cross + build under Wine (also `ctest --preset mingw-cross-release -R + '^Determinism[.]'`, as CI runs it); the race with `OPENMM2_GAME_DATA` + set. "Before" is integration's sources (782f469) with the two new test + files, built natively and with MinGW. * Uninitialised values: the three `Determinism` tests of `test_game` under valgrind memcheck: no errors. * The opponent sweep (`OPENMM2_AI_SWEEP=1`, OpponentRace.EveryRaceSweep) @@ -91,10 +92,10 @@ were added so that the test fails on a build whose maths differ. | # | Severity | Location | What | Fix | | --- | --- | --- | --- | --- | -| D1 | visible | 27 files: `phys` (`vehicle/Wheel.cpp` MakeRotateY and the road bumps, `TrailerJoint`, `vehicle/Transmission` and `vehicle/Controls` pow, `CarSim`, `Trailer`, `AgeMath`), `core/Math.cpp`, `ai` (Driving, DrivingTargets, DrivingRoute, PathGeometry, Traffic, Police, Opponent, Course, AmbientRoute, Pedestrians), `city/AiMap.cpp` (the intersection's road order), `game/session` (RaceSetup, Gate), `game/net` (TrafficPrediction, TrafficSync, PlayerCars, Autopilot), `net/VehicleDamage.h`, `app/Controls.cpp` | The simulation called the C runtime's sin, cos, tan, acos, atan2, exp, exp2, log2, pow and hypot, whose last bits differ between glibc, MSVC's runtime and MinGW's. The steered wheels' rotation, the AI's every steering angle and the damage encoding differed with them. | a0e118e: `core/Libm.h`, used at every call; a test fails when a simulation source calls the runtime again | -| D2 | latent | `src/phys/CMakeLists.txt`, `cmake/CompilerOptions.cmake` | Only `mm2_phys` was built with `-ffp-contract=off`. On FMA targets GCC and Clang fused the AI's, the props', the session's and the core maths' a * b + c, rounding once where the other builds round twice. The release x86-64 builds have no FMA, so it did not show there. | 6d37388: `-ffp-contract=off -fno-fast-math` (MSVC `/fp:precise`) for every target | -| D3 | minor | `game/net/TrafficProxies.cpp` | A client's traffic cars that the host knocked loose were listed in an `unordered_map`'s order and sorted by room with `std::sort`; the physics collides a room's instances in the order listed, and both orders differ between standard libraries. | 16f0e5d: by id, stably sorted by room (as `TrafficBodies`) | -| D4 | latent | `ai/Course.cpp` (`findTurns`), `RaceScreen.cpp` (the host's nearest players for a client) | `std::sort` on keys that can tie (a circuit's turns at the same distance; two players at the same distance): equal keys come out in an order that differs between standard libraries. | 16f0e5d: `stable_sort` | +| D1 | visible | 27 files: `phys` (`vehicle/Wheel.cpp` MakeRotateY and the road bumps, `TrailerJoint`, `vehicle/Transmission` and `vehicle/Controls` pow, `CarSim`, `Trailer`, `AgeMath`), `core/Math.cpp`, `ai` (Driving, DrivingTargets, DrivingRoute, PathGeometry, Traffic, Police, Opponent, Course, AmbientRoute, Pedestrians), `city/AiMap.cpp` (the intersection's road order), `game/session` (RaceSetup, Gate), `game/net` (TrafficPrediction, TrafficSync, PlayerCars, Autopilot), `net/VehicleDamage.h`, `app/Controls.cpp` | The simulation called the C runtime's sin, cos, tan, acos, atan2, exp, exp2, log2, pow and hypot, whose last bits differ between glibc, MSVC's runtime and MinGW's. The steered wheels' rotation, the AI's every steering angle and the damage encoding differed with them. | 37d2123: `core/Libm.h`, used at every call; a test fails when a simulation source calls the runtime again | +| D2 | latent | `src/phys/CMakeLists.txt`, `cmake/CompilerOptions.cmake` | Only `mm2_phys` was built with `-ffp-contract=off`. On FMA targets GCC and Clang fused the AI's, the props', the session's and the core maths' a * b + c, rounding once where the other builds round twice. The release x86-64 builds have no FMA, so it did not show there. | ee94506: `-ffp-contract=off -fno-fast-math` (MSVC `/fp:precise`) for every target | +| D3 | minor | `game/net/TrafficProxies.cpp` | A client's traffic cars that the host knocked loose were listed in an `unordered_map`'s order and sorted by room with `std::sort`; the physics collides a room's instances in the order listed, and both orders differ between standard libraries. | 222ee38: by id, stably sorted by room (as `TrafficBodies`) | +| D4 | latent | `ai/Course.cpp` (`findTurns`), `RaceScreen.cpp` (the host's nearest players for a client) | `std::sort` on keys that can tie (a circuit's turns at the same distance; two players at the same distance): equal keys come out in an order that differs between standard libraries. | 222ee38: `stable_sort` | ### Checked and found deterministic @@ -128,8 +129,8 @@ were added so that the test fails on a build whose maths differ. * **Order of evaluation.** No expression draws twice from one random stream (the comma-separated declarations that do are sequenced). Function arguments are evaluated in an unspecified order (GCC and MSVC from the - right, Clang from the left), so the Clang CI job is the check that - nothing else depends on it. + right, Clang from the left); a Clang build gives the GCC build's hashes + (see "What CI should show"), so nothing the tests run depends on it. * **Uninitialised values.** None under valgrind in the scenario and the 40 s race. * **Data.** Numbers are parsed with `std::from_chars` (correctly rounded in @@ -199,23 +200,29 @@ Windows MSVC job with the rest of the suite, the MinGW job under Wine ### What CI should show -* Linux GCC: everything passes (the hashes are this build's). -* MinGW under Wine: passes; checked here with the same Wine (11.19) and - MinGW GCC (16.2) as the Arch container installs. -* Linux Clang: should pass. Clang evaluates function arguments left to - right where GCC goes right to left; if some simulation code depends on - that order, `ScenarioHashesMatchEveryPlatform` fails and names the first - checkpoint and part (car, traffic, props) that differ. -* Windows MSVC: should pass. MSVC's `/fp:precise` on x64 neither fuses nor - widens, and the maths no longer touches its runtime. A failure there - would point at a remaining source (the report says where to look first: - the checkpoint and part; then the libm tests, which isolate the maths). +* Linux GCC: everything passes (the hashes are this build's; also in a + build with `-D_GLIBCXX_ASSERTIONS`, and under valgrind). +* Linux Clang: passes. Checked here in the CI job's own container + (`archlinux:latest` under podman, Clang 23.1.1, the CI's packages): all + eight `Determinism` tests pass with the GCC build's hashes, the London + race included (with the game data mounted). +* MinGW under Wine: passes. Checked here with the CI step's own commands + (`wineboot --init`, `ctest --preset mingw-cross-release -R + '^Determinism[.]'`) and the same Wine (11.19) and MinGW GCC (16.2) as the + Arch container installs; the London race passes too when run with the + game data. +* Windows MSVC: should pass, and is the one build not run here. MSVC's + `/fp:precise` on x64 neither fuses nor widens, and the maths no longer + touches its runtime. A failure would point at a remaining source: the + report names the first checkpoint and part (cars, traffic, props) that + differ and prints where the cars were at each checkpoint, to compare with + the Linux log; the libm tests isolate the maths. ## Open | # | Severity | What | Needs | | --- | --- | --- | --- | -| O1 | unknown | Clang and MSVC could not be run here; CI is the proof. | The PR's CI run. | +| O1 | unknown | MSVC could not be run here (GCC, Clang and MinGW under Wine were); CI is the proof. | The PR's CI run. | | O2 | minor | Where MM2's compiled code keeps an fsin or fpatan result in an x87 register for the next operation (e.g. `sin(a) * r` multiplied before the store), OpenMM2 rounds the function's result to float first. A parity question per call, as before this change. | The parity audits, call by call. | | O3 | minor | Nothing resets the floating-point environment at run time: a library that set flush-to-zero or another rounding mode on the main thread would change the results (no known one does; the test checks the defaults). | A guard in the frame loop if it is ever seen. | | O4 | minor | `libm::pow` is accurate to about \|y log x\| ulp in double (float results correctly rounded but for 1 in 10^7 or so); the game uses it for the gearing, the steering curve and the AI's per-frame factors. | Nothing unless a use needs double accuracy. | From 0c941981c3326ea008a7cabdf23092ae90760535 Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Fri, 9 Oct 2026 21:18:03 -0700 Subject: [PATCH 107/129] Compare OpenMM2's maths with the C runtime in mm2tool mm2tool libm [count] hashes OpenMM2's float functions and the runtime's over the same arguments (a million by default, the game's ranges) and counts where they differ, and where each differs from the runtime's long double function rounded to float. OpenMM2's hashes are the same on every platform; the record lists the Linux ones and the runtimes' differences (glibc against MinGW's under Wine). Run on a Windows MSVC build, it measures MSVC's runtime and checks the hashes there without game data. Co-Authored-By: Claude Opus 5.5 --- docs/review/multiplayer-determinism.md | 43 ++++++---- tools/mm2tool/cmd_libm.cpp | 107 +++++++++++++++++++++++++ 2 files changed, 134 insertions(+), 16 deletions(-) create mode 100644 tools/mm2tool/cmd_libm.cpp diff --git a/docs/review/multiplayer-determinism.md b/docs/review/multiplayer-determinism.md index b3f8fc6..136dcc9 100644 --- a/docs/review/multiplayer-determinism.md +++ b/docs/review/multiplayer-determinism.md @@ -17,7 +17,7 @@ No wire change: the protocol stays as integration has it. | | Before | After | | --- | --- | --- | -| Elementary functions in the simulation | the C runtime's: glibc against MinGW's (Wine's ucrtbase) differ for 21 % of atan2f, 14 % of tanf, 8 % of acosf, 2 % of atanf and about 1 % of sinf / cosf results (a million arguments each in the game's ranges) | OpenMM2's own (`core/Libm.h`), the same bits on every platform; float results correctly rounded, as MM2's x87 got them | +| Elementary functions in the simulation | the C runtime's: glibc against MinGW's (Wine's ucrtbase) differ for 21 % of atan2f, 14 % of tanf, 8 % of acosf, 6 % of asinf, 2 % of atanf and about 1 % of sinf / cosf results (a million arguments each in the game's ranges) | OpenMM2's own (`core/Libm.h`), the same bits on every platform; float results correctly rounded, as MM2's x87 got them | | Float contraction (a * b + c fused) | off for `mm2_phys` only; GCC (`fast` for C++) and Clang (`on`) fuse the AI, props, session and core maths on FMA targets (AArch64, `-march=native`, x86-64-v3) | off for every target, explicit on every compiler | | A client's knocked traffic cars | collided in an unordered map's order, unstably sorted by room (differs between libstdc++ and MSVC's STL) | room, then id | | Proof | none | `Determinism.*`: the maths (tests/core/test_libm.cpp) and a data-free minute of a car and an AI racer among props and traffic (tests/game/test_determinism.cpp) hashed against committed values on GCC, Clang, MinGW under Wine and MSVC in CI; run here on GCC, Clang (in the CI's container) and MinGW under Wine | @@ -27,21 +27,32 @@ No wire change: the protocol stays as integration has it. ## How it was measured -* The C runtimes: `crt_compare` (scratch) hashes the runtime's float - functions and OpenMM2's over the same million arguments, built natively - (glibc 2.44, GCC 16.2) and with MinGW-w64 GCC 16.2 run under Wine 11.19. - OpenMM2's hashes agree on both; the runtimes' differ: - - | Function (range) | glibc vs OpenMM2 | Wine (MinGW) vs OpenMM2 | - | --- | --- | --- | - | sinf, cosf (-100 .. 100) | 1.28 %, 1.29 % | 0.13 %, 0.14 % | - | tanf (-1.5 .. 1.5) | 0 | 14.0 % | - | atanf (-50 .. 50) | 0 | 2.4 % | - | atan2f | 0 | 20.6 % | - | acosf | 0 | 7.6 % | - | expf, exp2f, logf, log2f, powf | 0.02-0.06 % | the same as glibc | - | sin, cos in double, rounded to float | 0 | 0 | - | hypotf | 0 | 0 | +* The C runtimes: `mm2tool libm [count]` hashes OpenMM2's float functions + and the runtime's over the same million arguments and counts where they + differ (and where each differs from the runtime's long double function + rounded to float). Built natively (glibc 2.44, GCC 16.2) and with + MinGW-w64 GCC 16.2 run under Wine 11.19, OpenMM2's hashes agree; the + runtimes' differ: + + | Function (range) | OpenMM2's hash (every platform) | glibc vs OpenMM2 | Wine (MinGW) vs OpenMM2 | + | --- | --- | --- | --- | + | sinf (-100 .. 100) | a0ca0bfa0c155ccd | 1.28 % | 0.13 % | + | cosf (-100 .. 100) | f7798fc077c36869 | 1.29 % | 0.14 % | + | tanf (-1.5 .. 1.5) | fa764f4ac4c8d74e | 0 | 14.0 % | + | asinf (-1 .. 1) | a7f2537725727742 | 0 | 6.5 % | + | acosf (-1 .. 1) | 5adaf910ad1078c1 | 0 | 7.6 % | + | atanf (-50 .. 50) | 065bc3cf9236a2de | 0 | 2.4 % | + | atan2f | 9b9da75c0e13f5e8 | 0 | 20.6 % | + | expf (-30 .. 30) | 69c95c443cf80355 | 0.06 % | the same as glibc | + | exp2f (-30 .. 30) | 48ab300582a9d232 | 0.06 % | the same as glibc | + | logf (0.001 .. 1000) | 4fa2f339eeabe0bc | 0.04 % | the same as glibc | + | log2f (0.001 .. 1000) | b7d73e9134b55d4e | 0.02 % | the same as glibc | + | powf (x^1.7, 0 .. 50) | aad295ad178827c5 | 0.06 % | the same as glibc | + | hypotf | 3f5d9340c658959c | 0 | 0 | + + In every row OpenMM2's results equal the long double function rounded + to float. On a Windows MSVC build the same command shows MSVC's runtime, + and its libm hashes must be the ones above. (glibc 2.41 and later round atanf, atan2f, acosf, asinf, tanf and hypotf correctly; its sinf, cosf, expf, logf and powf come with an FMA variant diff --git a/tools/mm2tool/cmd_libm.cpp b/tools/mm2tool/cmd_libm.cpp new file mode 100644 index 0000000..8c95a04 --- /dev/null +++ b/tools/mm2tool/cmd_libm.cpp @@ -0,0 +1,107 @@ +// libm: OpenMM2's own elementary functions (core/Libm.h) against this +// platform's C runtime, over the same arguments everywhere. +// +// For each function it prints a hash of OpenMM2's float results, which must +// be the same on every platform (docs/review/multiplayer-determinism.md +// lists the Linux values), and how many of the runtime's results differ from +// them and from the long double functions rounded to float (the runtime's +// own reference; on MSVC long double is double). + +#include "Command.h" +#include "core/Libm.h" +#include "core/StringUtil.h" + +#include +#include +#include +#include + +namespace mm2::tool { +namespace { + +struct Counts { + std::uint64_t mine = 1469598103934665603ull; // FNV-1a of OpenMM2's results + std::uint64_t runtime = 1469598103934665603ull; + int differ = 0; // OpenMM2 against the runtime + int mineVsRef = 0; // against the long double result rounded to float + int runtimeVsRef = 0; +}; + +void hash(std::uint64_t& h, float v) { + const auto u = std::bit_cast(v); + for (int k = 0; k < 4; ++k) + h = (h ^ ((u >> (8 * k)) & 0xffu)) * 1099511628211ull; +} + +template +void sweep(const char* name, float lo, float hi, int count, Mine mine, Runtime runtime, Ref ref) { + Counts c; + std::uint32_t seed = 12345u; + for (int i = 0; i < count; ++i) { + seed = seed * 1664525u + 1013904223u; + const float x = lo + (hi - lo) * (static_cast(seed >> 8) * 0x1p-24f); + const float m = mine(x), r = runtime(x), e = static_cast(ref(static_cast(x))); + hash(c.mine, m); + hash(c.runtime, r); + c.differ += std::bit_cast(m) != std::bit_cast(r); + c.mineVsRef += std::bit_cast(m) != std::bit_cast(e); + c.runtimeVsRef += std::bit_cast(r) != std::bit_cast(e); + } + std::println("{:<6} {:>8g} .. {:<8g} libm {:016x} runtime {:016x} differ {:>7} libm/ref {:>5} " + "runtime/ref {:>7}", + name, lo, hi, c.mine, c.runtime, c.differ, c.mineVsRef, c.runtimeVsRef); +} + +int cmdLibm(std::span args) { + int count = 1000000; + if (!args.empty()) { + const auto n = str::parseInt(args[0]); + if (!n || *n <= 0) { + std::println(stderr, "libm: bad count '{}'", args[0]); + return 2; + } + count = static_cast(*n); + } + std::println("{} arguments per function; the libm hashes must match on every platform", count); + sweep("sin", -100.0f, 100.0f, count, [](float x) { return libm::sin(x); }, + [](float x) { return std::sin(x); }, [](long double x) { return std::sin(x); }); + sweep("cos", -100.0f, 100.0f, count, [](float x) { return libm::cos(x); }, + [](float x) { return std::cos(x); }, [](long double x) { return std::cos(x); }); + sweep("tan", -1.5f, 1.5f, count, [](float x) { return libm::tan(x); }, + [](float x) { return std::tan(x); }, [](long double x) { return std::tan(x); }); + sweep("asin", -1.0f, 1.0f, count, [](float x) { return libm::asin(x); }, + [](float x) { return std::asin(x); }, [](long double x) { return std::asin(x); }); + sweep("acos", -1.0f, 1.0f, count, [](float x) { return libm::acos(x); }, + [](float x) { return std::acos(x); }, [](long double x) { return std::acos(x); }); + sweep("atan", -50.0f, 50.0f, count, [](float x) { return libm::atan(x); }, + [](float x) { return std::atan(x); }, [](long double x) { return std::atan(x); }); + sweep("atan2", -40.0f, 40.0f, count, [](float x) { return libm::atan2(x, 7.0f - x * 0.5f); }, + [](float x) { return std::atan2(x, 7.0f - x * 0.5f); }, + [](long double x) { + const float other = 7.0f - static_cast(x) * 0.5f; + return std::atan2(x, static_cast(other)); + }); + sweep("exp", -30.0f, 30.0f, count, [](float x) { return libm::exp(x); }, + [](float x) { return std::exp(x); }, [](long double x) { return std::exp(x); }); + sweep("exp2", -30.0f, 30.0f, count, [](float x) { return libm::exp2(x); }, + [](float x) { return std::exp2(x); }, [](long double x) { return std::exp2(x); }); + sweep("log", 0.001f, 1000.0f, count, [](float x) { return libm::log(x); }, + [](float x) { return std::log(x); }, [](long double x) { return std::log(x); }); + sweep("log2", 0.001f, 1000.0f, count, [](float x) { return libm::log2(x); }, + [](float x) { return std::log2(x); }, [](long double x) { return std::log2(x); }); + sweep("pow", 0.0f, 50.0f, count, [](float x) { return libm::pow(x, 1.7f); }, + [](float x) { return std::pow(x, 1.7f); }, + [](long double x) { return std::pow(x, static_cast(1.7f)); }); + sweep("hypot", -50.0f, 50.0f, count, [](float x) { return libm::hypot(x, 3.0f - x); }, + [](float x) { return std::hypot(x, 3.0f - x); }, + [](long double x) { + const auto other = static_cast(3.0f - static_cast(x)); + return std::sqrt(x * x + other * other); + }); + return 0; +} + +const Registrar reg({"libm", "[count]", "compare OpenMM2's maths with this platform's C runtime", &cmdLibm}); + +} // namespace +} // namespace mm2::tool From b33bebcf51d38bc9b6c487784b407e69ac1d08b2 Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Fri, 9 Oct 2026 21:25:50 -0700 Subject: [PATCH 108/129] List the simulation's own maths library in the parity manifest Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/parity/README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/parity/README.md b/docs/parity/README.md index 8861f73..485643c 100644 --- a/docs/parity/README.md +++ b/docs/parity/README.md @@ -318,6 +318,8 @@ audited in [openmm2-only.md](openmm2-only.md). | `src/core/File.h` | 108 | O | - | | `src/core/Ini.cpp` | 189 | O | - | | `src/core/Ini.h` | 57 | O | - | +| `src/core/Libm.cpp` | 447 | O | - | +| `src/core/Libm.h` | 66 | O | - | | `src/core/Log.cpp` | 107 | O | - | | `src/core/Log.h` | 55 | O | - | | `src/core/Math.cpp` | 195 | M | phys-core | From 92628b282463dfd18183bf6218367b168de62f8f Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Fri, 9 Oct 2026 21:26:59 -0700 Subject: [PATCH 109/129] Send the police in full within 60 m only, chasing or not A chasing police car 60-100 m behind the client's car, simulated there on its driver's averaged controls, strayed further than the same car predicted along its velocity (2.2 m at the 90th percentile against 0.7 m), and it cannot meet the client's car before its next state anyway. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/app/RaceScreen.cpp | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/app/RaceScreen.cpp b/src/app/RaceScreen.cpp index 51b058e..71e8218 100644 --- a/src/app/RaceScreen.cpp +++ b/src/app/RaceScreen.cpp @@ -4200,8 +4200,11 @@ class RaceScreen final : public Screen { // police car chasing the client counting half as far); a car sent the // last time is kept kFullKeep metres further, so that one at the edge // does not switch between being simulated and placed on the client. + // Farther away a police car simulated on its driver's last controls + // strayed more than one predicted along its velocity (chasing 60-100 m + // behind: 2.2 m at the 90th percentile against 0.7 m), and it cannot + // meet the client's car before its next state. static constexpr float kFullPoliceRadius = 60.0f; // m - static constexpr float kFullChaseRadius = 100.0f; // a police car chasing the client static constexpr float kFullBodyRadius = 50.0f; // m static constexpr float kFullKeep = 20.0f; // m void sendNetTrafficFull(Context& ctx) { @@ -4222,8 +4225,7 @@ class RaceScreen final : public Screen { // Not one the world's last step did not run (MM2 neither // updates nor collides a cop it does not declare, nor a body // beyond its 32 movers): it stands where it is, whatever its - // velocity, and a client must not run it (it once showed one - // standing 1 m off for a minute). + // velocity, and a client must not run it. if (!cop.sim || id >= static_cast(net::kMaxAmbientIds) || !m_world || !m_world->isActive(&cop.sim->sim().body)) continue; @@ -4232,7 +4234,7 @@ class RaceScreen final : public Screen { const int target = cop.driver->target(); const bool chasing = target > kNetPlayerTrackedId && target - kNetPlayerTrackedId == static_cast(rc.id); - if (!within(id, d, chasing ? kFullChaseRadius : kFullPoliceRadius)) + if (!within(id, d, kFullPoliceRadius)) continue; net::TrafficFullCar f; f.id = static_cast(id); From efb3da4430f921264154cddc9dce5bd185ced37d Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Fri, 9 Oct 2026 21:27:13 -0700 Subject: [PATCH 110/129] Describe protocol 12's shared traffic docs/multiplayer.md: the TrafficFull message, what it carries and what a client refuses, how the client simulates the police and knocked cars with its own car, the 2600-byte AmbientState in unreliable fragments, standing cars and rail cars in replays, the bandwidth. docs/parity/openmm2-only.md: the rows for them. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/multiplayer.md | 116 +++++++++++++++++++++++++++--------- docs/parity/openmm2-only.md | 3 +- 2 files changed, 90 insertions(+), 29 deletions(-) diff --git a/docs/multiplayer.md b/docs/multiplayer.md index 1625bb9..d9ec6a7 100644 --- a/docs/multiplayer.md +++ b/docs/multiplayer.md @@ -188,9 +188,9 @@ 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, unsequenced (late snapshots still fill the buffer; ENet's throttle never drops them) | `VehicleState` (client→host), `WorldState` (host→clients), `PropState` (host→clients) | +| 1 State | unreliable, unsequenced (late snapshots still fill the buffer; ENet's throttle never drops them) | `VehicleState` (client→host), `WorldState` (host→clients), `PropState` (host→clients), `TrafficFull` (host→clients) | | 2 Events | reliable, ordered | `GameEvent` | -| 3 Ambient | unreliable, sequenced | `AmbientState` (host→clients): the shared cruise traffic | +| 3 Ambient | unreliable, sequenced; a message beyond ENet's MTU in unreliable fragments | `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 @@ -215,7 +215,9 @@ cars a message and no traffic hit reports, version 7 the host's props client's in full in `CarStates` and, in every car's full state, what a sample hands the next, version 10 the host's rules (the rules event; a player's own checkpoint, lap, finish and gold events refused; 8 on its own -branch). +branch), version 12 the shared traffic's police and knocked cars near a +client in full (`TrafficFull`) and an `AmbientState` of up to 2600 bytes and +320 cars. ### Handshake @@ -267,6 +269,7 @@ attempt within `connectTimeoutMs` (8 s). | 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") | +| TrafficFull | H→C | the police and knocked cars near the client in full, at the physics sample of its `CarStates` (see "Shared traffic") | | PropState | H→C | the host's ring of knocked-over props and thrown car parts (see "Props") | `SessionSettings` holds the session name, city, mode (Cruise, Checkpoint, @@ -351,14 +354,23 @@ packet to the game or discovery port, and whatever answers as the router. for its own race and newer than the last, shows a decision once, and rebuilds its waypoints from the list with its own rules (a waypoint the rules refuse is dropped). -* **Shared traffic.** An `AmbientState` holds at most 160 cars, ids 0-511, +* **Shared traffic.** An `AmbientState` holds at most 320 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). + model once per car shown (a model that fails is not tried again). A + `TrafficFull` holds at most 8 cars (ids 0-511, generations 0-7): a police + car's state is checked as a player's in `CarStates` and its controls lie + within ±1 (the throttle's cap ±10); a body's matrix is a rotation (entries + within ±2) at a position within ±16384 m, its other values are finite and + within ±10^7, its wheels' within ±100, its sleep state 0-2 and its counters + 0-1023, or the whole message is refused. A client takes it from the host + only, during a race, keeps at most 32 unread and 24 samples' worth, the + first state of a car at a sample, and simulates only a police car it shows + (of that generation) and a traffic car it lists. ### Session clock @@ -721,12 +733,16 @@ when its speed over the ground differs too), a police car 208, a knocked car 178 (258 with a body). 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 (at most 160); when they do not all fit, the cars the client has -count as 25 m nearer than they are, so the car at the edge of what fits does -not come and go either. 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, beyond 160 m every fourth (taking turns by id), and say only -"still there" in between. +2600 bytes (at most 320; protocol 12, before 1100 bytes and 160 cars, which +left the farthest 8 % out at traffic density 1); when they do not all fit, +the cars the client has count as 25 m nearer than they are, so the car at +the edge of what fits does not come and go either. A message beyond ENet's +MTU (1392 bytes) goes in two fragments, sent unreliably like the message +(`ENET_PACKET_FLAG_UNRELIABLE_FRAGMENT`; without it ENet sends the fragments +of an unreliable packet reliably). Cars within 120 m, off their rails, +police and cars new to the client carry their state in every message; the +others in every other one, beyond 180 m every fourth (taking turns 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 @@ -768,6 +784,38 @@ the past, as before, a moving car was met 2.6 m from where the host had it traffic; MM2 predicts its network cars forward from their last packet (`mmNetObject::PositionUpdate`), which is what this does for the host's. +**In full.** With each client's `CarStates` (the same physics sample) the +host sends a `TrafficFull` (`net/TrafficFull.h`, protocol 12) with the +police cars within 60 m of its car and the knocked cars with a body within +50 m, nearest first (a police car chasing it counting half as far; farther +away a police car on its driver's last controls strays more than one +predicted along its velocity), as many as one 1200-byte datagram holds (at most +eight; a car sent the last time is kept 20 m further): a police car's whole +simulation state, as the players' cars near a client travel in `CarStates` +(see "Players' cars"), with the controls its driver set averaged over the +host's frames since the last state (throttle, brake, steering, handbrake, +the throttle's cap: the driver's throttle and brake go on and off from frame +to frame); a knocked car's rigid body, what a sample hands the next, its +four wheels and its sleep state. Only the cars the host's last physics step +ran go (MM2 neither moves nor collides a police car it does not declare, +nor a body beyond its 32 movers: they stand where they are, whatever their +velocity). The host's police draw their wheels' bump numbers from their own +streams, which travel with them. The client puts each state in at the +acknowledgement of that sample (`RaceScreen::predictNetTraffic`, a +`CarPrediction::Companion` without a player) and runs it again with its own +car to the present, a police car on those controls (which it keeps until +the next state), so its car meets them with their mass where the host's +does, in the order the host's world has them (the police before the +players' cars, the bodies after). A police car is then a simulated body, +drawn between its last two samples with the host's corrections blended away +(80 ms; a switch between that and the kinematic placement 150 ms), and +knocks the traffic cars it runs into loose as the host's does (they count +as the client's own knocks, for the host's messages to confirm); a knocked +car a body of the client's `TrafficBodies`, which `NetTrafficCars` lists as +simulated (a car the client knocked loose itself goes on as the host's). A +car whose full states stop for 250 ms is predicted and kinematic again, the +host's messages leading. + **Collisions.** The received cars on their rails are instances of the client's level in `game::TrafficBodies`, as the host's rail cars are of its own, with the received cars as their traffic (`game::NetTrafficCars`): when @@ -775,10 +823,13 @@ the client's car hits one, it takes a body at once (`aiVehicleInstance::AttachEntity`, `aiVehicleActive`) and the collision runs the host's physics, the two cars pushing each other: the host simulates the same hit from the same inputs at the same session time (see "Players' -cars"). Only the client's own car knocks a car loose there (the police and -the other players' cars reach the client in the host's messages), and not -while it runs its samples again after a correction (`World::replaySample` -holds everything else still). While the knocked car moves, the local body +cars"). Only the client's own car and the police cars it simulates knock a +car loose there (the other players' cars reach the client in the host's +messages), and not while it runs its samples again after a correction +(`World::replaySample` holds everything else still): there a rail car meets +the cars run again as the body it would take, of its mass and moving along +its rail (`TrafficBodies`' `heldInertia`; protocol 12, before as a car of +infinite mass). While the knocked car moves, the local body leads, being the better guess (it runs the host's physics from the same hit; the host's knocked car arrives a trip old and is predicted along its velocity while it tumbles); once the host's messages show it off its rail @@ -787,21 +838,28 @@ or a host state stamped 150 ms after the hit still shows it on its rail (the host did not knock it), the host's messages lead and the drawing blends from where the local body left it. The cars off their rails on the host meet the client's car as kinematic instances moving at their predicted velocities -(`game::TrafficProxies`), the police as kinematic bodies. The hits happen on +(`game::TrafficProxies`), the police as kinematic bodies, unless they come in +full (above); a car standing still off its rail without a body on the host +(at rest after a knock) stands as a rail car at rest, which the client's car +knocks loose as the host's does (protocol 12). The hits happen on the host: until protocol 6 a client reported its hits (`TrafficHitEvent`), because the host's copy of its car lagged. 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: 20 messages a second to the client, 60-70 cars a message, -10.5-11.6 KB/s (13.4-15.6 KB/s before a far car's state came every fourth -message beyond 160 m); at density 1, 100-128 cars and about 21 KB/s, where -the 1100-byte budget binds (about 22 KB/s per client, 150 KB/s for seven -clients). The client's moving cars within 150 m were 3 cm (median) from the -host's at the same moment, 12 cm at the 90th percentile, through a relay of -60 ± 20 ms each way with 2 % loss; 2.6 m and 3.7 m before they were shown at -the present (docs/review/multiplayer-desync-traffic.md, which has the other -cases). +12.5-15.3 KB/s; at density 1, 100-130 cars (every car within 200 m) and +22.5-27 KB/s (before protocol 12 the 1100-byte budget bound there at about +21 KB/s and left 8 % of them out). The full states add 0.4-5.4 KB/s driving +through traffic and 6.6-11.2 KB/s with a police car near; at most 76 KB/s +per client in all. The client's moving cars within 150 m were 3 cm (median) +from the host's at the same moment, 12-24 cm at the 90th percentile, +through a relay of 60 ± 20 ms each way with 2 % loss; 2.6 m and 3.7 m +before they were shown at the present. A police car within 10 m of the +client's car, simulated there, was 6-14 cm (median) from the host's, 0.38 m +when it was dead-reckoned, and a car the client's car knocked loose 0-4 cm +in the second after (docs/review/multiplayer-desync-traffic.md, which has +the other cases). ### Damage @@ -1592,9 +1650,11 @@ the session time its state belongs to (the AI's step or the physics'); a client's `TC` lines, the cars where its car meets them each frame, and `TV`, that frame's session time of its car on the host's clock; the cars the client's car knocked loose (`TX`), the host's cars leaving their rails -(`TK`) and the client's knocks going back to the host's messages (`TL`). `mm2tool nettrace -` compares them: each car's error against the host's at the -session time of the client's car, the cars near the client one machine had -and the other did not, the police targets, the hits and knocks. +(`TK`) and the client's knocks (and the bodies it had in full) going back +to the host's messages (`TL`). `mm2tool nettrace ` compares them: each car's error against the host's at the session +time of the client's car (apart: the police and the knocked cars the client +simulates), the cars near the client one machine had and the other did not, +the police targets, the hits and knocks. 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/docs/parity/openmm2-only.md b/docs/parity/openmm2-only.md index 66f3259..3b18a8e 100644 --- a/docs/parity/openmm2-only.md +++ b/docs/parity/openmm2-only.md @@ -144,7 +144,8 @@ counted in the summary above. | `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` | | openmm2 | Replication: interest by distance with hysteresis, a packet budget (the client's cars kept at the edge), complete messages, generations for recycled slots, the client's car meeting the received cars off their rails as moving kinematic instances. The client's hit reports (`TrafficHitEvent`) went with protocol 6: the host simulates every player's car and sees the hits itself. | | `game::TrafficPrediction` (`RailMotionTracker`, `predictRailCar`, `predictBody`), `TrafficClient`'s prediction and corrections | `mmNetObject::PositionUpdate` (a network car predicted forward from its last packet) | openmm2 | Added 2026-10-09 (protocol 6, docs/review/multiplayer-desync-traffic.md): the host stamps the traffic with its AI step's time and sends a rail car's acceleration, curvature and speed over the ground; a client shows every car at its own car's time on the host's clock (its lead over the host's simulation of it added), a rail car predicted along an arc, a body along its velocity and yaw rate, the drawing blending corrections away. MM2's network cruise has no traffic; MM2 predicts its network cars forward, which this does for the host's. | -| `game::NetTrafficCars`, `TrafficBodies::Source` | `aiVehicleInstance::AttachEntity`, `aiVehicleAmbient::Impact`, `aiVehicleActive::Detach` / `PostUpdate` (the hand-over between the AI and the bodies) | openmm2 | Added 2026-10-09: `TrafficBodies` takes its traffic through an interface (the AI's for the host and single player: the same calls in the same order); a shared-traffic client gives it the received rail cars, which its own car (and a car it knocked) knocks loose at once with the host's physics; the local body leads until it rests or is 3 m from the host's car, then the host's messages (at once when the host did not knock the car). | +| `game::NetTrafficCars`, `TrafficBodies::Source` | `aiVehicleInstance::AttachEntity`, `aiVehicleAmbient::Impact`, `aiVehicleActive::Detach` / `PostUpdate` (the hand-over between the AI and the bodies) | openmm2 | Added 2026-10-09: `TrafficBodies` takes its traffic through an interface (the AI's for the host and single player: the same calls in the same order); a shared-traffic client gives it the received rail cars, which its own car (and a car it knocked) knocks loose at once with the host's physics; the local body leads until it rests or is 3 m from the host's car, then the host's messages (at once when the host did not knock the car). Protocol 12: a car standing still off its rail without a body on the host stands as a rail car at rest, which the client's car knocks loose as the host's does; in a replay a rail car meets the replayed cars as the body it would take (`RailCar::heldInertia`). | +| `net::TrafficFullMsg`, `RaceScreen::sendNetTrafficFull` / `predictNetTraffic`, `TrafficBodies::bodyState` / `setBodyState`, `CarPrediction::Companion` without a player | `aiPoliceOfficer::Update` (the police's controls), `aiVehicleActive` (a knocked car's body) | openmm2 | Added 2026-10-09 (protocol 12, docs/review/multiplayer-desync-traffic.md): the host sends each client, with its car's state, the police within 60 m with the controls their drivers set (averaged since the last state) and the knocked cars within 50 m in full, one datagram a state; the client simulates them with its own car from those states at each acknowledgement (as the players' cars near it), so its car meets them with their mass where the host's does; a police car runs on those controls until the next state. The host's police draw their wheels' bump numbers from their own streams. Only cars the host's last step ran go (MM2 leaves an undeclared police car or a body beyond its 32 movers where it is). A simulated police car knocks the traffic it hits loose. Without full states (farther, or none for 250 ms) they are predicted and kinematic as before. | | `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). | From 038e44a8bfa71ad99ecff6175b5b992acbcdf4b3 Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Fri, 9 Oct 2026 21:42:36 -0700 Subject: [PATCH 111/129] Record the second round of the shared traffic's review docs/review/multiplayer-desync-traffic.md: protocol 12 against 782f469 in the same scenarios plus a second police chase: what was fixed (O1-O3 and four more found on the way), what stays open, the bandwidth and the edits outside this area. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/review/multiplayer-desync-traffic.md | 93 ++++++++++++++++++++--- 1 file changed, 82 insertions(+), 11 deletions(-) diff --git a/docs/review/multiplayer-desync-traffic.md b/docs/review/multiplayer-desync-traffic.md index 0f6a6fc..7934b60 100644 --- a/docs/review/multiplayer-desync-traffic.md +++ b/docs/review/multiplayer-desync-traffic.md @@ -1,7 +1,8 @@ # Multiplayer desync review: shared traffic and police Reviewed on 2026-10-09 from `main` at cd5ae29 (release 0.3.1), then on top -of integration at 3f73059 (the host simulates every player's car). The +of integration at 3f73059 (the host simulates every player's car); a second +round on top of integration at 782f469 ("Round 2: protocol 12"). The maintainer's report for this area: in a multiplayer cruise over the Internet, "different cars, or the same cars in different places, on each screen". @@ -50,6 +51,59 @@ host is added to the trip), so its tails are those of the longer horizon; emulated before the merge with `OPENMM2_DEBUG_TRAFFIC_LEAD_MS`, 150 ms more gave 0.03 / 0.15 / 0.63 m and 300 ms more 0.05 / 0.49 / 1.69 m. +## Round 2: protocol 12 + +On top of integration at 782f469 (protocol 10), with the maintainer's leave +to spend more bandwidth: the first round's open O1-O3 fixed, and what the +measurements turned up on the way. Same relays, scripted driving and tool +as the first round ("How it was measured"), plus a second police chase +(post 1, 30 m) and two scripts in the review's scratch work: the police +error by distance from the client's car, and the client's corrections above +10 cm with a shared car within 12 m of its car (the ones the traffic can +cause; respawns left out). The chases and pile-ups differ from run to run +(how long a police car follows, how many cars get knocked), so each row is +the range over the runs (one to four per case); the baseline is 782f469 +built from the same sources. + +| Measure (median / 90th / 99th percentile) | 782f469 | Protocol 12 | +| --- | --- | --- | +| Moving police car within 10 m of the client's car, 60 ± 20 ms each way | 0.26-0.38 / 0.98-1.03 / 1.35-1.62 m | 0.06-0.14 / 0.16-0.73 / 0.22-1.02 m | +| Moving police car, any distance | 0.24-0.33 / 0.58-0.84 / 0.98-2.82 m | 0.10-0.15 / 0.24-0.77 / 0.33-2.64 m | +| A car the client's car hit, the second after | 0.07-0.27 / 0.26-0.90 / 0.33-4.44 m | 0.00-0.04 / 0.02-0.24 / 0.14-0.91 m | +| A knocked car the client simulates | 0.11-1.46 / 0.16-2.18 / 0.29-2.49 m | 0.00 / 0.01-0.19 / 0.06-1.17 m | +| ... 150 ± 30 ms, 5 % loss | 0.73-1.35 / 1.51-1.67 / 1.74-1.96 m | 0.00-0.08 / 0.00-0.08 / 0.13-1.40 m | +| A car the client's car hit, 150 ms (1-2 hits a run; one run 300) | 0.57-0.89 / 1.30-1.31 / 1.45-1.60 m | 0.00-0.09 / 0.00-5.47 / 0.04-7.09 m (see O9) | +| Client's corrections above 10 cm with a shared car within 12 m: ramming the police (post 0) | 28 a minute | 2-3 | +| ... a chase from post 1 (the police car pins the client's car) | 12 | 11-20 | +| ... rear-ending traffic, put back every 4 s | 43 | 3-8 | +| ... driving through traffic | 16 | 0-3 | +| ... 150 ms, 5 % loss | 6 | 1-4 | +| Density 1: the host's cars within 200 m missing on the client | 7.9 % | 0.6 % | +| Moving rail car within 150 m, 60 ms (unchanged in kind) | 0.03 / 0.18-0.23 / 0.89-3.91 m | 0.03 / 0.11-0.24 / 0.65-4.00 m | +| Bandwidth to a client, density 0.5 | 6.2-15.7 KB/s | 6.9-15.3 KB/s, and 0.4-11.2 KB/s in full | +| ... density 1 | 19.4-20.4 KB/s (103 cars a message) | 22.5-26.9 KB/s (99-130), and 0-1.4 KB/s in full | + +### Fixed + +| # | Severity | Location | Scenario | Confirmed | Fix | +| --- | --- | --- | --- | --- | --- | +| T10 (O1) | visible | `RaceScreen::updateNetCops`, `TrafficPrediction::predictBody` | The police were kinematic bodies on a client, dead-reckoned along their velocity and yaw rate over the trip and the client's lead (about 200 ms at 60 ms each way): a moving police car within 10 m of the client's car was 0.38 / 1.03 / 1.62 m from the host's, and the client's car, meeting it there with infinite mass, was corrected 28 times a minute by more than 10 cm in the ramming runs. | traces | 184c739, baa3c01, 92628b2: the host sends each client, with its car's state, the police within 60 m in full (a chasing one 60-100 m behind, simulated, strayed more than predicted along its velocity: 2.2 m at the 90th percentile against 0.7 m) (`TrafficFull`, `net/TrafficFull.h`): its whole simulation and its driver's controls averaged over the frames since the last state (the driver's throttle and brake go on and off from frame to frame; the average met the host's car 0.29 m off at the 90th percentile in a chase, the last controls 0.49 m). The client puts it in at that sample's acknowledgement and runs it again with its own car (`predictNetTraffic`, a `CarPrediction::Companion` without a player) on those controls until the next. The host's police draw their wheels' bump numbers from their own streams, which travel with them. A simulated police car knocks the traffic it hits loose, as the host's does. | +| T11 (O2) | visible | `TrafficProxies`, `NetTrafficCars` | The cars the host knocked loose were kinematic on a client (infinite mass), dead-reckoned while they tumbled: the client's car bounced off them where the host's pushed them (0.73 / 1.51 / 1.96 m off at 150 ms each way), and was corrected. | traces | 184c739, baa3c01: the knocked cars within 50 m in full (the rigid body, what a sample hands the next, the wheels and the sleep state: `TrafficBodies::bodyState`); the client gives each a body (`setBodyState`, `NetTrafficCars`' host bodies) and runs it with its car as the police. Test `TrafficBodies.ABodyPutToAStateMovesOnAlike`: a body put back to a state moves on bit for bit as it did. | +| T12 (O3) | minor | `TrafficHost::build`, `net/AmbientState.h` | At traffic density 1, 7.9 % of the host's cars within 200 m of the client's were missing there: they did not fit in 1100 bytes. | density-1 traces | 184c739: up to 2600 bytes and 320 cars a message, in two of ENet's fragments sent unreliably (`ENET_PACKET_FLAG_UNRELIABLE_FRAGMENT` on the Ambient channel; ENet sends an unreliable packet's fragments reliably otherwise); full rate within 120 m, every fourth state beyond 180 m. Test `TrafficHost.EveryCarWithin200MetresFitsAtDensityOne`. | +| T13 | minor | `RaceScreen::sendNetTraffic` (police) | A police car pressed against a wall kept 15 m/s in its velocity for 10 s while it stood; the client predicted it 3.9 m ahead (T6 for the police). | trace | baa3c01: shared standing once it has moved less than 5 cm in 100 ms (`game::StillBodies`), as the knocked cars. | +| T14 | visible | `World::replaySample`, `TrafficBodies::RailCar` | In the samples a client runs again after a correction its car met the rail cars as cars of infinite mass (`collideHeld`), where the first run knocked them loose. | by reading | baa3c01: a rail car gives the body it would take (`heldInertia`: its mass, moving along its rail), which the replayed car pushes for the rest of the replay. | +| T15 | visible | `NetTrafficCars`, `TrafficProxies` | A car standing off its rail without a body on the host (at rest after a knock) met the client's car as a kinematic instance of infinite mass, where on the host it is a rail car that takes a body when hit: ramming a pile of them the client's car was corrected by 1.6 m. | trace | baa3c01: it stands as a rail car at rest on the client, which its car knocks loose (the knock counts as confirmed at once). Test `NetTrafficCars.ACarStandingOffItsRailIsKnockedLooseAsOnTheHost`. | +| T16 | visible | `TrafficBodies::bodyState`, `RaceScreen::sendNetTrafficFull` | A knocked car the host's physics no longer ran (inferred: its table of 32 movers full, where MM2 leaves a body as it is; the car stood still, awake, for 90 s) went to the client in full with the 10 m/s its body had when it froze; the client ran it, 1.8 m from the host's for 90 s. | trace | aeb303a, 4cb28e1: only bodies and police cars the host's last step ran are sent in full. | + +### Measured and left as they are (round 2) + +* **Corrections without a shared car near.** At density 1 two thirds of the + client's corrections above 10 cm had no traffic or police car within + 12 m (props and the city; other areas). +* **Respawns.** After a respawn the client's car lacks the traffic of its + new place for a trip (92 % of the cars missing in the rear-end runs, which + put the car back every 4 s, were in the 500 ms after a respawn). + ## How it was measured * A host and one or two clients of a San Francisco cruise on this machine, @@ -153,23 +207,28 @@ simulation of it, while the traffic arrives a trip old: ## Bandwidth A car on its rail costs 120 bits (136 turning or changing speed, 148 when -its ground speed differs), as many as 1100 bytes hold (at most 160) per -client and message, 20 a second. At the cruise's default density (0.5) -10.5-11.6 KB/s to each client (60-70 cars), a fifth less than before for -the same cars (a known car beyond 160 m now carries its state every fourth -message); at density 1 the budget binds at about 21 KB/s. Seven clients: -about 80 KB/s from the host at density 0.5, 150 KB/s at density 1, on top -of the players' cars. +its ground speed differs), as many as 2600 bytes hold (at most 320; 1100 and +160 before protocol 12) per client and message, 20 a second. At the +cruise's default density (0.5) 12.5-15.3 KB/s to each client (60-70 cars); +at density 1 22.5-26.9 KB/s (100-130 cars, every car within 200 m). The +full states add one datagram of at most 1200 bytes a state: 0.4-5.4 KB/s +driving through traffic, 6.6-11.2 KB/s with a police car near. The worst +case per client is 76 KB/s (2600 + 1200 bytes 20 times a second), on top of +the players' cars; seven clients at density 1 with police near about +250 KB/s from the host. ## Open +O1-O3 of the first round are fixed in the second (T10-T12). + | # | Severity | Location | What | Needs | | --- | --- | --- | --- | --- | -| O1 | visible | `TrafficPrediction::predictBody` | The police are dead-reckoned along their velocity and yaw rate; over the host-simulated horizon (a trip plus the client's lead) a chasing police car is 0.33 m off (median), 3.7 m in the tail. | Simulating them on the client from their inputs (MM2's `mmNetObject` way) and pulling them to the host's states. | -| O2 | minor | `TrafficProxies`, `RaceScreen::updateNetCops` | The cars the host knocked loose and the police are kinematic on the client (infinite mass): the client's predicted car bounces off them where the host's pushes them, and is corrected. | Local bodies for them, as for the client's own knocks. | -| O3 | minor | `TrafficHost::build` | At traffic density 1 the farthest cars within 200 m (about 15 %, at 170-200 m) do not fit in a client's 1100 bytes and are not shown there; they stay out rather than come and go. | A larger datagram, or cheaper far cars. | | O4 | minor | `ai::Traffic` (host) | The AI's own jumps of 10-35 m in one step (see "Measured and left as they are"). | The AI area. | | O5 | minor | `TrafficBodies` (host) | T6's cause. | A reproduction. | +| O6 | minor | `RaceScreen::predictNetTraffic` | A simulated police car runs on its driver's averaged controls over the client's lead (about 200 ms at 60 ms each way); the driver's turns and brakes in that time, and what `aiVehiclePhysics` does to the car's body besides its controls (backing up turns its matrix, a wreck's momentum is damped), are not replayed: chasing 20-60 m away it was up to 1-2 m off at the 99th percentile, as dead reckoning was, and once a police car manoeuvring against the traffic stood 1 m off for 40 s. Within 10 m, where the cars meet, 0.06-0.14 m (median). | The police driver run on the client too (its map, targets and obstacles), or its body changes sent. | +| O7 | minor | `RaceScreen::predictNearCars` (the players' cars area) | The other players' cars a client simulates (`RemoteVehicle::predicted`) meet the rail cars as cars of infinite mass in its real samples: they cannot knock one loose there, where the host's simulation of them does. | `body.player` for them, as for the simulated police. | +| O8 | minor | `NetTrafficCars`, `TrafficBodies` (host) | A knocked car the client's car leans on goes to sleep on the host and is knocked loose again when pushed, every second or so; the client follows (a body while the full states come, a standing car between), its drawing jumping a few centimetres each time. | Nothing unless it shows. | +| O9 | minor | `NetTrafficCars` (the first round's local knock) | At 150 ms each way a car the client's car knocked loose was 3-7 m from the host's in the second after in two runs of three (one hit each; 4 cm at the 99th percentile with 300 hits in the third), then handed back: the client's car, predicted 300 ms ahead, met it otherwise than the host's simulation of it did. Before, 1.3-1.6 m in such runs. | More runs; a shorter lead for the local body at long trips. | ## Edits outside this area's files @@ -182,3 +241,15 @@ unchanged, the opponent sweep 516/517 and 508/517; `bodiesHitAsPlayer`; (`traceFile`), `src/net/AmbientState.h`, `src/net/Protocol.h` (6), `tools/mm2tool/cmd_nettrace.cpp`. New: `game/net/TrafficPrediction`, `NetTrafficCars`, `TrafficTrace` (parity class O). + +Round 2: `src/game/net/PlayerCars.{h,cpp}` (the players' cars area: +`CarPrediction::Companion` takes a car without a player, driven by a +function, or a body that is no car, put to its state by a function; the +players' cars go through it unchanged), `src/net/{Protocol,Session, +Transport}`, `src/game/net/NetGame` (`TrafficFull`, 12, the Ambient +channel's unreliable fragments), `src/game/TrafficBodies.{h,cpp}` (a body's +state in and out, a rail car's mass in replays), `src/app/RaceScreen.cpp` +(the host's full states and police controls, the client's simulated police +and bodies). New: `src/net/TrafficFull.h` (parity class O). The single-player +game is unchanged: the opponent sweep gives integration's 515/517 and +507/517 (b33bebc). From dcac54262c9e060b555ddff9fad325184123b719 Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Fri, 9 Oct 2026 21:46:48 -0700 Subject: [PATCH 112/129] Record the rules' second round docs/review/multiplayer-desync-rules.md, "Second round: the rough edges": MM2's water and fall handlers (mmGame::Update, mmGameMulti::HitWaterHandler, mmMultiCR's), what changed in protocol 14, the runs (the bay's water in a cruise and in Cops and Robbers, contested gold) and their results before and after: checkpoints confirmed with the loss tail gone (99th percentile 767-776 to 417-441 ms at 150 ms and 5 % loss), the water agreeing at the same samples and now the host's, no predicted pickup undone (3 of 7 contested runs before), the bandwidth; R9 and O1-O6. docs/multiplayer.md: a client's predicted water as its own bullet. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/multiplayer.md | 10 +- docs/review/multiplayer-desync-rules.md | 190 +++++++++++++++++++++++- 2 files changed, 190 insertions(+), 10 deletions(-) diff --git a/docs/multiplayer.md b/docs/multiplayer.md index 2140b92..975b30c 100644 --- a/docs/multiplayer.md +++ b/docs/multiplayer.md @@ -1150,10 +1150,12 @@ for everything) decides them on the host from the cars it simulates (see 1 m more, or on its way there in the next quarter of a second, as this machine has it where the host will run its sample: the cars it simulates with its own as they are, the others run on from their - drawing), when it waits for the host's word as MM2's did. The car's - water and fall are predicted the same way (see "Players' cars"). The host's `GoldTaken` for it confirms it; one for another - car undoes it and shows that car's; the state undoes it once the host - has seen the car 30 samples past the pickup without granting it. + drawing), when it waits for the host's word as MM2's did. The host's + `GoldTaken` for it confirms it; one for another car undoes it and + shows that car's; the state undoes it once the host has seen the car + 30 samples past the pickup without granting it. + * the water and the fall on its own car (see "Players' cars", "The water + and the fall"): put back at once, the host's states confirming it. * nothing else: the finishes, the results, the standings, drops, deliveries, scores, the new places and the limits are the host's word only. A client's own "finished in" line (MM2's client line: 149, 106, diff --git a/docs/review/multiplayer-desync-rules.md b/docs/review/multiplayer-desync-rules.md index cb91451..de88dc2 100644 --- a/docs/review/multiplayer-desync-rules.md +++ b/docs/review/multiplayer-desync-rules.md @@ -2,7 +2,11 @@ Reviewed and reworked on 2026-10-09 on top of integration (cae6989, the host simulating every player's car; then 14dc206, the host-run props; then -04112e1, the players' cars' second round). +04112e1, the players' cars' second round). A second round the same day +(below, "Second round: the rough edges", protocol 14, on integration +782f469) made the water and the fall the host's, sent the race's state +unreliably too and made a client's pickup prediction wait for a car about +to reach the gold. This is open item O3 of docs/review/multiplayer-desync-cars.md: with every player's car simulated by the host, each player's machine still decided its @@ -263,6 +267,158 @@ players a race's message is about 85 bytes (340 B/s to each client, finish event per event (a few bytes). The players' cars' states are 6.2 KB/s to each client for comparison. +## Second round: the rough edges (protocol 14) + +The maintainer accepts more bandwidth and wants the remaining rough edges +fixed. Four were open after the first round: O2 (the water was still a +client's word), O3 (a predicted pickup undone), O4 (ResetRules' Cops and +Robbers repair) and O1 (a checkpoint's confirmation a round trip late). + +### MM2's water and fall + +`mmGame::Update` checks the player's car after each frame's update: its +matrix below -50 m calls the game's `DropThruCityHandler`; otherwise, the +car's vehSplash latched (mmPlayer +0x2340 counts the frame's seconds, ++0x2348 is the handled flag), past 5 s it calls `HitWaterHandler`, clears +the timer and sets the flag. `mmGameMulti::DropThruCityHandler` calls the +water's handler. `mmGameMulti::HitWaterHandler`, with the race's +mmWaypoints, keeps the reset position and heading, sets them to the last +waypoint cleared (`GetWaypoint`, `GetHeading`), resets the player +(mmPlayer::Reset) and puts them back, keeping the waypoint count; without +waypoints it is `mmGame::HitWaterHandler` (mmPlayer::Reset at the reset +position); then `SendHitWater`. `mmMultiCR::HitWaterHandler` and +`DropThruCityHandler` drop a carried gold back (`DropGold`, +`FondleCarMass`, `EnableRegen`), reset the player and keep its waypoint +count. Each machine ran this for its own car. + +### What was left, and what changed + +1. **The water and the fall on the host** (e168a58). Before, a client's + session ran the timer on its own frames and sent a Reset or RespawnAt + command, which the host carried out when its own simulation of the car + had been in the water for 4 of the 5 s or was below -40 m + (`game::ResetRules`, a second's and 10 m's slack). Now mmGame::Update's + checks run on the car's samples (`game::NetCarDriver:: + setWaterHandler`, before each sample on the car as the last one left + it, the timer counting samples): on the host for every car it + simulates, with the respawn point from its referee + (`RaceReferee::lastCleared`, `NetRules::respawnIndex`) or the car's + reset position; on a client for its own car, predicted, with its + session's last checkpoint (`Session::netRespawnPoint`). Every car's full + state carries its vehSplash (latched, buoyancy, level) and the + handler's time, so a client resumes the host's water exactly, and a + client that disagrees is corrected to the host's car. A client's Reset + and RespawnAt are refused (only the debug respawn passes, with the + host's `OPENMM2_DEBUG_RESPAWN_MS`). The session shows only the water's + message; the race screen presents the reset (`presentOwnWaterReset`). + Cops and Robbers' water handler follows the host's resets. +2. **ResetRules narrowed** (e168a58): a repair only for a car past its + maximum damage (the wreck penalty's); a delivery's is the host's own. +3. **The rules state unreliably too** (e168a58, da767c3): in a race the + host sends each player its state alone (`RulesState` on the State + channel: the newest 16 waypoints, the samples seen, the place and the + icons; no decisions, no results) 20 times a second and at once on a + hit, numbered with the reliable messages, which now go once a second + when nothing happened (Cops and Robbers' stay at four a second: its + state goes no other way). Whichever arrives first stands; a reliable + message older than the newest state still brings its decisions. +4. **A contested pickup waits** (e168a58): a client predicts its pickup + only when no other car is within the gold's 5 m and 1 m more, or on its + way there in the next quarter of a second, as the client has it where + the host will run its sample: the near cars it simulates with its own + (`NearCarState`) as they are, the others run on from their drawing by + the playout delay and its car's lead (inferred margins). +5. Measurement aids (0c96102, da64b77): the RC and RW trace lines, + rulesreport's confirmation times, water resets and a client's own + pickups. Tests (e168a58, 8dbc9a3, 86db4c6): `NetWater.*`, + `WaterState.*`, the narrowed ResetRules, the unreliable state (on its + own, fuzzed, and in the live three-machine test) and the contested + pickup. + +### How it was measured + +As the first round (host and one client on one machine, `netprobe relay` +at 60 ± 20 ms 2 % loss and 150 ± 20 ms 5 % loss, release builds, the +autopilot in the races, the ram inputs in Cops and Robbers, now 270 s). +**Before** is integration 782f469 with the measurement aids (0c96102); +**after** is e168a58 and, for Cops and Robbers, da767c3. Added: + +* **The water**: a cruise and a Cops and Robbers game with both cars + started over the bay (room 228, `OPENMM2_DEBUG_START=-1779,3,-1040,0` and + `-1779,3,-1130,0`): every five seconds in the water the car goes back to + its reset position, in the water again (150 s, 120 s). Once with a client + whose simulation differs (`OPENMM2_DEBUG_NETCARS_NOISE=0.002`). +* **Contested gold**: both cars on opposite sides of the first gold, + facing it, at full throttle, the host's 12 m away and the client's 10 to + 14 m (seven runs each), so that they reach it together. + +### Results + +**Checkpoints** (the client, player 1; every run: 0 shown and never +counted, 0 taken back, every finish the host's measure on both machines): + +| Run | Link | Lead before the host counted (median) | The host's word after shown: median / 99 % / most | +| --- | --- | --- | --- | +| race before | 60 ms | 142 ms | 208 / 217 / 217 ms | +| race after | 60 ms | 117 ms | 208 / 217 / 217 ms | +| circuit before | 60 ms | 130 ms | 200 / 217 / 217 ms | +| circuit after | 60 ms | 142 ms | 217 / 233 / 233 ms | +| race before | 150 ms, 5 % | 238 ms | 458 / 776 / 776 ms | +| race after | 150 ms, 5 % | 241 ms | 395 / 417 / 417 ms | +| circuit before | 150 ms, 5 % | 227 ms | 392 / 767 / 767 ms | +| circuit after | 150 ms, 5 % | 226 ms | 400 / 441 / 441 ms | + +A lost reliable message held the confirmation until ENet sent it again +(the 99th percentile twice the median at 5 % loss); the unreliable copy +takes the tail away. The median is a round trip plus the host's margin of +inputs and cannot go lower without taking the client's word. + +**The water** (each machine's own car's resets against the host's): + +| Run | Link | Before | After | +| --- | --- | --- | --- | +| cruise | 60 ms | 24 / 24 at the same sample, 0 corrections | 24 / 24, 0 corrections | +| cruise | 150 ms, 5 % | 21 / 21, 0 corrections | 24 / 24, 0 corrections | +| Cops and Robbers | 60 ms | 18 / 18 (one more on the client at the end of the run) | 18 / 18 | +| cruise, a client that simulates differently | 60 ms | 13 / 13 | 13 / 13 | + +With an honest client the two agree: its word was checked and the +simulations match. What changed is who decides: a client can no longer +put its car back (a second early, or anywhere the checks allowed), and its +timer counts the samples the host counts. One race started with both cars +in the water (`OPENMM2_DEBUG_SPAWN`) diverged the same way before and +after: the host simulates a client's car only from its inputs after its +own loading, and this car sank while held on the grid during the load (a +held car does not float, vehCar::Update), so the client's car fell below +-50 m at its sample 140 and the host's at 436; before, the host refused the +client's reset and the car went back into the water; after, the host's +states take its prediction back the same way. It needs a car spawned in +the water by the development aid. + +**Cops and Robbers**: + +| Run | Link | Gold events (agree) | Scores | Pickups predicted and undone | The client's own pickups | +| --- | --- | --- | --- | --- | --- | +| ram before | 60 ms | 5 (yes) | the same | 1 | 1 predicted, 1 on the host's word | +| ram after | 60 ms | 3 (yes) | the same | 0 | 1 on the host's word | +| ram before | 150 ms, 5 % | 11 (yes) | the same | 1 | 1 predicted, 1 on the host's word | +| ram after | 150 ms, 5 % | 5 (yes) | the same | 0 | (none) | +| contested before | 60 ms, 7 runs | 7 (yes) | the same | 3 | 1 predicted (202 ms early) | +| contested after | 60 ms, 7 runs | 7 (yes) | the same | 0 | 1 on the host's word (65 ms after it decided) | + +In the contested runs the host's car took the gold six times of seven +before and after; before, the client showed "You have the Gold!" and took +it back in three of them. After, it waits whenever the host's car is at +the gold or about to be, and its own pickup shows a trip later (MM2's +client always waited for 0x25a). + +**Bandwidth** (per client): in a race the reliable messages fell from +93-101 B/s to 22-27 B/s and the unreliable state is 411-494 B/s (20 a +second, about 23 bytes with two players; about 40 with eight: 800 B/s to +each client, 5.6 KB/s from the host). Cops and Robbers' stays 284-292 B/s. +A car's full state costs two bits more, twelve bytes more while it is in +the water. + ## Deviations from MM2 * The host decides every machine's rules (MM2: each machine its own car's, @@ -283,6 +439,13 @@ finish event per event (a few bytes). The players' cars' states are * The place and the icon numbers use the host's view of each car (its target is the nearest one at its last hit; the Next / Prev. Checkpoint keys stay on that player's machine). +* The water and the fall (protocol 14): the host runs mmGame::Update's + checks for every car it simulates (MM2: each machine for its own car), + before each sample rather than after each frame, the water's 5 s counted + in samples; a client predicts its own. +* A client's pickup prediction waits for the host's word while another car + is near the gold or heading into it (inferred margins: 1 m beyond the + gold's 5 m, a quarter of a second). ## Findings @@ -296,15 +459,30 @@ finish event per event (a few bytes). The players' cars' states are | R6 | minor | `RaceScreen::netCommandAllowed` | A client's water respawn could put its car on any checkpoint (players' cars review O4). | fixed for races: only at the start or a checkpoint the host counted for it (`game::ResetRules`' respawn points) | | R7 | crash (development aid only) | `game::NetAutopilot` (new) | The autopilot's AI took the player's car's impact callback and left it pointing at a destroyed driver. | fixed before measuring (619ce6f) | | R8 | visible | `Session::playerFinished` | A machine whose player finished second or later showed "Place 1/N" from the finish on: the place was its own count of finishers, which network finishes never raised. | fixed: the host's place, kept from the finish on | -| O1 | minor | the host's frame | The host's own car is decided with no latency; a client's a trip later. Its finish time does not depend on it (measured in its own samples), but a client's checkpoints are confirmed a trip later (they show at once, predicted). | open: inherent | -| O2 | minor | Cops and Robbers | Whether a car was in the water (its reset) is still its player's word, checked by `game::ResetRules` (five seconds in the water on the host's simulation) before the host applies it. | open: the host could run the water handler itself on the cars it simulates | -| O3 | minor | Cops and Robbers | A predicted pickup is undone when the host gave the gold to a car the client had not yet seen at the gold (the other car's state a trip old): one in each run. | open: inherent to predicting; MM2's client waited for 0x25a | -| O4 | minor | `game::ResetRules` | Its Cops and Robbers repair exception (any repair allowed) is now only needed for a client's own command; the host repairs a deliverer's car itself. | open: could be narrowed | +| R9 | minor (security) | `Session::updateHazards`, `RaceScreen` | The water's and the fall's handler ran on the client's frames for its own car and its reset was its command, which the host took with a second's and 10 m's slack: a client could put its car back a second early, or not at all (the host never did it for it). | fixed (protocol 14): the host's own, on the car's samples; a client's reset refused | +| O1 | minor | the host's frame | The host's own car is decided with no latency; a client's a trip later. Its finish time does not depend on it (measured in its own samples), but a client's checkpoints are confirmed a round trip later (they show at once, predicted); at 5 % loss a lost message doubled that. | tightened (protocol 14): the unreliable state takes the tail away (99 %: 767-776 to 417-441 ms at 150 ms); the round trip itself is inherent | +| O2 | minor | Cops and Robbers | Whether a car was in the water (its reset) is still its player's word, checked by `game::ResetRules` (five seconds in the water on the host's simulation) before the host applies it. | fixed (protocol 14): the host runs the water and fall handlers for every car | +| O3 | minor | Cops and Robbers | A predicted pickup is undone when the host gave the gold to a car the client had not yet seen at the gold (the other car's state a trip old): one in each run. | fixed (protocol 14): no prediction while another car is at the gold or about to be, as the near-car simulation has it; 3 of 7 contested runs undone before, 0 after | +| O4 | minor | `game::ResetRules` | Its Cops and Robbers repair exception (any repair allowed) is now only needed for a client's own command; the host repairs a deliverer's car itself. | fixed (protocol 14): a repair only for a wrecked car | +| O5 | minor (development aid) | the players' cars | The host simulates a client's car from its inputs after its own loading; a car that moves while held on the grid (sinking in the water from a debug spawn) is somewhere else on the client by then, and the two put it back at different samples. | open: only with `OPENMM2_DEBUG_SPAWN` in the water; the players' cars' start | +| O6 | minor | Cops and Robbers | Its state goes only reliably (four a second); a lost message delays an undone pickup or a missed decision's stand-in until ENet sends it again. | open: its scores and gold would need the reliable decisions' order to go unreliably | ## Reproducing The scripts are kept with the scratch files of this review -(`desync-rules/`); the runs need the retail ISO. +(`desync-rules/`, the second round's in `round2/`); the runs need the +retail ISO. + +``` +# second round: run2.sh +# (AUTOPILOT=0, HOST_ENV / CLIENT_ENV add variables to either instance) +batch.sh # races, circuits, Cops and Robbers (ram), the water: both links +batch_before2.sh # the before build's Cops and Robbers (270 s) and the race in the water +batch_contest.sh # contested gold, before and after; Cops and Robbers on the final build +batch_water2.sh # the water with a client that simulates differently +report.sh [run]... # rulesreport and the bandwidth and correction lines +report_contest.sh # the contested runs +``` ``` batch.sh # before and after, both links: run.sh (races, autopilot), run_cr.sh (Cops and Robbers) From 0dbe185a88ea3f283e43399d391ef540b002af62 Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Fri, 9 Oct 2026 21:53:14 -0700 Subject: [PATCH 113/129] Let a client that fell behind the host's samples skip to them When a client stalled, or its inputs were lost for longer than the host repeats them, the host coasted its car on and then dropped every input as late, until the client's 3% faster pace made the gap up: a minute for two seconds (one 75 s run here had the host's car of the client parked while the client drove off 500 m). A state acknowledging a sample the client has not run now makes it count the samples up to it, the round trip and the host's margin as run (CarPrediction::skipTo), so its next inputs reach the host in time; the state then corrects the car to where the host coasted it. The players' cars' section of docs/multiplayer.md describes this round's states, near cars and drawing. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/multiplayer.md | 45 +++++++++++++++++++++++---------- src/app/RaceScreen.cpp | 10 ++++++++ src/game/net/PlayerCars.cpp | 17 +++++++++++++ src/game/net/PlayerCars.h | 11 ++++++++ tests/game/test_player_cars.cpp | 41 +++++++++++++++++++++++------- 5 files changed, 101 insertions(+), 23 deletions(-) diff --git a/docs/multiplayer.md b/docs/multiplayer.md index 1625bb9..826e5ac 100644 --- a/docs/multiplayer.md +++ b/docs/multiplayer.md @@ -215,7 +215,10 @@ cars a message and no traffic hit reports, version 7 the host's props client's in full in `CarStates` and, in every car's full state, what a sample hands the next, version 10 the host's rules (the rules event; a player's own checkpoint, lap, finish and gold events refused; 8 on its own -branch). +branch), version 11 `CarStates` after every host frame that ran a sample, +the near set (three cars within 60 m) with as many of them in full as fit +one packet, and a near client car's inputs the host holds for its next +samples. ### Handshake @@ -625,7 +628,8 @@ every player's car on the host: stream (`CarSim::ownRandom`) on every machine, so the host and the car's own machine simulate it alike whatever else each simulates (deviation: MM2 has one `rand()` for the game). -* **States.** 20 times a second the host sends each client its +* **States.** After every frame that ran a sample (up to 60 times a + second; 20 before protocol 11) the host sends each client its `CarStates`: the number of the last input applied to its car, the car's state after it at full precision (the body's matrix, momenta, velocities and last push; the wheels' spin, turn, springs and tyre deflections; the @@ -633,10 +637,13 @@ every player's car on the host: pedal swap and the hold; and what a sample hands the next: the force and torque the wheels and engine set for it, each tyre's rolling resistance, and in a contact the impulses and pushes: about 310 bytes, 370 in a - contact), every other player's car as a `VehicleSnapshot`, and the two - nearest other players' cars within 40 m of the client's (kept to 50 m; - not one towing a trailer) in full with the input the host last applied to - them (`net::NearCarState`). + contact), every other player's car as a `VehicleSnapshot`, the near set + (the three nearest other players' cars within 60 m of the client's, kept + to 70 m; not one towing a trailer) and, of those, as many in full as keep + the message in one ENet packet (1372 bytes: ENet would send a longer + unsequenced packet as reliable fragments), in turn, each with the input the + host last applied to it and, for another client's car, the inputs the host + already holds for its next samples (`net::NearCarState`). * **Prediction** (`game::CarPrediction`). A client runs its car on its inputs at once and keeps each sample's input and the car's whole state after it (`CarSim::saveState`, 3 s). When a host state for a sample @@ -659,18 +666,28 @@ every player's car on the host: in full) are simulated there along with its own: at every state the client puts them to the host's state at the acknowledged sample and runs them with its own car through the later samples (`CarPrediction`'s - companions), on the input the host last applied to them, and on between - states. Its car then meets them where the host's does and both give way - by their masses, as on the host; only the other player's change of input - since that state is unknown. Farther ones (by their distance and how fast + companions), on the inputs the host holds for them and then the last one + again, and on between states; it runs them again only when the host's + state or inputs differ from what it ran them on (as its own car: 3 mm, + 3 cm/s, 0.0015 in the matrix, any input), and then puts its own car to + the host's state too, even within the tolerance (in a contact what the + tolerance lets pass grows). Its car then meets them where the host's does + and both give way by their masses, as on the host; only the other player's + change of input since that state is unknown. Farther ones (by their distance and how fast they close in the samples run again) run again without the client's car, which stays where each sample had it. Every machine keeps the players' cars in the world's movers in player order (the host's first), and a replay runs them in that order: two cars collide in that order, and the - order changes the outcome. They are drawn between their last two samples - with what each state moves them by eased away (80 ms half-life), and the - switch between simulating one and placing it at its states eased over - 150 ms. + order changes the outcome. They are drawn at their player's present (where + the car stood a lead back, little predicted: what the host's newest + states put there) unless the two cars may meet within half a second (8 m + apart, or closing on the 5 m between at more than that rate), and at this + machine's car's time then (between their last two samples), sliding + between the two over a quarter of a second; what each state moves them by + is eased away (80 ms half-life), and the switch between simulating one and + placing it at its states over 150 ms. (`OPENMM2_NET_NEAR_DRAW=ahead` draws + them at this machine's car's time always, `OPENMM2_NET_STATE_HZ=` sends + fewer states, for comparison.) * **The other cars farther away** on a client are drawn interpolated from the host's states a playout delay in the past, as before, and its own car collides with them there as kinematic bodies moving at their velocity; the diff --git a/src/app/RaceScreen.cpp b/src/app/RaceScreen.cpp index a476bbe..9c0102e 100644 --- a/src/app/RaceScreen.cpp +++ b/src/app/RaceScreen.cpp @@ -5425,6 +5425,16 @@ class RaceScreen final : public Screen { } updateNetCarLead(u.ack, u.time); } + // The host ran this car's samples past this machine's (it stalled, or + // its inputs were lost for a while): count those as run. + if (ack != 0 && ack >= m_prediction.nextSeq()) { + const double rtt = ctx.netGame->peerStats(net::kHostPlayerId).rttMs; + const auto trip = static_cast( + std::ceil(rtt / (static_cast(phys::kFixedSampleStep) * 1000.0))) + 1; + if (const std::uint32_t n = m_prediction.skipTo(ack, trip); n > 0) + log::info("netcars: the host ran {} samples of this car this machine had not; skipped to {}", + n, m_prediction.nextSeq()); + } while (m_netWaiting.size() > 1 && m_netWaiting.back().first - m_netWaiting.front().first > 1000.0) m_netWaiting.pop_front(); // a second of reports // The host keeps a sample of this car's inputs in hand at the least diff --git a/src/game/net/PlayerCars.cpp b/src/game/net/PlayerCars.cpp index 01e9b57..8567de0 100644 --- a/src/game/net/PlayerCars.cpp +++ b/src/game/net/PlayerCars.cpp @@ -412,6 +412,23 @@ void CarPrediction::endSample(const SimVehicle& car, const NetCarDriver& driver) m_history.pop_front(); } +std::uint32_t CarPrediction::skipTo(std::uint32_t ack, std::uint32_t trip) { + const std::uint32_t to = ack + trip + HostInputQueue::kStartMargin; // the next sample's number + if (to <= m_next || m_history.empty() || m_history.back().seq != m_next - 1) + return 0; + const std::uint32_t skipped = to - m_next; + Entry last = m_history.back(); + last.commands.clear(); + last.input.events = 0; + for (; m_next < to; ++m_next) { + last.seq = m_next; + m_history.push_back(last); + } + while (m_history.size() > m_options.history) + m_history.pop_front(); + return skipped; +} + std::optional CarPrediction::message() const { if (m_history.empty() || m_history.back().seq != m_next - 1) return std::nullopt; diff --git a/src/game/net/PlayerCars.h b/src/game/net/PlayerCars.h index 57bbd60..e3e7b27 100644 --- a/src/game/net/PlayerCars.h +++ b/src/game/net/PlayerCars.h @@ -214,6 +214,17 @@ class CarPrediction { void beginSample(SimVehicle& car, NetCarDriver& driver, const net::CarInputFrame& input); void endSample(const SimVehicle& car, const NetCarDriver& driver); + // The host has run this car's samples up to `ack` and this machine has + // not (it stalled, or its inputs were lost for longer than the host + // repeats them): the samples up to `ack`, the `trip` the host runs on + // while the state and the next inputs travel (samples) and the host's + // margin count as run, standing as the newest did (on its input without + // keys), so that + // the next inputs reach the host before their samples rather than seconds + // after (it ignores late ones, and the 3% faster pace would take a + // minute to make up two seconds). Returns how many it skipped. + std::uint32_t skipTo(std::uint32_t ack, std::uint32_t trip); + // The inputs and commands the host has not acknowledged (the newest // net::kMaxInputFrames), or nothing before the first sample. std::optional message() const; diff --git a/tests/game/test_player_cars.cpp b/tests/game/test_player_cars.cpp index c80c074..b2f1020 100644 --- a/tests/game/test_player_cars.cpp +++ b/tests/game/test_player_cars.cpp @@ -73,6 +73,8 @@ struct Outcome { int replayed = 0; float largest = 0.0f; // the largest correction's move (m) std::uint64_t missed = 0; + std::uint64_t missedAfterStall = 0; // a second after a stall ended + std::uint32_t skipped = 0; }; // The client drives `samples` samples; its messages reach the host @@ -80,7 +82,7 @@ struct Outcome { // samples after it sent them (every third sample). `lose` drops every n-th // message of the client's (0 none). Outcome simulate(const vfs::Vfs& vfs, int samples, std::uint32_t upLatency, std::uint32_t downLatency, - int lose = 0) { + int lose = 0, std::uint32_t stallAt = 0, std::uint32_t stallFor = 0) { Machine host(vfs), client(vfs); const Mat34 start = Mat34::translation({0.0f, 0.6f, 0.0f}); client.car->setResetPos(start); @@ -93,9 +95,13 @@ Outcome simulate(const vfs::Vfs& vfs, int samples, std::uint32_t upLatency, std: Outcome run; const float dt = phys::kFixedSampleStep; for (std::uint32_t t = 1; t <= static_cast(samples); ++t) { - // Client: the host's states due by now, then its sample. - while (!down.empty() && down.front().due <= t) { + // Client: the host's states due by now, then its sample (none while + // it stalls). + const bool stalled = t >= stallAt && t < stallAt + stallFor; + while (!stalled && !down.empty() && down.front().due <= t) { const auto& [ack, state] = down.front().msg; + if (ack >= prediction.nextSeq()) + run.skipped += prediction.skipTo(ack, upLatency + downLatency); const auto c = prediction.acknowledge(*client.car, client.driver, client.world, ack, state); if (c.corrected) { ++run.corrections; @@ -107,12 +113,16 @@ Outcome simulate(const vfs::Vfs& vfs, int samples, std::uint32_t upLatency, std: if (prediction.nextSeq() == 1) prediction.command(*client.car, {0, net::CarCommandKind::ResetTo, client.car->sim().resetPos(), client.car->sim().resetRotation}); - prediction.beginSample(*client.car, client.driver, driveInput(prediction.nextSeq())); - client.world.step(dt); - prediction.endSample(*client.car, client.driver); - const bool lost = lose > 0 && t % static_cast(lose) == 0; - if (const auto m = prediction.message(); m && !lost) - up.push_back({t + upLatency, *m}); + if (!stalled) { + prediction.beginSample(*client.car, client.driver, driveInput(prediction.nextSeq())); + client.world.step(dt); + prediction.endSample(*client.car, client.driver); + const bool lost = lose > 0 && t % static_cast(lose) == 0; + if (const auto m = prediction.message(); m && !lost) + up.push_back({t + upLatency, *m}); + } + if (t == stallAt + stallFor + 60) + run.missedAfterStall = queue.missed(); // Host: the inputs due by now, then its sample. while (!up.empty() && up.front().due <= t) { queue.receive(up.front().msg); @@ -485,6 +495,19 @@ TEST(PlayerCars, ACarPredictedOnTheSameInputsNeedsNoCorrection) { EXPECT_EQ(run.missed, 0u); } +TEST(PlayerCars, AClientThatStalledSkipsToTheHostsSamples) { + MM2_REQUIRE_GAME_DATA(); + // The client freezes for two seconds: the host coasts its car through + // them, then the client counts those samples as run and its inputs reach + // the host in time again (otherwise they arrive behind the host's samples + // and are dropped until the 3% faster pace makes up two seconds: over a + // minute). + const Outcome run = simulate(*test::gameData(), 900, 6, 6, 0, 300, 120); + EXPECT_GE(run.skipped, 100u); + EXPECT_LE(run.missed, run.missedAfterStall + 2); // no input missed after the first second back + EXPECT_LE(run.missedAfterStall, 120u + 60u + 12u); +} + TEST(PlayerCars, LostInputsAreRepeatedFromTheNextMessage) { MM2_REQUIRE_GAME_DATA(); // Every fourth message lost: the next one carries its inputs again. From 134c9792de43506a80fc76155a3d3967f167b567 Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Fri, 9 Oct 2026 21:54:33 -0700 Subject: [PATCH 114/129] List the full traffic state message in the parity manifest Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/parity/README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/parity/README.md b/docs/parity/README.md index 485643c..4d653c4 100644 --- a/docs/parity/README.md +++ b/docs/parity/README.md @@ -490,6 +490,7 @@ audited in [openmm2-only.md](openmm2-only.md). | `src/net/Sha256.h` | 37 | O | - | | `src/net/Snapshot.cpp` | 94 | O | - | | `src/net/Snapshot.h` | 106 | O | - | +| `src/net/TrafficFull.h` | 154 | O | - | | `src/net/Transport.cpp` | 270 | O | - | | `src/net/Transport.h` | 109 | O | - | | `src/net/UpnpBackend.cpp` | 190 | O | - | From 601a35be6f648179133363a58977c1b8dbdf1755 Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Fri, 9 Oct 2026 21:59:10 -0700 Subject: [PATCH 115/129] Carry a contact's bound and hardest pusher with a car's state A car's full state now also holds, when the sample's push has moved the body past it, the bound's matrix as the collisions saw it (the next sweep starts there), and the collider that pushed it hardest in the sample (CopyLastMatrix reads it) as a player's number. Rebuilt without them, a car pressed against another drifted from the host's: a push against a braking car was corrected by 0.93 m; now twice by a centimetre (tests/game/test_player_cars.cpp). Co-Authored-By: Claude Opus 5.5 (1M context) --- src/app/RaceScreen.cpp | 21 +++++++++++++++-- src/game/net/PlayerCars.cpp | 41 +++++++++++++++++++++++++-------- src/game/net/PlayerCars.h | 14 +++++++++-- src/net/PlayerCars.h | 26 ++++++++++++++++++++- tests/game/test_player_cars.cpp | 25 +++++++++++++++++--- tests/net/test_player_cars.cpp | 23 +++++++++++++----- 6 files changed, 126 insertions(+), 24 deletions(-) diff --git a/src/app/RaceScreen.cpp b/src/app/RaceScreen.cpp index 9c0102e..26d1d9f 100644 --- a/src/app/RaceScreen.cpp +++ b/src/app/RaceScreen.cpp @@ -5474,6 +5474,14 @@ class RaceScreen final : public Screen { } std::vector companions; predictNearCars(newest->nearIds, newest->near, ack, companions); + m_prediction.pusherKey = [this, &ctx](std::uint8_t id) -> const void* { + if (id == ctx.netGame->localId()) + return m_player->sim().body.collider.key(); + const auto it = m_remotes.find(id); + if (it == m_remotes.end() || !it->second.sim) + return nullptr; + return it->second.sim->sim().body.collider.key(); + }; // The shared traffic's cars on their rails around this car: the // samples run again meet each where that sample met it (or, the // experiment, where the host had it at that sample's time); after, @@ -5735,6 +5743,15 @@ class RaceScreen final : public Screen { std::vector upcoming; }; std::vector full; + // The players' cars by their colliders (the states' pushers). + const game::PusherOf pusherOf = [this, &ctx](const void* key) -> std::uint8_t { + if (key == m_player->sim().body.collider.key()) + return ctx.netGame->localId(); + for (const auto& [id, rv] : m_remotes) + if (rv.sim && key == rv.sim->sim().body.collider.key()) + return id; + return net::kPusherOther; + }; if (!m_player->trailer()) full.push_back({ctx.netGame->localId(), m_player.get(), m_netInput, {}}); for (const auto& [id, rv] : m_remotes) @@ -5752,7 +5769,7 @@ class RaceScreen final : public Screen { msg.waiting = rv.inputs.takeLeastWaiting(); msg.hasOwn = rv.placed && msg.ack != 0; if (msg.hasOwn) - msg.own = game::ownCarState(*rv.sim, rv.resets); + msg.own = game::ownCarState(*rv.sim, rv.resets, pusherOf); for (const auto& c : cars) if (c.first != id) msg.cars.push_back(c); @@ -5780,7 +5797,7 @@ class RaceScreen final : public Screen { const Full* f = near[(rv.nearTurn + k) % near.size()].second; net::NearCarState n; n.id = f->id; - n.state = game::ownCarState(*f->car, 0); + n.state = game::ownCarState(*f->car, 0, pusherOf); n.input = f->input; n.input.events = 0; n.upcoming = f->upcoming; diff --git a/src/game/net/PlayerCars.cpp b/src/game/net/PlayerCars.cpp index 8567de0..98cb2cf 100644 --- a/src/game/net/PlayerCars.cpp +++ b/src/game/net/PlayerCars.cpp @@ -208,7 +208,7 @@ std::int32_t HostInputQueue::takeLeastWaiting() { return w; } -net::OwnCarState ownCarState(const SimVehicle& car, std::uint32_t resets) { +net::OwnCarState ownCarState(const SimVehicle& car, std::uint32_t resets, const PusherOf& pusherOf) { const phys::CarSim& sim = car.sim(); const phys::InertialCS& ics = sim.body.ics; net::OwnCarState s; @@ -254,10 +254,19 @@ net::OwnCarState ownCarState(const SimVehicle& car, std::uint32_t resets) { const Vec3 zero{}; s.contact = !(s.linearImpulse == zero && s.angularImpulse == zero && s.linearPush == zero && s.turnForce == zero && s.framePush == zero); + // The bound where the collisions saw it, when the push moved the body on. + const phys::Body& body = sim.body; + Mat34 follows = ics.matrix; + follows.m3 = ics.matrix.transform(body.boundOrigin); + s.hasBound = !(body.boundMatrix.m0 == follows.m0 && body.boundMatrix.m1 == follows.m1 && + body.boundMatrix.m2 == follows.m2 && body.boundMatrix.m3 == follows.m3); + s.bound = s.hasBound ? body.boundMatrix : Mat34::identity(); + const void* pusher = body.collider.lastMaxPusher; + s.pusher = !pusher ? net::kPusherNone : pusherOf ? pusherOf(pusher) : net::kPusherOther; return s; } -void applyOwnCarState(SimVehicle& car, const net::OwnCarState& s) { +void applyOwnCarState(SimVehicle& car, const net::OwnCarState& s, const PusherKey& pusherKey) { phys::CarSim& sim = car.sim(); phys::InertialCS& ics = sim.body.ics; ics.matrix = s.matrix; @@ -303,11 +312,23 @@ void applyOwnCarState(SimVehicle& car, const net::OwnCarState& s) { sim.randomState = s.random; car.controls().swapThrottle = s.swapThrottle; car.setHeld(s.held); - // The bound follows the body; the next sweep starts where it is (the - // host's bound matrix does not travel: it differs from the body's only - // by a push during a contact). - sim.body.syncBoundMatrix(); - sim.body.collider.lastMatrix = sim.body.boundMatrix; + // The bound follows the body, or (the sample's push moved the body past + // it) stands where the collisions saw it; the next sweep starts there + // (phColliderBase::UpdateMtx's last matrix), and the hardest pusher of + // the sample is the same car's here. + if (s.hasBound) + sim.body.boundMatrix = s.bound; + else + sim.body.syncBoundMatrix(); + auto& collider = sim.body.collider; + collider.lastMatrix = sim.body.boundMatrix; + static const char kSomethingElse = 0; // a pusher that is no player's car: matches none here + collider.lastMaxPusher = s.pusher == net::kPusherNone ? nullptr + : s.pusher == net::kPusherOther || !pusherKey ? &kSomethingElse + : pusherKey(s.pusher); + collider.maxPusher = nullptr; + collider.maxPush2 = 0.0f; + collider.justReset = false; } bool ResetRules::allows(const net::CarCommand& c) const { @@ -507,7 +528,7 @@ CarPrediction::Correction CarPrediction::acknowledge(SimVehicle& car, NetCarDriv if (replay && alone) { std::vector bodies; for (const Companion& c : companions) { - applyOwnCarState(*c.car, *c.state); + applyOwnCarState(*c.car, *c.state, pusherKey); bodies.push_back(&c.car->sim().body); } // (In the world's order: the companions come in player order.) @@ -541,7 +562,7 @@ CarPrediction::Correction CarPrediction::acknowledge(SimVehicle& car, NetCarDriv // state to state (a shunt with the other car braking: 7 corrections // over the tolerance before, 2 with it). if (differs || !companions.empty()) { - applyOwnCarState(car, host); + applyOwnCarState(car, host, pusherKey); save(base, car, driver); } // The bodies in the world's order (the players' cars by number), so @@ -557,7 +578,7 @@ CarPrediction::Correction CarPrediction::acknowledge(SimVehicle& car, NetCarDriv if (!c.first) bodies.push_back(&c.car->sim().body); for (const Companion& c : companions) - applyOwnCarState(*c.car, *c.state); + applyOwnCarState(*c.car, *c.state, pusherKey); if (companionsAt && !companions.empty()) companionsAt(ack); // OpenMM2: the props the replay meets (World::collideHeld), the samples diff --git a/src/game/net/PlayerCars.h b/src/game/net/PlayerCars.h index e3e7b27..47c5fec 100644 --- a/src/game/net/PlayerCars.h +++ b/src/game/net/PlayerCars.h @@ -176,8 +176,14 @@ struct ResetRules { // The car's state as the host sends it to its player, and the car put there // (the rest of its state stays as it was). -net::OwnCarState ownCarState(const SimVehicle& car, std::uint32_t resets); -void applyOwnCarState(SimVehicle& car, const net::OwnCarState& state); +// `pusherOf` names the player whose car a collider is (its collider key; +// net::kPusherOther for anything else), `pusherKey` the collider of a +// player's car on this machine (nullptr when it has none): the hardest +// pusher of the car's last sample travels as a player's number. +using PusherOf = std::function; +using PusherKey = std::function; +net::OwnCarState ownCarState(const SimVehicle& car, std::uint32_t resets, const PusherOf& pusherOf = {}); +void applyOwnCarState(SimVehicle& car, const net::OwnCarState& state, const PusherKey& pusherKey = {}); // A player's car as the others draw it. net::VehicleSnapshot carSnapshot(const SimVehicle& car, const net::CarInputFrame& input); @@ -225,6 +231,10 @@ class CarPrediction { // minute to make up two seconds). Returns how many it skipped. std::uint32_t skipTo(std::uint32_t ack, std::uint32_t trip); + // The players' cars' colliders on this machine, for the states' pushers + // (applyOwnCarState). + PusherKey pusherKey; + // The inputs and commands the host has not acknowledged (the newest // net::kMaxInputFrames), or nothing before the first sample. std::optional message() const; diff --git a/src/net/PlayerCars.h b/src/net/PlayerCars.h index e5cac89..e999930 100644 --- a/src/net/PlayerCars.h +++ b/src/net/PlayerCars.h @@ -231,7 +231,20 @@ struct OwnCarState { std::array tireResistance{}; bool contact = false; Vec3 linearImpulse, angularImpulse, linearPush, turnForce, framePush; + // A contact's bookkeeping at the sample's end (protocol 11): the bound's + // matrix as the sample's collisions saw it, when the sample's push has + // moved the body past it (`hasBound`; otherwise it follows the body), and + // the collider that pushed hardest in the sample (phColliderBase's last + // max pusher, which CopyLastMatrix reads): a player's car by its number, + // or kPusherNone / kPusherOther. Without them a car pressed against + // another drifted from the host's (a push against a braking car: a 0.93 m + // correction). + bool hasBound = false; + Mat34 bound; + std::uint8_t pusher = 0xFF; }; +inline constexpr std::uint8_t kPusherNone = 0xFF; +inline constexpr std::uint8_t kPusherOther = 0xFE; // Ranges an OwnCarState must lie in (anything else is refused). inline constexpr float kOwnStateMaxCoordinate = 16384.0f; @@ -289,6 +302,16 @@ bool serialize(S& s, OwnCarState& o) { } else if constexpr (S::kReading) { o.linearImpulse = o.angularImpulse = o.linearPush = o.turnForce = o.framePush = {}; } + s.boolean(o.hasBound); + if (o.hasBound) { + s.vec3(o.bound.m0); + s.vec3(o.bound.m1); + s.vec3(o.bound.m2); + s.vec3(o.bound.m3); + } else if constexpr (S::kReading) { + o.bound = Mat34::identity(); + } + s.u8(o.pusher); if constexpr (S::kReading) { // Untrusted: every number finite (f32 refuses the rest) and in range. const auto ok = [](const Vec3& v, float r) { @@ -302,7 +325,8 @@ bool serialize(S& s, OwnCarState& o) { ok(o.force, kOwnStateMaxValue) && ok(o.torque, kOwnStateMaxValue) && ok(o.linearImpulse, kOwnStateMaxValue) && ok(o.angularImpulse, kOwnStateMaxValue) && ok(o.linearPush, kOwnStateMaxValue) && ok(o.turnForce, kOwnStateMaxValue) && - ok(o.framePush, kOwnStateMaxValue); + ok(o.framePush, kOwnStateMaxValue) && ok(o.bound.m0, 2.0f) && ok(o.bound.m1, 2.0f) && + ok(o.bound.m2, 2.0f) && ok(o.bound.m3, kOwnStateMaxCoordinate); for (float r : o.tireResistance) good = good && std::abs(r) <= kOwnStateMaxValue; for (const auto& w : o.wheels) diff --git a/tests/game/test_player_cars.cpp b/tests/game/test_player_cars.cpp index b2f1020..60263df 100644 --- a/tests/game/test_player_cars.cpp +++ b/tests/game/test_player_cars.cpp @@ -211,6 +211,14 @@ ShuntOutcome shunt(const vfs::Vfs& vfs, bool companion, float throttle, std::uin client.a->sim().body.resetCollider(); } CarPrediction prediction; + prediction.pusherKey = [&](std::uint8_t id) -> const void* { + return id == 0 ? client.a->sim().body.collider.key() : client.b->sim().body.collider.key(); + }; + const PusherOf pusherOf = [&](const void* key) -> std::uint8_t { + return key == host.a->sim().body.collider.key() ? 0 + : key == host.b->sim().body.collider.key() ? 1 + : net::kPusherOther; + }; HostInputQueue queue; struct Down { std::uint32_t ack = 0; @@ -235,7 +243,7 @@ ShuntOutcome shunt(const vfs::Vfs& vfs, bool companion, float throttle, std::uin : prediction.acknowledge(*client.b, client.db, client.world, d.ack, d.own); if (!companion) { // The host's car where its newest state says, held there. - applyOwnCarState(*client.a, d.near.state); + applyOwnCarState(*client.a, d.near.state, prediction.pusherKey); } else if (r.rebased) { leader = d.near.upcoming.empty() ? d.near.input : d.near.upcoming.back(); const auto from = std::min(r.replayed, std::ssize(d.near.upcoming)); @@ -284,9 +292,9 @@ ShuntOutcome shunt(const vfs::Vfs& vfs, bool companion, float throttle, std::uin if (t % 3 == 0 && queue.lastApplied() != 0) { Down d; d.ack = queue.lastApplied(); - d.own = ownCarState(*host.b, 0); + d.own = ownCarState(*host.b, 0, pusherOf); d.near.id = 0; - d.near.state = ownCarState(*host.a, 0); + d.near.state = ownCarState(*host.a, 0, pusherOf); d.near.input = leaderInput(t, throttle, brakeAt); // A client's car: the inputs the host already holds for it. for (std::uint32_t k = 1; k <= ahead; ++k) @@ -340,6 +348,17 @@ TEST(PlayerCars, AShuntIsCorrectedOnlyWhereTheOtherPlayerChangedItsInput) { EXPECT_LE(relayed.largest, run.largest + 1e-4f); } +TEST(PlayerCars, APushAgainstABrakingCarStaysPredicted) { + MM2_REQUIRE_GAME_DATA(); + // The host's car stands on its brakes and the client's pushes it at full + // throttle: the two stay pressed together (a wedge). + const ShuntOutcome run = shunt(*test::gameData(), true, 0.0f, 0); + std::printf("[ measure ] a push against a braking car: %d corrections, largest %.3f m, closest %.2f m\n", + run.corrections, static_cast(run.largest), static_cast(run.closest)); + EXPECT_LT(run.closest, 4.6f); + EXPECT_LE(run.corrections, 3); +} + TEST(PlayerCars, TheHostResetsAClientsCarOnlyWhereTheRulesDo) { ResetRules r; r.city = {{-2000.0f, -100.0f, -2000.0f}, {2000.0f, 300.0f, 2000.0f}}; diff --git a/tests/net/test_player_cars.cpp b/tests/net/test_player_cars.cpp index c4d0fda..153c5f7 100644 --- a/tests/net/test_player_cars.cpp +++ b/tests/net/test_player_cars.cpp @@ -92,6 +92,10 @@ OwnCarState sampleOwn() { o.linearPush = {0.001f, 0.0f, 0.002f}; o.turnForce = {0.0f, 0.0001f, 0.0f}; o.framePush = {0.003f, 0.0f, 0.0f}; + o.hasBound = true; + o.bound = Mat34::rotationY(0.69f); + o.bound.m3 = {-1149.9f, 112.2f, 163.1f}; + o.pusher = 3; return o; } @@ -159,6 +163,10 @@ void expectSameOwn(const OwnCarState& a, const OwnCarState& b) { EXPECT_TRUE(vec(a.linearImpulse, b.linearImpulse) && vec(a.angularImpulse, b.angularImpulse)); EXPECT_TRUE(vec(a.linearPush, b.linearPush) && vec(a.turnForce, b.turnForce) && vec(a.framePush, b.framePush)); + EXPECT_EQ(a.hasBound, b.hasBound); + EXPECT_TRUE(vec(a.bound.m0, b.bound.m0) && vec(a.bound.m1, b.bound.m1) && vec(a.bound.m2, b.bound.m2) && + vec(a.bound.m3, b.bound.m3)); + EXPECT_EQ(a.pusher, b.pusher); } // What a decoded message may hold, whatever arrived. @@ -373,12 +381,11 @@ TEST(PlayerCars, NearCarsTravelInFullWithoutTheirKeys) { EXPECT_EQ(out.near[i].upcoming, m.near[i].upcoming); // keys and all: each applied once, in turn } // The host puts in full as many as keep it in one ENet packet - // (kMaxCarStatesBytes), here two of them without their coming inputs. - CarStatesMsg two = m; - two.near.pop_back(); - for (auto& n : two.near) - n.upcoming.clear(); - EXPECT_LE(encodeMessage(two).size(), kMaxCarStatesBytes); + // (kMaxCarStatesBytes), taking them in turn: one in a contact with + // eight players. + CarStatesMsg one = m; + one.near.resize(1); + EXPECT_LE(encodeMessage(one).size(), kMaxCarStatesBytes); // More than kMaxNearCars: the first ones only. m.near.push_back(m.near[0]); m.nearIds.push_back(7); @@ -408,9 +415,13 @@ TEST(PlayerCars, StatesOutOfRangeAreRefused) { bad = sampleStates(2); bad.own.framePush.x = std::numeric_limits::quiet_NaN(); EXPECT_FALSE(decodeMessage(encodeMessage(bad), out)); + bad = sampleStates(2); + bad.own.bound.m1.y = 5.0f; // not a rotation + EXPECT_FALSE(decodeMessage(encodeMessage(bad), out)); // Out of a contact the impulses and pushes do not travel. auto calm = sampleStates(2); calm.own.contact = false; + calm.own.hasBound = false; ASSERT_TRUE(decodeMessage(encodeMessage(calm), out)); EXPECT_EQ(out.own.linearImpulse, Vec3{}); EXPECT_LT(encodeMessage(calm).size(), encodeMessage(sampleStates(2)).size()); From 8e74c96d4a042225c1bb90123faf52197bd1962c Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Fri, 9 Oct 2026 21:59:20 -0700 Subject: [PATCH 116/129] Check the race start test's go by frame order, not a time window NetGameStart.SlowLoaderIsWaitedForAndBothGoTogether wanted each machine's countdown and go within 25 ms of the shared times, which a frame under a loaded ctest run can exceed. NetRaceStart counts down and goes in the first frame at or after those times on each machine's session clock; the test now checks exactly that (the frame before was earlier, the frame itself not). Co-Authored-By: Claude Opus 5.5 (1M context) --- tests/game/test_netgame_start.cpp | 33 ++++++++++++++++++++++++------- 1 file changed, 26 insertions(+), 7 deletions(-) diff --git a/tests/game/test_netgame_start.cpp b/tests/game/test_netgame_start.cpp index a8a1a30..c94db2e 100644 --- a/tests/game/test_netgame_start.cpp +++ b/tests/game/test_netgame_start.cpp @@ -93,6 +93,8 @@ struct Machine { std::optional loadedAt; // the race screen is running from here int maxWaitingFor = 0; std::optional countdownAt, goAt; // session times + std::optional beforeCountdown, beforeGo; // the frames before them + std::optional lastFrame; bool everReleasedEarly = false; Machine(NetGame& n, game::GameMode mode) : net(n), start(NetRaceStart::kindOf(mode)) {} @@ -101,12 +103,27 @@ struct Machine { return; const auto out = start.update(dt, net); maxWaitingFor = std::max(maxWaitingFor, out.waitingFor); - if (out.countdownBegan) + if (out.countdownBegan) { countdownAt = net.frameTime(); - if (out.went) + beforeCountdown = lastFrame; + } + if (out.went) { goAt = net.frameTime(); + beforeGo = lastFrame; + } if (!out.held && !start.gone()) everReleasedEarly = true; + lastFrame = net.frameTime(); + } + // `at` is the first frame of this machine at or after `time` (within the + // float seconds NetRaceStart counts in), however late the frames come. + static void expectFirstFrameAt(const std::optional& at, const std::optional& before, + double time) { + ASSERT_TRUE(at.has_value()); + EXPECT_GE(*at, time - 1.0); + if (before) { + EXPECT_LT(*before, time + 1.0); + } } }; @@ -281,12 +298,14 @@ TEST(NetGameStart, SlowLoaderIsWaitedForAndBothGoTogether) { EXPECT_EQ(client.maxWaitingFor, 0); // the host had reported long before EXPECT_EQ(p.host.raceStartTime(), p.client.raceStartTime()); ASSERT_TRUE(host.countdownAt && client.countdownAt); - // Within a frame and the clock's error of each other, and of the start. + // Each machine counts down and goes in its first frame at or after the + // shared times, on its own session clock (a frame's length under load + // would not fit a fixed tolerance). const double start = p.host.raceStartTime(); - EXPECT_NEAR(*host.goAt, start, 25.0); - EXPECT_NEAR(*client.goAt, start, 25.0); - EXPECT_NEAR(*host.countdownAt, start - 2500.0, 25.0); - EXPECT_NEAR(*client.countdownAt, start - 2500.0, 25.0); + for (const Machine* m : {&host, &client}) { + Machine::expectFirstFrameAt(m->goAt, m->beforeGo, start); + Machine::expectFirstFrameAt(m->countdownAt, m->beforeCountdown, start - 2500.0); + } EXPECT_FALSE(host.everReleasedEarly); EXPECT_FALSE(client.everReleasedEarly); } From a179197240409120b992246c80c98ae261476048 Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Fri, 9 Oct 2026 22:10:41 -0700 Subject: [PATCH 117/129] Record the props' rough edges round Before and after on the merged builds: another player's car's knocks now fall as that car meets the prop (they fell 140-400 ms after it passed; 138 of 155 confirmed by the host), a missed prediction stands again in 0.7-1.8 s instead of 2 s, a client's pieces knock no placed prop, each client's message carries its own area first; every knock still shared, the pieces within 3 mm, 0.1-6.4 KB/s to each client. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/multiplayer.md | 12 +- docs/review/multiplayer-desync-props.md | 145 ++++++++++++++++++++---- 2 files changed, 131 insertions(+), 26 deletions(-) diff --git a/docs/multiplayer.md b/docs/multiplayer.md index 3381cd5..b3c5346 100644 --- a/docs/multiplayer.md +++ b/docs/multiplayer.md @@ -1060,10 +1060,14 @@ apart, a playout delay), no prop differed at the end, and the knocked-over props, parked cars and parts rested within 3 mm of the host's (the position's quantization is 4 mm). A client alone among props had no correction of its car, and a client's corrections over 30 cm just after a -prop hit (nothing else around) went from 31 in eight runs to 7. The host -sent 0.1-5 KB/s to each client (10 s averages; a big crash in the test, 27 -props knocked by one car, 5 KB/s for a few seconds, at most 425 bytes a -message); a message stays within 1100 bytes, the states nearest the +prop hit (nothing else around) went from 31 in eight runs to 7. Since +protocol 13 a prop another player's car (run on the client) hits falls as +that car meets it, where it fell 140-400 ms after the car came closest +(138 of 155 such knocks confirmed by the host; the others stand again +within 0.7-2 s), and a missed prediction stands again in 0.7-1.8 s, not 2 s. +The host sent 0.1-6.4 KB/s to each client (10 s averages; a big crash in the +test, 27 props knocked by one car, 5 KB/s for a few seconds, at most 425 +bytes a message); a message stays within 1100 bytes, the states nearest the client first. **Deviations.** MM2 lets each machine knock its own props; OpenMM2 shows the diff --git a/docs/review/multiplayer-desync-props.md b/docs/review/multiplayer-desync-props.md index a8100f8..19c16b3 100644 --- a/docs/review/multiplayer-desync-props.md +++ b/docs/review/multiplayer-desync-props.md @@ -2,7 +2,10 @@ Worked on 2026-10-09 from `integration` at cd5ae29 (release 0.3.1), merged with integration at 3f73059 (the host simulates every player's car), 52aa601 -(the shared traffic) and cae6989 (the players' cars' final work), on the +(the shared traffic) and cae6989 (the players' cars' final work); and in the +rough edges round (protocol 13, "Rough edges round" below) at 782f469 (the +players' cars' second round, the host's rules) and b33bebc (the +cross-platform determinism work), on the maintainer's report that in a multiplayer cruise over the Internet "knocked-over props, parked cars, objects or car damage look different on each screen", and on the maintainer's decision that the host is the @@ -50,6 +53,12 @@ corrections just after a prop hit with nothing else around went from 31 over | host and two clients, 150 ±20 ms, 5 % loss, after | 0 of 15 (each pair) | 15 | 245-249 ms median (clients 492) | 3 mm max (14), clients 9 mm | | one client alone through props, 80 ±20 ms, after | 0 of 7 | 7 | 173 ms | 3 mm max (4) | +In the rough edges round (protocol 13, below) a client also predicts the +knocks of the other players' cars it runs (before, such a car drove through +a prop that fell 140-400 ms later), undoes a missed prediction in 0.7-1.8 s +instead of 2 s, no longer lets its own pieces knock props, and gets its own +message with the states near its car first; the ring grows in a pile-up. + Props down on one machine and standing on the other, at any 250 ms tick: 6-16 at the end of the 0.3.1 runs (in 72-93 % of the ticks), 2-6 at the end of the integration runs (72-93 %); after, at most 2 or 3 for a moment (a @@ -142,6 +151,11 @@ This is why OpenMM2 does not follow MM2 here (the maintainer's decision). | D10 | minor (found in the final runs) | `BangerSet::acceptsFrom` | A client's own simulated piece knocked a parking meter 3 s after its car hit a street light; the host's piece of the same knock had gone elsewhere, and the meter stood up again 2 s later (an undone prediction). | client alone, one undone of 4 | f1f3b56: a piece this machine simulates knocks placed props for its first 0.5 s only | | D11 | visible (found in the final runs) | `BangerSet::replayGhost` (784b3b1) | A prop the client's car broke loose stood again for every replay in the next second, so a replay that started after the knock (the host's correction a few samples later) knocked the car a second time. | client alone: corrections of 1.3-1.6 m and up to 8.6 m/s 250-550 ms after a hit; 31 corrections over 30 cm after prop hits alone in eight clients' runs | 59ca116: it stands for the samples up to the one that broke it loose (`World::replayTime`) | | D12 | visible (found in the final runs) | `World::collideHeld` (0791996) | The client puts the bodies around its car back where each sample started them (c367288), but a real sample moves a simulated body on before the collisions: the replayed car met the pieces a sample behind and sank into them; and a light piece gave way after one hit, though the real car pushed it all along. | test: a replay from 1-8 samples after a knock ended 0.1-0.2 m from the real samples | 59ca116: the held copy moves on over the sample; no giving way (the replay ends within 15 mm and 0.04 m/s) | +| D13 | visible (rough edges round) | `RaceScreen` props toucher (`ownCarBody`), with c5c1b4c | A client runs the other players' cars near its own (`NearCarState`) ahead to its own time, but only its own car could knock its props: such a car drove through a prop that fell when the host's knock came. | before: props fell 143-404 ms after the car (as drawn there) came closest, the car 2.2-8.5 m from the prop's place (median per run) | 993c285: a client predicts the knocks of the cars it simulates | +| D14 | visible (rough edges round) | `PropClient::update` | A prediction the host never confirmed stood until 2 s had passed. | every undone prediction, 2.0 s | 993c285: undone once the host's messages have passed it by the host's lag (another player's car's: once the host also had that car away from the prop and this machine's car is not about to hit it) | +| D15 | visible (rough edges round) | `BangerSet::acceptsFrom` (f1f3b56) | A client's own pieces still knocked placed props in their first 0.5 s: 4 of 9 such knocks were confirmed, and one left the client's car tangled in pieces the host never had (157 corrections over 30 cm in a minute, the prop knocked and undone three times). | follow 150 ms | 993c285: a client's pieces knock no placed prop (the host's knocks bring its chain knocks) | +| D16 | minor (rough edges round) | `PropHost::build`, `BangerSet::getBanger` | Every client got the same message, the whole ring whatever was near it; and the ring of 40 wraps in a pile-up, so props knocked moments ago disappeared in mid-flight. | by reading; tests | 993c285: each client its own message, the states near its car first within a datagram; the ring grows instead of wrapping onto a busy prop | +| D17 | minor (rough edges round) | `PropClient::update` (mirror hold) | A host's piece the client's car pushed was held where it stopped for 2 s even when the host's car never touched it, before going back to the host's. | parked cars: a piece 1.5-2 m from the host's for up to 2 s | 993c285: held as long as the host's push would take to show (its lag and the playout delay) | Damaging impacts on each machine's own car by cause, 0.3.1 runs: the city 47, props 3, traffic 2; no player-versus-player impact. After, each car's @@ -238,9 +252,10 @@ in a crash that keeps all 40 slots flying. ## Tests * `tests/net/test_prop_state.cpp`: the messages' round trips, sizes (a full - ring in one datagram, 250 live and 500 catch-up knocks in one event) and - malformed input; `test_fuzz.cpp`: both in the corpus and the decoders' - checks, and sent mutated to a client. + ring in one datagram, a grown ring of 256 listed in 6 bits a slot, 250 live + and 500 catch-up knocks in one event) and malformed input (slots out of + order among them); `test_fuzz.cpp`: both in the corpus and the decoders' + checks (ascending slots), and sent mutated to a client. * `tests/game/test_prop_sync.cpp`: a host and a client in one process through a lossy link: the host's knocks and pieces on the client, a client's knock predicted and handed over, one undone, other cars passing through a @@ -248,7 +263,13 @@ in a crash that keeps all 40 slots flying. car meeting a prop as a real sample does (from before the knock, and from 1, 4 and 8 samples after it, the bodies put back for each sample as the race screen does), a replay knocking and moving no prop for real, 30 % - loss with reordering, a big crash, the catch-up, thrown car parts, knocks + loss with reordering, a big crash, a pile-up growing the ring, two clients + far apart each getting its own area's states first within a small + datagram, a pile-up beyond a datagram taking turns, a client's piece + knocking no placed prop, a near car's knock predicted, one undone once the + host had that car elsewhere, one held while this machine's car comes and + one the host's braking car makes late, a missed own knock undone once the + host's messages passed it, the catch-up, thrown car parts, knocks counted from the host only, refused input and a different placement, and on retail data the same placement on every machine (cruise at the host's and a client's traffic density, a checkpoint race with parked cars) for @@ -257,11 +278,86 @@ in a crash that keeps all 40 slots flying. the same with `-D_GLIBCXX_ASSERTIONS`; the opponent sweep 516/517 finished, 508/517 across the line (as before); single-player smoke screenshots (a San Francisco cruise, a London circuit) as before. +* Rough edges round, merged with integration at b33bebc: 1027 of 1027 (3 + skipped; integration's 1017 and 10 new), the determinism hashes + unchanged; the same with `-D_GLIBCXX_ASSERTIONS`; the sweep 515/517 finished, 507/517 across the line (the new + baseline); the smoke screenshots as before; a three-machine and a slalom + run on the merged build as above (every knock shared, pieces within 3 mm + of the host's, 31 predictions with one undone). + +## Rough edges round (protocol 13) + +The maintainer accepted more bandwidth and asked for the remaining rough +edges. Measured as above on the merged build before (integration 782f469, +protocol 10) and after (993c285), one run each: + +| Run | Knocks shared; on one machine only at the end | Resting apart, host and client | Another player's car's knocks: that car from the prop's place when it fell, and when it fell against when that car (as drawn there) came closest (medians) | Predicted (undone) | +| --- | --- | --- | --- | --- | +| slalom, 80 ms, before | 25; 0 | 3 mm | 12: 3.5 m, 222 ms after | 11 (1, at 2 s) | +| slalom, 80 ms, after | 6; 0 | 3 mm | (none this run) | 6 (1, at 2 s: this machine's car was close) | +| slalom, 150 ms, before | 7; 0 | 2 mm | 1: 2.2 m, 143 ms after | 4 (0) | +| slalom, 150 ms, after | 8; 0 | 2 mm | 1: 3.2 m, 192 ms before | 6 (0) | +| follow, 80 ms, before | 12; 0 | 2 mm | 8: 3.1 m, 84 ms before | 0 | +| follow, 80 ms, after | 17; 0 | 2 mm | 6: 2.3 m, 152 ms before | 9 (0) | +| follow, 150 ms, before | 10; 0 | 3 mm | 6: 4.6 m, 369 ms after | 1 (0) | +| follow, 150 ms, after | 10; 0 | 2 mm | 3: 2.6 m, 146 ms before | 6 (0) | +| parked cars, before | 5; 0 | 27 mm | 3: 3.9 m, 171 ms after | 2 (0) | +| parked cars, after | 5; 0 | 2 mm | 3: 3.4 m (pushed slowly) | 4 (0) | +| three machines, before | 28; 1-3 (knocked in the last second, after a client's trace ended) | 3 mm (one piece still falling: 0.9 m) | 11: 2.7 m, 25 ms before; 6: 8.5 m, 404 ms after | 14 (0) | +| three machines, after | 23; 0 | 2 mm | 8: 2.7 m, 169 ms before; 12: 2.3 m, 138 ms before | 15 (4, after 1.5-1.8 s) | +| one client alone, before | 12; 0 | 2 mm | | 7 (0); no correction | +| one client alone, after | 5; 0 | 2 mm | | 4 (0); no correction | + +A car's centre is about 2.2 m behind its front: "2.2-2.7 m" is a prop +falling as the car's front meets it, and a prop falling 130-190 ms before +the car's centre came closest is one falling at the contact. + +* **Another player's car's knocks** (D13) are now predicted by the client + that runs that car (the near ones, `NearCarState`), as its own car's are, + and replayed with it (`World::collideHeld`, `replayGhost`). Over the seven + batches of the round with it the host confirmed 138 of 155 such knocks; + the 17 others were knocks the client's run-ahead of that car made and the + real car did not (the prop stands again). Better inputs for the near cars + (the players' cars' work) will lower that; the client's own car's + predictions: 279 of 281 confirmed. +* **Undoing** (D14): a missed prediction stands again as soon as the host's + messages have passed it by the host's usual lag (the most of the last 16 + confirmed, plus 0.3 s, at least 0.6 s), in the runs 0.7-1.8 s after it + fell (2 s before); one by another player's car waits until the host also + had that car more than 6 m from the prop and this machine's car is more + than 15 m from it (the host's knock may still come from either: in two + runs it came 1.7-2.2 s later, from the client's own car, or a braking + car reached a parked car 1.9 s after this machine's run-ahead of it). +* **Chain knocks** (D15): a client's own pieces meet one another and no + placed prop; the host's knocks bring the ones its pieces make. +* **Interest** (D16): each client's message lists every occupied slot and + carries the states its area needs first (150 m from its car), within 1100 + bytes; far states less often; the knocks go to everyone, reliably, so no + client misses one in its area or anywhere else. In the runs 88-100 % of + the states sent were for the client's area while props lay there, and no + state waited for room. Tests: two clients 300 m apart with a 300-byte + budget got every flying state of their own area in every message (890 of + 890) and the far ones as room allowed (85 deferred), and both ended with + every piece where the host's rests; 24 props flying around one client with + a 200-byte budget took turns, none waiting more than 6 messages. +* **The ring** grows by 40 slots, up to 40 a player (at least 80, at most + 256), when it is about to wrap onto a prop that still moves or was knocked + less than 10 s ago (test: a car through 60 props in 9 s: 80 slots, every + knocked prop still there; single player: 40, wrapped). The measured runs + never needed it (at most 29 knocks a minute). +* **Mirrors** (D17): a host's piece a client's car pushed goes back to the + host's as soon as the host's push would have shown. + +Bandwidth after: 64 bytes/s to 6.4 KB/s to each client (10 s averages), at +most 5.5 KB/s before; a message at most 1100 bytes. ## For the players' cars agent -* A client's props accept contacts only from its own car and trailer - (`ownCarBody`) and its own predicted pieces (`BangerSet::setReplica`). +* A client's props accept contacts from its own car and trailer + (`ownCarBody`), the other players' cars it simulates (`propMover`, + `predictedBody`'s cars) and its own pieces among themselves + (`BangerSet::setReplica`). The companions' replays meet props as the own + car's do (`replayGhost` for the knocks a companion made here). * `World::beginReplay(from)` (called by `CarPrediction::acknowledge`) starts a replay; `from` is the world time the first sample run again first ran at (the samples ran one after another up to the world's time now), and @@ -270,24 +366,29 @@ in a crash that keeps all 40 slots flying. * `World::collideHeld` moves a simulated body's copy on by its velocity over the sample before the test (the race screen puts it back where the sample started it); the light bodies' giving way is gone. -* A body the history did not hold for a sample (one that came within 40 m, - or into the world, later, such as a piece of a knock the host's message - brought since) stands where it is now during that sample's replay. Leaving - it out of that sample would be closer to the real samples. -* In the checkpoint race the client's corrections ran at every +* `hostCarState` reads `m_remoteCars` (the host's state of a player's car as + the client shows it) to tell whether the host's car went away from a prop + its run-ahead knocked. +* In the checkpoint race the client's corrections still run at every acknowledgement for the first 3-4 s after the start (0.56 m each, the sign alternating, 58-59 samples run again), with the props kept local as well: the race's start, not the props. ## Open -* The client's prediction covers its own car only; a prop another player's - car hits is shown when the host's knock comes, as that car is shown, a - playout delay in the past. -* Props are not interest-managed: every client gets every knock and the - whole ring; the ring's 40 slots bound it. -* A client's predicted knock is undone when the host's simulation of its car - went elsewhere (4 of 65 in the final runs, each after a traffic car met - the host's copy of the car and not the client's). -* The new files want listing in the parity manifest: `game/net/PropSync`, - `game/net/NetProps`, `net/PropState.h`, `tools/netprobe/PropDiff`. +* A knock by another player's car that this client runs ahead is wrong one + time in nine (the real car passed the prop); it stands again within + 0.7-2 s. Re-measure once the near cars' inputs improve; a further step + would re-run the knock from each newer host state and undo it as soon as + that run misses the prop. +* The shared traffic's knocks are shown when the host's come, while a + traffic car is shown at the client's time: a prop it hits falls up to a + few hundred milliseconds after it passes. Once the knocked traffic cars + are simulated on the client (the traffic's work), their knocks can be + predicted like the near cars' (`propMover` needs to know their bodies). +* A client's car leaning on a slowly creeping piece that the host's copy of + the car does not touch (once, at the end of a three-machine run): the + piece is pushed here, held, handed back, pushed again, and the car's + corrections run at every acknowledgement until it drives away. +* Props are interest-managed by distance from the client's car only, not by + what its camera sees. From ba818ca66d5910ad4b3f6b450d15186304be01fa Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Fri, 9 Oct 2026 22:21:49 -0700 Subject: [PATCH 118/129] Describe the contact bookkeeping and the skip in the players' cars' section Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/multiplayer.md | 27 ++++++++++++++++++--------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/docs/multiplayer.md b/docs/multiplayer.md index a1c50cb..d0a8ac8 100644 --- a/docs/multiplayer.md +++ b/docs/multiplayer.md @@ -642,15 +642,18 @@ every player's car on the host: own machine simulate it alike whatever else each simulates (deviation: MM2 has one `rand()` for the game). * **States.** After every frame that ran a sample (up to 60 times a - second; 20 before protocol 11) the host sends each client its + second; 20 before protocol 13) the host sends each client its `CarStates`: the number of the last input applied to its car, the car's state after it at full precision (the body's matrix, momenta, velocities and last push; the wheels' spin, turn, springs and tyre deflections; the engine, gearbox, drivetrains, stuck watcher, damage and random stream; the pedal swap and the hold; and what a sample hands the next: the force and torque the wheels and engine set for it, each tyre's rolling resistance, - and in a contact the impulses and pushes: about 310 bytes, 370 in a - contact), every other player's car as a `VehicleSnapshot`, the near set + and in a contact the impulses and pushes, the bound's matrix as the + collisions saw it when the push moved the body past it, and the player + whose car pushed hardest (`phColliderBase`'s last max pusher, which + `CopyLastMatrix` reads): about 315 bytes, 430 in a contact), every other + player's car as a `VehicleSnapshot`, the near set (the three nearest other players' cars within 60 m of the client's, kept to 70 m; not one towing a trailer) and, of those, as many in full as keep the message in one ENet packet (1372 bytes: ENet would send a longer @@ -669,12 +672,18 @@ every player's car on the host: players' cars placed at their states, the police, knocked traffic cars and props) where each sample first met it, and one that sample did not meet out of the way; the shared traffic's cars on their rails where each - sample met them; no sound or effect), at most 120 samples. The drawing keeps the car where it was and eases it - onto the corrected place with a 60 ms half-life (`game::CorrectionBlend`; - more than 4 m is a jump, drawn at once). The client's simulation runs up - to 3% faster when the host had fewer than one of its inputs in hand over - the last second and 2% slower above three, which keeps its inputs a - sample or three ahead of the host's need. + sample met them; no sound or effect), at most 120 samples. The drawing + keeps the car where it was and eases it onto the corrected place with a + 60 ms half-life (`game::CorrectionBlend`; more than 4 m is a jump, drawn + at once). The client's simulation runs up to 3% faster when the host had + fewer than one of its inputs in hand over the last second and 2% slower + above three, which keeps its inputs a sample or three ahead of the host's + need. A state acknowledging a sample the client has not run (it stalled, + or its inputs were lost for longer than the host repeats them, so the host + coasted the car on) makes it count the samples up to it, the round trip + and the host's margin as run (`CarPrediction::skipTo`): its next inputs + reach the host in time again instead of being dropped as late until the + faster pace makes the gap up. * **The other cars near** a client's car (the ones its `CarStates` carries in full) are simulated there along with its own: at every state the client puts them to the host's state at the acknowledged sample and runs From dcca773f13210e57cd4dff2c399eae03184ce924 Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Fri, 9 Oct 2026 22:23:01 -0700 Subject: [PATCH 119/129] Predict the knocks of the police and traffic a client simulates The shared police and knocked traffic cars a client now simulates with its car (TrafficFull, or knocked loose by it) may knock its props as the near players' cars do: a prop one of them hits falls as it meets it, the host's knock confirming it (TrafficBodies::carOfBody tells a traffic car's body). Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/bangers.md | 5 +++-- docs/multiplayer.md | 8 +++++--- docs/parity/openmm2-only.md | 2 +- src/app/RaceScreen.cpp | 15 +++++++++++++-- src/game/TrafficBodies.cpp | 9 +++++++++ src/game/TrafficBodies.h | 2 ++ 6 files changed, 33 insertions(+), 8 deletions(-) diff --git a/docs/bangers.md b/docs/bangers.md index b2d114f..b79a0fe 100644 --- a/docs/bangers.md +++ b/docs/bangers.md @@ -201,8 +201,9 @@ needs, all of it inert in a single-player race: ring of 40 makes such props disappear in a pile-up). * `takeKnocks` (with `recordKnocks(true)`): the placed props that broke loose (`dgUnhitBangerInstance::Impact`) and what hit them. -* A client (`setReplica`): only its own car and the other players' cars it - simulates may touch its props, and the props it simulates one another but +* A client (`setReplica`): only its own car and the cars it simulates with + it (the other players' near ones, the shared police and knocked traffic + cars) may touch its props, and the props it simulates one another but no placed prop (`phys::Instance::acceptsContact`); `breakPlaced` takes a prop out of its room as Impact does but without a body; `restoreStanding` undoes a knock its car predicted that the host did not make (Reset for one diff --git a/docs/multiplayer.md b/docs/multiplayer.md index 6bd18bf..0a36af2 100644 --- a/docs/multiplayer.md +++ b/docs/multiplayer.md @@ -1059,9 +1059,11 @@ them, and dent them, through single player's code. in the past (what the messages needed to arrive over the last 3 s plus a send interval, 50-500 ms, as the other players' cars): a knock when it shows that time, the ring in mirror slots interpolated on their velocities - (`net::SnapshotBuffer`). Only its own car and the other players' cars it - simulates along with it (the near ones the host sends in full, - `NearCarState`) may touch its props (`phys::Instance::acceptsContact`), and + (`net::SnapshotBuffer`). Only its own car and the cars it simulates along + with it (the other players' near ones the host sends in full, + `NearCarState`; the shared police and knocked traffic cars it sends in + full, `TrafficFull`, or its car knocked loose) may touch its props + (`phys::Instance::acceptsContact`), and the pieces it simulates itself one another but no standing prop (the host's knocks bring those its pieces knock): the other cars pass through them, since the host decides what they do. Those cars hit a prop at once, diff --git a/docs/parity/openmm2-only.md b/docs/parity/openmm2-only.md index ace6fe4..3b7e615 100644 --- a/docs/parity/openmm2-only.md +++ b/docs/parity/openmm2-only.md @@ -167,7 +167,7 @@ in the summary above. | OpenMM2 | MM2 | Verdict | Notes | | --- | --- | --- | --- | | `game::PropHost`, `PropClient`, `NetProps`, `net::PropStateMsg`, `PropKnocksEvent` | (none) | openmm2 | The host simulates the props for everyone and sends the knocks (reliable, all of them to a machine that has loaded the race) and its ring of knocked-over props (`dgBangerManager`'s 40 slots); a client shows them a playout delay in the past, predicts its own car's knocks and hands them over (docs/multiplayer.md, "Props"). Since protocol 13 each client gets its own message: every occupied slot, the states near its car (150 m) first within 1100 bytes, the others less often; a client also predicts the knocks of the other players' cars it simulates (`NearCarState`), and undoes a missed one as soon as the host's messages (and, for another player's car, the host's state of it) show it missed. | -| `BangerSet::setReplica`, `phys::Instance::acceptsContact` | `dgPhysManager::CollideInstances` | deviation | A client's props take contacts from its own car and the other players' cars it simulates along with it (`NearCarState`), and its own simulated props one another (no placed prop: the host's knocks bring the chain knocks); MM2's take them from every mover. | +| `BangerSet::setReplica`, `phys::Instance::acceptsContact` | `dgPhysManager::CollideInstances` | deviation | A client's props take contacts from its own car and the cars it simulates along with it (the other players' near ones, `NearCarState`; the shared police and knocked traffic cars, `TrafficFull`), and its own simulated props one another (no placed prop: the host's knocks bring the chain knocks); MM2's take them from every mover. | | `BangerSet::setRingGrowth` (network races) | `dgBangerManager::GetBanger` (the ring of 40, `dgBangerManager::Init(40)`) | deviation | When the ring is about to wrap onto a prop that still moves or was handed out less than 10 s ago, it grows by 40 slots instead, up to 40 a player (at least 80, at most 256); MM2 wraps and the oldest knocked-over prop disappears, in a pile-up while still flying. Off in single player. Test: `PropSync.APileUpGrowsTheRing`. | | `BangerSet::breakPlaced`, `restoreStanding`, `showMirror`, `hideMirror` | `dgUnhitBangerInstance::Impact`, `Reset`, `dgHitBangerInstance` | openmm2 | A client's placed prop leaves its room as Impact makes it leave (without a body), stands again as Reset makes it stand (a prediction the host did not confirm), and the host's pieces are hit instances of their own. | | `BangerSet::attachEntity` on a mirror | `dgBangerActive::Attach` | deviation | MM2 starts an active at rest (a placed or resting prop is); a client's mirror of a host piece in flight starts from the host's motion. | diff --git a/src/app/RaceScreen.cpp b/src/app/RaceScreen.cpp index 42cc552..b2a92da 100644 --- a/src/app/RaceScreen.cpp +++ b/src/app/RaceScreen.cpp @@ -6251,14 +6251,25 @@ class RaceScreen final : public Screen { } // A client: what may set its props moving (the host decides what the - // others do): its own car, and the other players' cars it simulates - // along with it (predictNearCars), whose knocks it predicts as its own. + // others do): its own car, and the cars it simulates along with it, + // whose knocks it predicts as its own: the other players' near it + // (predictNearCars), and the shared police and knocked traffic cars the + // host sends it in full or its car knocked loose (predictNetTraffic). bool propMover(const phys::Instance* other) const { + if (!other) + return false; if (ownCarBody(other)) return true; for (const auto& [id, rv] : m_remotes) if (rv.predicted && rv.sim && other == &rv.sim->sim().body) return true; + for (const auto& [id, c] : m_netCops) + if (c.predicted && c.sim && other == &c.sim->sim().body) + return true; + if (m_netTrafficCars && m_trafficBodies) { + const int car = m_trafficBodies->carOfBody(other); + return car >= 0 && m_netTrafficCars->simulated(car); + } return false; } diff --git a/src/game/TrafficBodies.cpp b/src/game/TrafficBodies.cpp index 5f5772a..5f10b20 100644 --- a/src/game/TrafficBodies.cpp +++ b/src/game/TrafficBodies.cpp @@ -852,6 +852,15 @@ phys::Body* TrafficBodies::body(int carId) { return r && r->active ? &r->active->body : nullptr; } +int TrafficBodies::carOfBody(const phys::Instance* b) const { + for (int n = 0; n < m_count; ++n) { + const Active* a = m_order[static_cast(n)]; + if (a && &a->body == b && a->rail) + return a->rail->id; + } + return -1; +} + 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 4682c8b..796a83d 100644 --- a/src/game/TrafficBodies.h +++ b/src/game/TrafficBodies.h @@ -140,6 +140,8 @@ class TrafficBodies final : public InstanceSource { bool setBodyState(int carId, const net::TrafficBodyState& state); // The body of a car that has one (null without). phys::Body* body(int carId); + // OpenMM2: the car whose body `b` is (-1: none of them). + int carOfBody(const phys::Instance* b) const; // Cars with a body (at most 32). std::size_t activeCount() const { return static_cast(m_count); } From ea3ec52a9de6aee93c586cd502526107ef7a573c Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Fri, 9 Oct 2026 22:26:53 -0700 Subject: [PATCH 120/129] Wrap a long declaration Co-Authored-By: Claude Opus 5.5 (1M context) --- src/game/net/PlayerCars.h | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/game/net/PlayerCars.h b/src/game/net/PlayerCars.h index c8d782f..af42f0e 100644 --- a/src/game/net/PlayerCars.h +++ b/src/game/net/PlayerCars.h @@ -200,8 +200,8 @@ struct ResetRules { // player's number. using PusherOf = std::function; using PusherKey = std::function; -net::OwnCarState ownCarState(const SimVehicle& car, std::uint32_t resets, const NetCarDriver* driver = nullptr, - const PusherOf& pusherOf = {}); +net::OwnCarState ownCarState(const SimVehicle& car, std::uint32_t resets, + const NetCarDriver* driver = nullptr, const PusherOf& pusherOf = {}); void applyOwnCarState(SimVehicle& car, const net::OwnCarState& state, const PusherKey& pusherKey = {}); // A player's car as the others draw it. From cd9a4096a648331262585f01bf2c1ac0487f43b2 Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Fri, 9 Oct 2026 22:35:42 -0700 Subject: [PATCH 121/129] Let a near car's knock wait while the host has that car by the prop A client ran the host's braking car into a parked car 1.9 s before the real one reached it; the prediction was undone at 2 s and the host's knock came a moment later, so the car stood up and fell again. While the host still has the knocking car within 6 m of the prop, its prediction now waits up to 4 s. Protocol 16 in the docs (13 on this branch). Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/multiplayer.md | 3 ++- docs/parity/openmm2-only.md | 2 +- src/game/net/PropSync.cpp | 15 ++++++++++----- src/game/net/PropSync.h | 1 + tests/game/test_prop_sync.cpp | 8 +++++--- 5 files changed, 19 insertions(+), 10 deletions(-) diff --git a/docs/multiplayer.md b/docs/multiplayer.md index e99fce8..b528ecd 100644 --- a/docs/multiplayer.md +++ b/docs/multiplayer.md @@ -1098,7 +1098,8 @@ them, and dent them, through single player's code. may reach a prop seconds before the real one) once the host's states had that car more than 6 m from the prop by then as well, and its own car is more than 15 m from it (about to knock it itself, which the host's knock - would then confirm); any after 2 s. A mirror its cars touch is simulated + would then confirm); any after 2 s (4 s for another player's car the + host still had by the prop). A mirror its cars touch is simulated here from the host's motion (so a flying cone hits its car as on the host) and stays where it stopped until the host's has moved and rests again, or as long as the host's push would take to show here (the lag above and diff --git a/docs/parity/openmm2-only.md b/docs/parity/openmm2-only.md index 3b7e615..760a7a1 100644 --- a/docs/parity/openmm2-only.md +++ b/docs/parity/openmm2-only.md @@ -166,7 +166,7 @@ in the summary above. | OpenMM2 | MM2 | Verdict | Notes | | --- | --- | --- | --- | -| `game::PropHost`, `PropClient`, `NetProps`, `net::PropStateMsg`, `PropKnocksEvent` | (none) | openmm2 | The host simulates the props for everyone and sends the knocks (reliable, all of them to a machine that has loaded the race) and its ring of knocked-over props (`dgBangerManager`'s 40 slots); a client shows them a playout delay in the past, predicts its own car's knocks and hands them over (docs/multiplayer.md, "Props"). Since protocol 13 each client gets its own message: every occupied slot, the states near its car (150 m) first within 1100 bytes, the others less often; a client also predicts the knocks of the other players' cars it simulates (`NearCarState`), and undoes a missed one as soon as the host's messages (and, for another player's car, the host's state of it) show it missed. | +| `game::PropHost`, `PropClient`, `NetProps`, `net::PropStateMsg`, `PropKnocksEvent` | (none) | openmm2 | The host simulates the props for everyone and sends the knocks (reliable, all of them to a machine that has loaded the race) and its ring of knocked-over props (`dgBangerManager`'s 40 slots); a client shows them a playout delay in the past, predicts its own car's knocks and hands them over (docs/multiplayer.md, "Props"). Since protocol 16 each client gets its own message: every occupied slot, the states near its car (150 m) first within 1100 bytes, the others less often; a client also predicts the knocks of the other players' cars it simulates (`NearCarState`), and undoes a missed one as soon as the host's messages (and, for another player's car, the host's state of it) show it missed. | | `BangerSet::setReplica`, `phys::Instance::acceptsContact` | `dgPhysManager::CollideInstances` | deviation | A client's props take contacts from its own car and the cars it simulates along with it (the other players' near ones, `NearCarState`; the shared police and knocked traffic cars, `TrafficFull`), and its own simulated props one another (no placed prop: the host's knocks bring the chain knocks); MM2's take them from every mover. | | `BangerSet::setRingGrowth` (network races) | `dgBangerManager::GetBanger` (the ring of 40, `dgBangerManager::Init(40)`) | deviation | When the ring is about to wrap onto a prop that still moves or was handed out less than 10 s ago, it grows by 40 slots instead, up to 40 a player (at least 80, at most 256); MM2 wraps and the oldest knocked-over prop disappears, in a pile-up while still flying. Off in single player. Test: `PropSync.APileUpGrowsTheRing`. | | `BangerSet::breakPlaced`, `restoreStanding`, `showMirror`, `hideMirror` | `dgUnhitBangerInstance::Impact`, `Reset`, `dgHitBangerInstance` | openmm2 | A client's placed prop leaves its room as Impact makes it leave (without a body), stands again as Reset makes it stand (a prediction the host did not confirm), and the host's pieces are hit instances of their own. | diff --git a/src/game/net/PropSync.cpp b/src/game/net/PropSync.cpp index 273366c..8a3a422 100644 --- a/src/game/net/PropSync.cpp +++ b/src/game/net/PropSync.cpp @@ -615,21 +615,26 @@ void PropClient::update(BangerSet& set, double now, const CarPartResolver& carPa // host's lag behind this machine (and neither the knock nor its // pieces came): the host's car went elsewhere (another player's car: // the host had it away from the prop by then). + // Another player's car the host still had by the prop may knock it + // yet (it braked short of it, say): its prediction waits longer. const Prediction& p = it->second; const double after = p.at + undoAfterMs(); bool passed = !p.confirmed && m_any && static_cast(m_latest) >= after; - if (passed && !p.ownCar) { + double timeout = m_options.predictTimeoutMs; + if (!p.ownCar) { const auto car = p.car >= 0 && hostCar ? hostCar(p.car) : std::nullopt; const float left2 = m_options.leftPropM * m_options.leftPropM; const float own2 = m_options.ownNearM * m_options.ownNearM; const Vec3& prop = set.instances()[it->first].matrix.m3; - passed = car && car->first >= after && car->second.dist2(prop) > left2 && - (!ownCarAt || ownCarAt->dist2(prop) > own2); + const bool away = car && car->second.dist2(prop) > left2; + passed = passed && away && car->first >= after && (!ownCarAt || ownCarAt->dist2(prop) > own2); + if (car && !away) + timeout = m_options.nearCarTimeoutMs; } - if (!it->second.confirmed && (passed || now - it->second.at >= m_options.predictTimeoutMs)) { + if (!p.confirmed && (passed || now - p.at >= timeout)) { set.restoreStanding(it->first); ++m_stats.undone; - if (passed && now - it->second.at < m_options.predictTimeoutMs) + if (passed && now - p.at < m_options.predictTimeoutMs) ++m_stats.undoneEarly; m_applied.emplace_back(it->first, true); log::info("netprops: the host did not knock prop {} ({}): standing again", it->first, diff --git a/src/game/net/PropSync.h b/src/game/net/PropSync.h index 883412d..772577c 100644 --- a/src/game/net/PropSync.h +++ b/src/game/net/PropSync.h @@ -171,6 +171,7 @@ class PropClient { // this machine's own car is farther than ownNearM (it may be about // to knock the prop itself, and the host with it). double predictTimeoutMs = 2000.0; + double nearCarTimeoutMs = 4000.0; // ... one whose car the host still had by the prop double undoMarginMs = 300.0; double minUndoMs = 600.0; float leftPropM = 6.0f; diff --git a/tests/game/test_prop_sync.cpp b/tests/game/test_prop_sync.cpp index 2fe7fb2..c0a071e 100644 --- a/tests/game/test_prop_sync.cpp +++ b/tests/game/test_prop_sync.cpp @@ -997,7 +997,7 @@ TEST(PropSync, ANearCarsKnockStandsWhileThisCarIsComing) { // A near car this machine runs ahead reaches a prop well before the real // one (its player braked short of it): while the host has that car near the -// prop the knock stands, and the host's comes in time. +// prop the knock stands, past the 2 s, and the host's comes in time. TEST(PropSync, ANearCarsEarlyKnockWaitsForTheHost) { Race r; Car near({6, 1, 3.0f}, {0, 0, -10}); @@ -1009,8 +1009,10 @@ TEST(PropSync, ANearCarsEarlyKnockWaitsForTheHost) { r.run(0.5); r.client.world.remove(&near.body); EXPECT_FALSE(r.client.set.standing(1)); // predicted - r.run(1.0); - EXPECT_FALSE(r.client.set.standing(1)); // the host's car is still by it + for (int i = 0; i < 150; ++i) { + r.frame(); + EXPECT_FALSE(r.client.set.standing(1)) << i; // the host's car is still by it + } copy.body.ics.linearVelocity = {0, 0, -10}; // and drives on into it copy.body.ics.linearMomentum = copy.body.ics.linearVelocity * copy.body.ics.mass; r.run(1.5); From 482ac2c5cd83388c238092dfb593a360326e7131 Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Fri, 9 Oct 2026 22:53:43 -0700 Subject: [PATCH 122/129] Record the props' final runs of the rough edges round On the build merged with the traffic's and the rules' second rounds: every knock shared, the pieces within 3 mm of the host's, all 66 predictions of the last batch confirmed (own car, other players' cars, the police and knocked traffic), another player's car's knocks falling as it meets the prop; 1045 of 1045 tests with and without assertions; protocol 16. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/multiplayer.md | 2 +- docs/review/multiplayer-desync-props.md | 80 +++++++++++++------------ 2 files changed, 43 insertions(+), 39 deletions(-) diff --git a/docs/multiplayer.md b/docs/multiplayer.md index b528ecd..b6da8dd 100644 --- a/docs/multiplayer.md +++ b/docs/multiplayer.md @@ -1142,7 +1142,7 @@ correction of its car, and a client's corrections over 30 cm just after a prop hit (nothing else around) went from 31 in eight runs to 7. Since protocol 16 a prop another player's car (run on the client) hits falls as that car meets it, where it fell 140-400 ms after the car came closest -(138 of 155 such knocks confirmed by the host; the others stand again +(179 of 198 such knocks confirmed by the host; the others stand again within 0.7-2 s), and a missed prediction stands again in 0.7-1.8 s, not 2 s. The host sent 0.1-6.4 KB/s to each client (10 s averages; a big crash in the test, 27 props knocked by one car, 5 KB/s for a few seconds, at most 425 diff --git a/docs/review/multiplayer-desync-props.md b/docs/review/multiplayer-desync-props.md index 19c16b3..ff8c509 100644 --- a/docs/review/multiplayer-desync-props.md +++ b/docs/review/multiplayer-desync-props.md @@ -3,7 +3,7 @@ Worked on 2026-10-09 from `integration` at cd5ae29 (release 0.3.1), merged with integration at 3f73059 (the host simulates every player's car), 52aa601 (the shared traffic) and cae6989 (the players' cars' final work); and in the -rough edges round (protocol 13, "Rough edges round" below) at 782f469 (the +rough edges round (protocol 16, "Rough edges round" below) at 782f469 (the players' cars' second round, the host's rules) and b33bebc (the cross-platform determinism work), on the maintainer's report that in a multiplayer cruise over the Internet @@ -53,7 +53,7 @@ corrections just after a prop hit with nothing else around went from 31 over | host and two clients, 150 ±20 ms, 5 % loss, after | 0 of 15 (each pair) | 15 | 245-249 ms median (clients 492) | 3 mm max (14), clients 9 mm | | one client alone through props, 80 ±20 ms, after | 0 of 7 | 7 | 173 ms | 3 mm max (4) | -In the rough edges round (protocol 13, below) a client also predicts the +In the rough edges round (protocol 16, below) a client also predicts the knocks of the other players' cars it runs (before, such a car drove through a prop that fell 140-400 ms later), undoes a missed prediction in 0.7-1.8 s instead of 2 s, no longer lets its own pieces knock props, and gets its own @@ -151,7 +151,7 @@ This is why OpenMM2 does not follow MM2 here (the maintainer's decision). | D10 | minor (found in the final runs) | `BangerSet::acceptsFrom` | A client's own simulated piece knocked a parking meter 3 s after its car hit a street light; the host's piece of the same knock had gone elsewhere, and the meter stood up again 2 s later (an undone prediction). | client alone, one undone of 4 | f1f3b56: a piece this machine simulates knocks placed props for its first 0.5 s only | | D11 | visible (found in the final runs) | `BangerSet::replayGhost` (784b3b1) | A prop the client's car broke loose stood again for every replay in the next second, so a replay that started after the knock (the host's correction a few samples later) knocked the car a second time. | client alone: corrections of 1.3-1.6 m and up to 8.6 m/s 250-550 ms after a hit; 31 corrections over 30 cm after prop hits alone in eight clients' runs | 59ca116: it stands for the samples up to the one that broke it loose (`World::replayTime`) | | D12 | visible (found in the final runs) | `World::collideHeld` (0791996) | The client puts the bodies around its car back where each sample started them (c367288), but a real sample moves a simulated body on before the collisions: the replayed car met the pieces a sample behind and sank into them; and a light piece gave way after one hit, though the real car pushed it all along. | test: a replay from 1-8 samples after a knock ended 0.1-0.2 m from the real samples | 59ca116: the held copy moves on over the sample; no giving way (the replay ends within 15 mm and 0.04 m/s) | -| D13 | visible (rough edges round) | `RaceScreen` props toucher (`ownCarBody`), with c5c1b4c | A client runs the other players' cars near its own (`NearCarState`) ahead to its own time, but only its own car could knock its props: such a car drove through a prop that fell when the host's knock came. | before: props fell 143-404 ms after the car (as drawn there) came closest, the car 2.2-8.5 m from the prop's place (median per run) | 993c285: a client predicts the knocks of the cars it simulates | +| D13 | visible (rough edges round) | `RaceScreen` props toucher (`ownCarBody`), with c5c1b4c | A client runs the other players' cars near its own (`NearCarState`) ahead to its own time, but only its own car could knock its props: such a car drove through a prop that fell when the host's knock came. | before: props fell 143-404 ms after the car (as drawn there) came closest, the car 2.2-8.5 m from the prop's place (median per run) | 993c285: a client predicts the knocks of the cars it simulates (dcca773: the police and knocked traffic cars too; cd9a409: waiting up to 4 s while the host has that car by the prop) | | D14 | visible (rough edges round) | `PropClient::update` | A prediction the host never confirmed stood until 2 s had passed. | every undone prediction, 2.0 s | 993c285: undone once the host's messages have passed it by the host's lag (another player's car's: once the host also had that car away from the prop and this machine's car is not about to hit it) | | D15 | visible (rough edges round) | `BangerSet::acceptsFrom` (f1f3b56) | A client's own pieces still knocked placed props in their first 0.5 s: 4 of 9 such knocks were confirmed, and one left the client's car tangled in pieces the host never had (157 corrections over 30 cm in a minute, the prop knocked and undone three times). | follow 150 ms | 993c285: a client's pieces knock no placed prop (the host's knocks bring its chain knocks) | | D16 | minor (rough edges round) | `PropHost::build`, `BangerSet::getBanger` | Every client got the same message, the whole ring whatever was near it; and the ring of 40 wraps in a pile-up, so props knocked moments ago disappeared in mid-flight. | by reading; tests | 993c285: each client its own message, the states near its car first within a datagram; the ring grows instead of wrapping onto a busy prop | @@ -278,35 +278,36 @@ in a crash that keeps all 40 slots flying. the same with `-D_GLIBCXX_ASSERTIONS`; the opponent sweep 516/517 finished, 508/517 across the line (as before); single-player smoke screenshots (a San Francisco cruise, a London circuit) as before. -* Rough edges round, merged with integration at b33bebc: 1027 of 1027 (3 - skipped; integration's 1017 and 10 new), the determinism hashes - unchanged; the same with `-D_GLIBCXX_ASSERTIONS`; the sweep 515/517 finished, 507/517 across the line (the new - baseline); the smoke screenshots as before; a three-machine and a slalom - run on the merged build as above (every knock shared, pieces within 3 mm - of the host's, 31 predictions with one undone). +* Rough edges round, merged with integration at 65529a6 (the determinism + work, the traffic's and the rules' second rounds): 1045 of 1045 (3 + skipped; integration's 1035 and 10 new), the determinism hashes + unchanged; the same with `-D_GLIBCXX_ASSERTIONS`; the sweep 515/517 + finished, 507/517 across the line (the new baseline); the smoke + screenshots as before. -## Rough edges round (protocol 13) +## Rough edges round (protocol 16; 13 on its branch) The maintainer accepted more bandwidth and asked for the remaining rough edges. Measured as above on the merged build before (integration 782f469, -protocol 10) and after (993c285), one run each: +protocol 10) and after (cd9a409 merged with integration at 65529a6: the +traffic's and the rules' second rounds), one run each: | Run | Knocks shared; on one machine only at the end | Resting apart, host and client | Another player's car's knocks: that car from the prop's place when it fell, and when it fell against when that car (as drawn there) came closest (medians) | Predicted (undone) | | --- | --- | --- | --- | --- | | slalom, 80 ms, before | 25; 0 | 3 mm | 12: 3.5 m, 222 ms after | 11 (1, at 2 s) | -| slalom, 80 ms, after | 6; 0 | 3 mm | (none this run) | 6 (1, at 2 s: this machine's car was close) | +| slalom, 80 ms, after | 13; 0 | 15 mm | 1: 2.3 m, 257 ms before | 11 (0) | | slalom, 150 ms, before | 7; 0 | 2 mm | 1: 2.2 m, 143 ms after | 4 (0) | -| slalom, 150 ms, after | 8; 0 | 2 mm | 1: 3.2 m, 192 ms before | 6 (0) | +| slalom, 150 ms, after | 5; 0 | 2 mm | (none this run) | 3 (0) | | follow, 80 ms, before | 12; 0 | 2 mm | 8: 3.1 m, 84 ms before | 0 | -| follow, 80 ms, after | 17; 0 | 2 mm | 6: 2.3 m, 152 ms before | 9 (0) | +| follow, 80 ms, after | 14; 0 | 3 mm | 5: 2.3 m, 131 ms before | 7 (0) | | follow, 150 ms, before | 10; 0 | 3 mm | 6: 4.6 m, 369 ms after | 1 (0) | -| follow, 150 ms, after | 10; 0 | 2 mm | 3: 2.6 m, 146 ms before | 6 (0) | +| follow, 150 ms, after | 15; 0 | 3 mm | 6: 2.4 m, 102 ms before | 8 (0) | | parked cars, before | 5; 0 | 27 mm | 3: 3.9 m, 171 ms after | 2 (0) | -| parked cars, after | 5; 0 | 2 mm | 3: 3.4 m (pushed slowly) | 4 (0) | +| parked cars, after | 5; 0 | 3 mm | 4: 3.7 m (pushed slowly) | 4 (0) | | three machines, before | 28; 1-3 (knocked in the last second, after a client's trace ended) | 3 mm (one piece still falling: 0.9 m) | 11: 2.7 m, 25 ms before; 6: 8.5 m, 404 ms after | 14 (0) | -| three machines, after | 23; 0 | 2 mm | 8: 2.7 m, 169 ms before; 12: 2.3 m, 138 ms before | 15 (4, after 1.5-1.8 s) | +| three machines, after | 32; 0 | 3 mm | 12: 2.6 m, 116 ms before; 22: 2.8 m, 128 ms before | 33 (0) | | one client alone, before | 12; 0 | 2 mm | | 7 (0); no correction | -| one client alone, after | 5; 0 | 2 mm | | 4 (0); no correction | +| one client alone, after | 4; 0 | 3 mm | | 4 (0); no correction | A car's centre is about 2.2 m behind its front: "2.2-2.7 m" is a prop falling as the car's front meets it, and a prop falling 130-190 ms before @@ -314,20 +315,25 @@ the car's centre came closest is one falling at the contact. * **Another player's car's knocks** (D13) are now predicted by the client that runs that car (the near ones, `NearCarState`), as its own car's are, - and replayed with it (`World::collideHeld`, `replayGhost`). Over the seven - batches of the round with it the host confirmed 138 of 155 such knocks; - the 17 others were knocks the client's run-ahead of that car made and the - real car did not (the prop stands again). Better inputs for the near cars - (the players' cars' work) will lower that; the client's own car's - predictions: 279 of 281 confirmed. + and replayed with it (`World::collideHeld`, `replayGhost`); since the + traffic's second round also the shared police and knocked traffic cars it + runs (`TrafficFull`; 7 of 8 confirmed in the last two batches). Over the + nine batches of the round with it the host confirmed 179 of 198 knocks by + another player's car; the 19 others were knocks the client's run-ahead of + that car made and the real car did not (the prop stands again; none in + the last batch, all 66 predictions there confirmed). Better inputs for + the near cars (the players' cars' work) will lower that; the client's own + car's predictions: 378 of 382 confirmed. * **Undoing** (D14): a missed prediction stands again as soon as the host's messages have passed it by the host's usual lag (the most of the last 16 confirmed, plus 0.3 s, at least 0.6 s), in the runs 0.7-1.8 s after it fell (2 s before); one by another player's car waits until the host also had that car more than 6 m from the prop and this machine's car is more than 15 m from it (the host's knock may still come from either: in two - runs it came 1.7-2.2 s later, from the client's own car, or a braking - car reached a parked car 1.9 s after this machine's run-ahead of it). + runs it came 1.7-2.2 s later, from the client's own car); while the host + still has that car within 6 m of the prop it waits up to 4 s (a braking + car reached a parked car 1.9 s after this machine's run-ahead of it, and + the knock stood up and fell again at 2 s, cd9a409). * **Chain knocks** (D15): a client's own pieces meet one another and no placed prop; the host's knocks bring the ones its pieces make. * **Interest** (D16): each client's message lists every occupied slot and @@ -348,8 +354,8 @@ the car's centre came closest is one falling at the contact. * **Mirrors** (D17): a host's piece a client's car pushed goes back to the host's as soon as the host's push would have shown. -Bandwidth after: 64 bytes/s to 6.4 KB/s to each client (10 s averages), at -most 5.5 KB/s before; a message at most 1100 bytes. +Bandwidth after: 31 bytes/s to 6.4 KB/s to each client (10 s averages over +the round's runs), at most 5.5 KB/s before; a message at most 1100 bytes. ## For the players' cars agent @@ -376,16 +382,14 @@ most 5.5 KB/s before; a message at most 1100 bytes. ## Open -* A knock by another player's car that this client runs ahead is wrong one - time in nine (the real car passed the prop); it stands again within - 0.7-2 s. Re-measure once the near cars' inputs improve; a further step - would re-run the knock from each newer host state and undo it as soon as - that run misses the prop. -* The shared traffic's knocks are shown when the host's come, while a - traffic car is shown at the client's time: a prop it hits falls up to a - few hundred milliseconds after it passes. Once the knocked traffic cars - are simulated on the client (the traffic's work), their knocks can be - predicted like the near cars' (`propMover` needs to know their bodies). +* A knock by another player's car that this client runs ahead is wrong about + one time in ten (the real car passed the prop); it stands again within + 0.7-2 s (4 s while the host has that car by the prop). Re-measure once + the near cars' inputs improve; a further step would re-run the knock from + each newer host state and undo it as soon as that run misses the prop. +* The shared traffic on its rails (not simulated on the client) still + knocks props only when the host's knock comes; the police and knocked + cars the client simulates predict theirs (dcca773). * A client's car leaning on a slowly creeping piece that the host's copy of the car does not touch (once, at the end of a three-machine run): the piece is pushed here, held, handed back, pushed again, and the car's From 7944ac53c96cc372726041d92d800b9dac6f54e0 Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Sat, 10 Oct 2026 01:11:04 -0700 Subject: [PATCH 123/129] Draw another client's near car at this machine's time A client drew every near car at its player's present, a lead back on its trail: right for the host's car, whose player's present is the host's, but another client's screen runs ahead of the host's as this one's does (each predicts its car its inputs' trip and the host's margin ahead), so its car was drawn about 150 ms behind it. Another client's car is now drawn at this machine's car's time always (three players ramming, client to client: 0.02 m at the median while the clients were near each other, against 0.38-0.54); the host's car as before. The players' cars' section of docs/multiplayer.md has this round's measurements. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/multiplayer.md | 54 +++++++++++++++++++++++------------------- src/app/RaceScreen.cpp | 22 +++++++++++------ 2 files changed, 45 insertions(+), 31 deletions(-) diff --git a/docs/multiplayer.md b/docs/multiplayer.md index 4425c13..f350cb6 100644 --- a/docs/multiplayer.md +++ b/docs/multiplayer.md @@ -663,7 +663,7 @@ every player's car on the host: own machine simulate it alike whatever else each simulates (deviation: MM2 has one `rand()` for the game). * **States.** After every frame that ran a sample (up to 60 times a - second; 20 before protocol 13) the host sends each client its + second; 20 before protocol 17) the host sends each client its `CarStates`: the number of the last input applied to its car, the car's state after it at full precision (the body's matrix, momenta, velocities and last push; the wheels' spin, turn, springs and tyre deflections; the @@ -721,16 +721,19 @@ every player's car on the host: which stays where each sample had it. Every machine keeps the players' cars in the world's movers in player order (the host's first), and a replay runs them in that order: two cars collide in that order, and the - order changes the outcome. They are drawn at their player's present (where - the car stood a lead back, little predicted: what the host's newest - states put there) unless the two cars may meet within half a second (8 m - apart, or closing on the 5 m between at more than that rate), and at this - machine's car's time then (between their last two samples), sliding - between the two over a quarter of a second; what each state moves them by - is eased away (80 ms half-life), and the switch between simulating one and - placing it at its states over 150 ms. (`OPENMM2_NET_NEAR_DRAW=ahead` draws - them at this machine's car's time always, `OPENMM2_NET_STATE_HZ=` sends - fewer states, for comparison.) + order changes the outcome. Another client's car is drawn at this + machine's car's time (between its last two samples): that client's screen + runs ahead of the host's as this one's does, so that is its player's + present. The host's car is drawn at the host's present (where it stood a + lead back, little predicted: what the host's newest states put there) + unless the two cars may meet within half a second (8 m apart, or closing + on the 5 m between at more than that rate), and at this machine's car's + time then, sliding between the two over a quarter of a second; what each + state moves them by is eased away (80 ms half-life), and the switch + between simulating one and placing it at its states over 150 ms. + (`OPENMM2_NET_NEAR_DRAW=ahead` draws the host's car at this machine's + car's time always, `OPENMM2_NET_STATE_HZ=` sends fewer states, for + comparison.) * **The other cars farther away** on a client are drawn interpolated from the host's states a playout delay in the past, as before, and its own car collides with them there as kinematic bodies moving at their velocity; the @@ -741,19 +744,22 @@ every player's car on the host: Measured through `netprobe relay` (60 ± 20 ms each way, 2% loss, reordering), a host and a client ramming each other: the client's car was -corrected 3-7 times a second, by 1-3 cm at the median and at most 33 times -a run over 10 cm, never over 1 m; shunting from behind, at most 7 times -over 1 m a run, and never drawn jumping over 1 m (the first round, without -the near cars: up to 53); the host never ran short of its inputs. Every -machine draws another player's car about 150 ms from its player's screen -(the host behind it, a client behind a far car and ahead of a near one), -as 0.3.1 did; at that delay a few centimetres remain at the median. The -inputs cost a client about 0.9 KB/s of payload (3.3 KB/s on the wire), the -states about 0.4 KB a message per client, 0.7 KB with a near car (8 KB/s -of payload for two players apart, 14 KB/s near; with eight players at most -about 22 KB/s per client and 160 KB/s for the host, about 11 and 78 KB/s -apart). See `docs/review/multiplayer-desync-cars.md` for the measurements -against 0.3.1. +corrected 1-4 times a second, by 0.5-0.7 cm at the median and at most 19 +times a run over 10 cm, never over 1 m; shunting from behind, 2-19 times a +second (most where the cars pushed into knocked-over props and their +pieces), at most twice over 1 m a run, and never drawn jumping over 1 m; +the host never ran short of its inputs. The host draws a client's car +about 140-150 ms behind that client's screen (where it simulates it); a +client draws a near car within a few centimetres of its player's screen at +the median, and the host's car about 150 ms ahead of the host's screen +while the two cars meet (where the client shows the contact). The inputs +cost a client about 0.9 KB/s of payload (3.3 KB/s on the wire); the states +about 440 bytes a message without a near car and 700-870 with one, 60 a +second (26 KB/s of payload for two players apart, 42-52 KB/s near; 65 KB/s +for each of two clients near each other and the host; with eight players +at most 1372 bytes a message, 82 KB/s for each client and about 580 KB/s +for the host). See `docs/review/multiplayer-desync-cars.md` for the +measurements against 0.3.1. ### Shared traffic diff --git a/src/app/RaceScreen.cpp b/src/app/RaceScreen.cpp index 2301a50..d00b945 100644 --- a/src/app/RaceScreen.cpp +++ b/src/app/RaceScreen.cpp @@ -791,7 +791,7 @@ class RaceScreen final : public Screen { for (auto& [id, rv] : m_remotes) { if (rv.simulated || !rv.sim) continue; - updateDrawAhead(rv); + updateDrawAhead(id, rv); rv.blend.update(m_frameDt); if (const auto base = remoteDrawnBase(id, rv)) m_remoteDrawn[id] = rv.blend.apply(*base); @@ -5300,8 +5300,14 @@ class RaceScreen final : public Screen { // that leaves at more than that rate), at its player's present once they // are 12 m apart and a second from meeting, sliding between over a // quarter of a second (OPENMM2_NET_NEAR_DRAW=ahead draws it at this - // machine's car's time always, the comparison). - void updateDrawAhead(RemoteVehicle& rv) const { + // machine's car's time always, the comparison). Another client's car is + // at this machine's car's time always: that client's screen runs ahead + // of the host's as this one's does (each predicts its car its inputs' + // trip and the host's margin ahead), so that is its player's present + // (three players ramming: 0.02-0.06 m from it at the median, against + // 0.38-0.54 drawn at the host's present); the host's car's present is + // the host's. + void updateDrawAhead(std::uint8_t id, RemoteVehicle& rv) const { static const bool always = [] { const char* v = std::getenv("OPENMM2_NET_NEAR_DRAW"); return v && std::string_view(v) == "ahead"; @@ -5317,7 +5323,7 @@ class RaceScreen final : public Screen { const float closing = d > 0.01f ? -(other.linearVelocity - own.linearVelocity).dot(apart) / d : 0.0f; const float toMeet = closing > 0.01f ? std::max(0.0f, d - 5.0f) / closing : 1e9f; float& w = rv.drawAhead; - if (always || d < 8.0f || toMeet < 0.5f) + if (always || id != net::kHostPlayerId || d < 8.0f || toMeet < 0.5f) w = std::min(1.0f, w + m_frameDt * 4.0f); else if (d > 12.0f && toMeet > 1.0f) w = std::max(0.0f, w - m_frameDt * 4.0f); @@ -6438,9 +6444,11 @@ class RaceScreen final : public Screen { std::optional remoteDrawnBase(std::uint8_t id, const RemoteVehicle& rv) const { if (rv.predicted && rv.sim) { // Simulated here: at this machine's car's time while the two may - // meet (they meet where the host's do), else at its player's - // present (the car's place a lead back on its trail: what the - // host's newest states put there, little predicted), blended. + // meet (they meet where the host's do) and always for another + // client's car, else (the host's car) at its player's present + // (the car's place a lead back on its trail: what the host's + // newest states put there, little predicted), blended + // (updateDrawAhead). const Mat34 now = drawnPose(game::Drawn::RemoteCar, id, *rv.sim).body; if (rv.drawAhead >= 1.0f || !m_world || !m_netCarLead) return now; From 852eaa212cd818c46e79f0dc8b4db021f5a4a352 Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Sat, 10 Oct 2026 01:12:37 -0700 Subject: [PATCH 124/129] Record the players' cars' rough edges round The third round's section of docs/review/multiplayer-desync-cars.md: what the runs showed (the near cars' old inputs, where they were drawn, a contact's bookkeeping, a client behind the host's samples, O8's cause), what changed, every scenario before and after against 0.3.1 and integration before and after the rules' and props' second rounds merged, what drives the corrections (knocked-over props in the rear runs), the traffic (O8, fixed by the traffic's second round), another player's car's knocks for the props (10 of 57 undone on integration, 3 of 43 here), the checkpoint race's start (not reproduced), a race started in the water (the rules' O5, still open) and the bandwidth. O5 dropped, O8 and O9 fixed, C9-C11 and O10-O11 added. The parity and sync notes and two comments carry protocol 17. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/parity/openmm2-only.md | 2 +- docs/review/multiplayer-desync-cars.md | 300 ++++++++++++++++++++++++- docs/review/multiplayer-sync.md | 2 +- src/net/PlayerCars.h | 4 +- 4 files changed, 295 insertions(+), 13 deletions(-) diff --git a/docs/parity/openmm2-only.md b/docs/parity/openmm2-only.md index 760a7a1..08589af 100644 --- a/docs/parity/openmm2-only.md +++ b/docs/parity/openmm2-only.md @@ -121,7 +121,7 @@ counted in the summary above. | OpenMM2 | MM2 | Verdict | Notes | | --- | --- | --- | --- | | `game::NetCarDriver`, `HostInputQueue`, `CarPrediction`, `CorrectionBlend`, `net::PlayerInputMsg`, `CarStatesMsg` | `mmNetObject`, `mmGameMulti::SendPosition` | deviation | The host simulates every player's car from its inputs; a client predicts its own and is corrected by the host's states. | -| `CarPrediction`'s companions, `net::NearCarState` | `mmNetObject::Predict` (a network car driven on its last pedals) | deviation | A client simulates the other players' cars near its own from the host's full states and last inputs, put to the host's state at every one (MM2 pulled the car toward its packet along a path). | +| `CarPrediction`'s companions, `net::NearCarState` | `mmNetObject::Predict` (a network car driven on its last pedals) | deviation | A client simulates the other players' cars near its own from the host's full states, the inputs the host last applied and those it holds for their next samples (protocol 17), put to the host's state at every one that differs from what it ran them on (MM2 pulled the car toward its packet along a path). | | The players' cars in player order among the world's movers (`RaceScreen::orderPlayerCars`) | `dgPhysManager::DeclareMover` (the order of the declarations) | deviation | So that two players' cars collide in the same order on every machine. | | `game::ResetRules` | `mmPlayer::Reset` from `mmGameMulti::HitWaterHandler` and `DropThruCityHandler` | openmm2 | The host carries out a client's reset only where those rules would. | diff --git a/docs/review/multiplayer-desync-cars.md b/docs/review/multiplayer-desync-cars.md index d3bc554..b1b02a6 100644 --- a/docs/review/multiplayer-desync-cars.md +++ b/docs/review/multiplayer-desync-cars.md @@ -2,7 +2,10 @@ Reviewed and reworked on 2026-10-09 from 0.3.1 (cd5ae29); a second round the same day (below, "Second round") made shunting predictable, the host -check a client's resets and the replays meet the traffic where it was. +check a client's resets and the replays meet the traffic where it was; a +third (below, "Third round", protocol 17) sent the states every sample with +the inputs the host holds, and drew the near cars at their players' +present. The maintainer's report, for multiplayer cruise over the internet with everyone on 0.3.1: "another player's car is somewhere other than where they @@ -477,6 +480,273 @@ apart, as in the first round, about 11 and 78 KB/s. * A client's car pressed against a wall correcting: a car driven into a wall at full throttle for 20 s was not corrected once. +## Third round: the rough edges (protocol 17) + +The maintainer accepted more bandwidth (about 100 KB/s down for each client +and 1 MB/s up for a host with eight players) and asked for the remaining +rough edges: a near car mispredicted when its player brakes or turns (O9), +and a client pushing through a queue of traffic corrected at nearly every +state (O8). O5 (the state's size) was dropped. The wire number was 11, 13 +and 15 on this branch while the other rounds merged, 17 at the end. + +### What the runs showed + +* **A near car ran on old inputs.** The host holds a client's inputs some + 50 ms before it applies them (`HostInputQueue`'s margin), but a near car + went to the other clients with the input last applied only, and at 20 + states a second a change of input reached them up to 50 ms later than it + had to. (The host's own car has no inputs ahead: the host applies its + player's as they come.) +* **Where a near car was drawn.** A client drew a near car where it + predicted it, at its own car's time, about 150 ms ahead of the host's + screen: half a metre off at the median wherever the two cars were (0.3.1 + drew it as far behind). Bucketed by how far apart the host had the two + cars (client ← host in the rear runs, median / 99th percentile): with the + host's car drawn at its player's present (below), 15-40 m apart + 0.03-0.04 / 0.05-0.14 (0.3.1: 0.53-0.72 / 0.93-1.22); within 6 m, where + the client draws it ahead because the two meet there, 0.10-0.96 / + 2.04-2.25 against the host's screen at the same moment but 0.02-0.20 / + 0.32-0.36 against the host's screen 125-150 ms later. What remains in a + contact is the time by which the client shows it ahead, not the + prediction. +* **A contact's bookkeeping.** With the second round's full state a push + against a braking car (the two-car test) was still corrected by 0.93 m: + the next sample also reads the bound's matrix as the collisions saw it + (when the push moved the body past it, the next sweep starts there, + phColliderBase::UpdateMtx) and which collider pushed hardest + (`CopyLastMatrix` adds the last push only for the same pusher). +* **A client behind the host's samples.** In one run with the traffic, on a + loaded machine, a client stalled; the host coasted its car through the + samples it had no inputs for and then dropped every input as late (the + client's 3% faster pace would have taken a minute to make up two + seconds): the host's copy of the car stood while the client drove off, + 537 m apart at the median. +* **O8.** In the second round's jam run 99.5% of the client's corrections + had a traffic car the host had knocked off its rail within 6 m of the + client's car (the trace's `TC` lines), which the client met as a body of + infinite mass (`TrafficProxies`): its car bounced off cars the host's + pushed aside. The shared traffic's second round (protocol 12) made those + cars bodies of their mass that the client simulates from the host's full + states with its own car (`TrafficFull`, `predictNetTraffic`). + +### What changed + +1. **States every sample, the inputs ahead** (557ade8): the host sends each + client its `CarStates` after every frame that ran a sample (up to 60 a + second; `OPENMM2_NET_STATE_HZ` for fewer), the near set (the three + nearest other players' cars within 60 m, kept to 70 m) and as many of + them in full as keep the message in one ENet packet (1372 bytes), in + turn; for another client's car the inputs the host holds for its next + samples (up to 16, `NearCarState::upcoming`). The client runs a near car + on them, then the last again, and runs the samples again for its near + cars only when the host's state or inputs differ from what it ran them + on. The host's car is drawn at its player's present (where its trail + stood a lead back: what the host's newest states put there, little + predicted) unless the two cars may meet within half a second (8 m apart, + or closing on 5 m faster than that), and at the client's car's time + then, sliding between over a quarter of a second + (`OPENMM2_NET_NEAR_DRAW=ahead` keeps the old drawing). +2. **The client's car from the host's state** (83f5762) whenever the + samples run again with near cars, even within the tolerance (in a + contact what the tolerance let pass grew from state to state: the shunt + test with the other car braking, 2 corrections instead of 7). +3. **A skip** (28562cf): a state acknowledging a sample the client has not + run makes it count the samples up to it, the round trip and the host's + margin as run (`CarPrediction::skipTo`), so that its next inputs reach + the host in time (the stalled-client test: no input missed from a second + after the stall). +4. **A contact's bound and hardest pusher** (c29326f) in every car's full + state (the pusher as a player's number): the braking push of the test + corrected twice by a centimetre instead of 0.93 m. +5. **Another client's car at the client's time** (694ebfe): another + client's screen runs ahead of the host's as this one's does, so a near + client's car is drawn at this machine's car's time always; only the + host's car is drawn at its (the host's) present (three players: client ← + client 0.02 m at the median while the clients were near each other, + against 0.38-0.54 drawn at the host's present). + +### Before and after + +Two runs of each scenario on each build, interleaved, 75 s each; 0.3.1 as +in the second round. "Before" is integration and "after" this branch, each +twice: the first two runs before the rules' and props' second rounds +merged (integration 8e74c96; this branch at 15f69e6), the last two after +(integration 482ac2c; this branch at d805730). Metres, median / 99th +percentile. + +Host ← client: + +| Scenario | 0.3.1 | before | after | +| --- | --- | --- | --- | +| rear | 0.88 / 11.79, 0.99 / 4.28 | 0.50 / 2.39, 0.36 / 2.08; 0.40 / 2.11, 0.55 / 3.10 | 0.45 / 2.66, 0.47 / 2.37; 0.31 / 2.11, 1.09 / 3.64 | +| ram | 0.60 / 1.56, 0.60 / 1.45 | 0.56 / 1.37, 0.28 / 1.28; 0.56 / 1.37, 0.58 / 1.44 | 0.60 / 1.42, 0.57 / 1.37; 0.57 / 1.32, 0.56 / 1.35 | +| chase | 0.60 / 4.89, 0.26 / 2.57 | 0.50 / 2.62, 0.27 / 2.24; 0.51 / 2.42, 0.24 / 2.00 | 1.01 / 3.90, 0.22 / 2.36; 0.24 / 2.35, 0.27 / 2.27 | +| ram, 150 ms, 5% | 1.00 / 2.54, 0.97 / 2.32 | 0.92 / 2.13, 0.94 / 1.99; 0.94 / 2.05, 0.95 / 2.49 | 0.93 / 2.05, 0.91 / 1.97; 0.87 / 2.07, 0.94 / 2.19 | +| ram, 3 players (client 1) | 0.60 / 1.54, 0.60 / 1.74 | 0.54 / 1.35, 0.56 / 1.33; 0.52 / 1.26, 0.53 / 1.22 | 0.51 / 1.24, 0.48 / 1.20; 0.53 / 1.29, 0.57 / 1.39 | +| ram, shared traffic | 0.55 / 1.43, 0.56 / 1.57 | 0.49 / 1.14, 0.50 / 1.32; 0.51 / 1.35, 0.56 / 1.30 | 0.55 / 1.38, 0.55 / 1.35; 0.52 / 1.31, 0.58 / 1.38 | + +Client ← host: + +| Scenario | 0.3.1 | before | after | +| --- | --- | --- | --- | +| rear | 0.33 / 1.13, 0.49 / 1.19 | 0.27 / 1.92, 0.04 / 1.77; 0.17 / 1.73, 0.61 / 1.53 | 0.10 / 1.86, 0.07 / 2.19; 0.09 / 1.84, 0.18 / 2.01 | +| ram | 0.57 / 1.53, 0.57 / 1.50 | 0.56 / 2.54, 0.51 / 1.54; 0.54 / 1.57, 0.56 / 1.56 | 0.04 / 1.46, 0.06 / 1.40; 0.06 / 1.40, 0.04 / 1.39 | +| chase | 0.76 / 5.40, 0.53 / 1.76 | 0.56 / 1.48, 0.53 / 1.46; 0.46 / 1.32, 0.49 / 1.50 | 0.03 / 1.26, 0.01 / 1.20; 0.06 / 1.20, 0.07 / 1.30 | +| ram, 150 ms, 5% | 0.84 / 2.43, 0.76 / 2.24 | 0.92 / 2.63, 0.99 / 2.85; 0.93 / 2.82, 0.96 / 2.77 | 0.04 / 2.22, 0.12 / 2.11; 0.19 / 2.17, 0.07 / 2.10 | +| ram, 3 players (client 1) | 0.54 / 1.37, 0.30 / 1.35 | 0.54 / 1.56, 0.59 / 3.55; 0.50 / 1.48, 0.51 / 1.37 | 0.07 / 1.29, 0.11 / 1.33; 0.06 / 1.26, 0.06 / 1.45 | +| ram, shared traffic | 0.57 / 1.45, 0.59 / 1.47 | 0.50 / 1.44, 0.53 / 1.51; 0.53 / 1.44, 0.54 / 1.44 | 0.14 / 1.39, 0.14 / 1.31; 0.09 / 1.17, 0.06 / 1.44 | + +The three-player runs' second client against the host: 0.3.1 0.55 / 1.42, +0.30 / 1.31; before 0.48-0.56 / 1.49-1.63; after 0.12-0.18 / 1.19-1.30. +The clients' views of each other (client 1 ← client 2, client 2 ← client +1), with the final build (item 5) twice more: + +| Build | client 1 ← client 2 | client 2 ← client 1 | +| --- | --- | --- | +| 0.3.1 | 0.82 / 2.05, 0.81 / 2.20 | 0.90 / 2.26, 0.88 / 2.39 | +| before | 0.17 / 2.51, 0.32 / 3.78; 0.04 / 2.40, 0.09 / 2.64 | 0.15 / 2.75, 0.30 / 4.24; 0.04 / 2.66, 0.09 / 2.62 | +| after, drawn at the host's present | 0.44 / 2.06, 0.34 / 1.77; 0.44 / 2.21, 0.38 / 2.10 | 0.36 / 2.12, 0.27 / 1.92; 0.45 / 2.28, 0.54 / 2.42 | +| final, drawn at the client's time | 0.02 / 2.04, 0.24 / 2.23 | 0.02 / 2.20, 0.32 / 2.38 | + +(In the final build's second run the two clients were within 60 m of each +other 29% of the time, 78% in the first: farther, the other client's car +is drawn from its states a playout delay in the past. The final build +changes only how another client's car is drawn; everything else below is +the same simulation.) + +**O9.** A client's view of the other cars near it is now within +centimetres at the median (0.01-0.19 m against the host's car, 0.02 against +a near client's; 0.46-0.99 before outside the rear runs, 0.30-0.90 in +0.3.1), and its 99th +percentile is at or below 0.3.1's in the ram (1.39-1.46 against 1.50-1.53), +harsh (2.10-2.22 against 2.24-2.43), chase, traffic and three-player runs. +Not in the rear runs (1.84-2.19 against 1.13-1.19): there the cars are in +contact most of the run, and the client shows the contact ahead with its +own car (against the host's screen 125-150 ms later the contact's 99th +percentile is 0.32-0.36, above). Drawing the host's car at the host's +present in a contact would put it back into the client's car (O1). The +host's view of a client's car is unchanged: it draws what it simulates, +about 140-150 ms behind the client's screen, so its divergence is that +time the car's speed (the client's car's median speed in the second rear +run after the merge was 7.8 m/s, in the others 3.0-4.1). + +Corrections of a client's own car (a minute; over 10 cm; over 1 m; no +jump of a client's own car over 1 m in any run): + +| Scenario | before | after | +| --- | --- | --- | +| rear | 376, 512; 40, 23; 2, 2 / 610, 314; 22, 32; 0, 0 | 314, 308; 5, 19; 0, 0 / 1112, 225; 68, 69; 2, 0 | +| ram | 136, 141; 1, 13; 0, 0 / 177, 155; 9, 2; 0, 0 | 68, 114; 0, 1; 0, 0 / 104, 217; 3, 19; 0, 0 | +| chase | 0, 0; 0, 0; 0, 0 / 405, 191; 47, 10; 0, 0 | 424, 15; 54, 0; 0, 0 / 114, 267; 4, 9; 0, 0 | +| ram, 150 ms, 5% | 186, 204; 47, 43; 2, 4 / 228, 423; 24, 220; 1, 3 | 181, 364; 14, 149; 1, 13 / 257, 106; 75, 16; 1, 1 | +| ram, 3 players (two clients) | 146 and 154, 71 and 364; 21 and 13, 1 and 3; 0 / 141 and 98, 170 and 99; 8 and 8, 10 and 10; 0, 0 and 1 | 128 and 97, 112 and 87; 6 and 10, 10 and 9; 0 / 180 and 114, 149 and 218; 9 and 4, 7 and 15; 0 | +| ram, shared traffic | 103, 325; 4, 87; 0, 10 / 191, 171; 21, 6; 0, 0 | 436, 172; 39, 9; 0, 0 / 86, 144; 12, 9; 2, 0 | + +The corrections vary from run to run more than between the builds; what +drives them: + +* **Knocked-over props in the rear runs.** The rear runs end with the two + cars pushing each other into street furniture the host's car knocked + over (a tree, a traffic light, parking meters), and the runs that + corrected most did it there: of the corrections over 1 cm, those with a + knocked-over prop or piece within 4 m of the client's car (the props' + `h` lines) were 851 of 894 in this branch's first run after the merge + (1112 a minute), 344 of 445 in integration's (610 a minute), and in two + more runs of this branch each before and after the merge 960 of 1027 + (978 a minute) and 474 of 610 (868 a minute); the other two of them + corrected 140 and 167 times a minute. Without a prop near, the + corrections over 1 cm with the host's car within 6 m: before 216, 342, + 101, 72; after 37, 49, 50, 64 and 43, 64, 39, 136. The props' record has + the pieces a client's car leans on (multiplayer-desync-props.md, + "Open"). +* **The harsh runs' tangles.** Most of the over-1 m corrections in the + harsh runs (13 in one, 4 before) fall within three seconds in which the + two cars ride up on each other at walking pace, in runs of both builds. +* **The chase.** Before the props' second round the integration build's + chase never corrected the client (the cars never touched); this branch's + did in a run where they touched twice. Since, both correct it 114-405 + times a minute, mostly by millimetres: the client predicts its knocks + and the other car's (the props' second round). + +Running the samples again cost a client 5.5 ms a second on average over +the runs after the merge (integration 7.9), at most 37 ms in a second and +2.2 ms in a frame (integration 27 and 2.9). + +### The traffic (O8) + +Driving through the shared traffic along the busy street (as in the second +round), two runs each on the merged builds; the client knocked 5-32 traffic +cars loose in each (the host 18-49), and in three of the four runs 80-98% +of the corrections had a car the host had knocked off its rail within 6 m +(in integration's first, 11 of 294): + +| Build | a minute | median | over 10 cm | over 1 m | +| --- | --- | --- | --- | --- | +| second round (where each sample met them) | 904, 184 | - | 60, 3 | 6, 0 | +| integration 482ac2c | 238, 184 | 2.1, 0.5 cm | 53, 4 | 3, 1 | +| this branch | 120, 216 | 0.7, 0.9 cm | 2, 3 | 1, 0 | + +With the knocked cars simulated with their mass on the client, pushing +through them is corrected by millimetres: O8 is fixed by the traffic's +second round. + +### Another player's car's knocks (for the props) + +The props' record found about one knock in ten that a client predicted for +another player's car undone (the real car missed the prop). The props' +scenarios (slalom and follow at 80 ms and 150 ms, three machines at 150 ms) +twice each on the merged builds, the knocks each client predicted and how +many the host never confirmed: + +| Build | another player's car | the host's | another client's | its own car | +| --- | --- | --- | --- | --- | +| integration 482ac2c | 10 undone of 57 | 5 of 30 | 5 of 27 | 0 of 65 | +| this branch | 3 undone of 43 | 2 of 20 | 1 of 23 | 1 of 84 | + +Most of them in the three-machine runs (integration 9 of 37, this branch 2 +of 28): with the inputs the host holds for a client's car and 60 states a +second, a client runs the other cars on what their players did. + +### The checkpoint race's start + +The props' record saw a client corrected at every acknowledgement for 3-4 +s after a checkpoint race's start (0.56 m, the sign alternating, 58-59 +samples run again). Four checkpoint races on the merged builds (the race's +grid, the start handshake, 60 ms; two each): each had one correction +during the countdown with 55-57 samples run again (the host's first state +of the held car: 5-23 cm, the car drawn 1 mm away), then samples run again +11-13 at a time and millimetres until the cars met. Not reproduced. + +### A race started in the water (the rules' O5) + +Both cars spawned in the water (`OPENMM2_DEBUG_SPAWN`), a checkpoint race, +60 ms, on the final build: the client's car went back to the race's start +at its sample 141 (its own prediction of the water's handler; held on the +grid, it sank from the first sample), the host's copy of it at 435. Each +machine runs its own car's samples from its own loading, but the host +places a client's car only once the race's start is set (here 5 s later) +and drops the inputs it is behind on (42), so a car that moves while held +before then is elsewhere on the client. Only a car spawned in the water by +the development aid moves while held; starting a client's samples when the +host places its car would change how every race starts, so it stays open +(the rules' O5). + +### Bandwidth + +60 states a second: about 440 bytes a message for a client with no near +car (26 KB/s of payload), 700-870 bytes with the host's car near (42-52 +KB/s); with three players near each other 1.1 KB (65 KB/s for each client, +133 KB/s for the host). A message stays within 1372 bytes, so with eight +players a client gets at most 82 KB/s and the host sends at most about 580 +KB/s, within the maintainer's ceiling. A client's inputs stay at 0.9 KB/s. + +### Not done + +* The host's own car has no inputs ahead (the host applies its player's + input at once); a client learns of the host braking a trip after it. + Holding the host's input back by the clients' margin would give them + the same 50 ms, at the cost of the host's own response. + ## Deviations from MM2 * The host simulates every player's car; MM2 ran every network car on every @@ -489,11 +759,11 @@ apart, as in the first round, about 11 and 78 KB/s. each machine its own car's damage; the limits were already the host's in MM2, `UpdateLimit` / `SendLimitReached`). * A client simulates the other players' cars near its own from the host's - states and their last inputs (MM2 drove every network car on its last - pedals and pulled it toward the packets, `mmNetObject::Predict`; OpenMM2 - puts it to the host's state instead), and keeps the players' cars in - player order among the world's movers (MM2: in the order they were - declared). + states and their inputs (the last the host applied and those it holds + for the next samples; MM2 drove every network car on its last pedals and + pulled it toward the packets, `mmNetObject::Predict`; OpenMM2 puts it to + the host's state instead), and keeps the players' cars in player order + among the world's movers (MM2: in the order they were declared). * The host resets a client's car only where the game's rules would (MM2's peers reset their own cars and told the others). @@ -510,14 +780,19 @@ apart, as in the first round, about 11 and 78 KB/s. | O2 | visible | props | Each machine runs its own props, so a car knocking them is corrected (the props work makes them the host's). | the props work landed (14dc206): docs/review/multiplayer-desync-props.md; a chase with props knocked is now corrected about 130-240 times a minute by a few millimetres | | O3 | minor | rules | Checkpoints, laps, finishes and Cops and Robbers' gold were decided on each player's own machine and relayed (a client could claim them); only the limits were the host's. | fixed (protocol 10, docs/review/multiplayer-desync-rules.md): the host runs every car's waypoints after every sample (`game::session::RaceReferee`), the finish exchange, timeout and standings, and Cops and Robbers' rules for every car (`CopsAndRobbers::updateHost`), and tells each player (`game::NetRules`); a client predicts its checkpoints and a pickup, which the host confirms or corrects, and the host refuses a player's own rule events | | O4 | minor | commands | The host checks a client's resets only against the city's box and four a second, not against the race's checkpoints and the water. | fixed (78886cf): `game::ResetRules` (the water, the fall, the race's checkpoints, a wreck's or Cops and Robbers' repair) on the host's simulation of the car; in a race a respawn only at the start or at a checkpoint the host's referee counted for that car (docs/review/multiplayer-desync-rules.md); since protocol 14 the water and the fall are the host's own (`NetCarDriver::setWaterHandler`) and a client's reset is refused, a repair only the wreck penalty's | -| O5 | minor | `CarStatesMsg` | The own car's state is 270 bytes at 20 Hz to every client (78 KB/s for the host with eight players); since protocol 9 about 310 (370 in a contact), and up to two near cars in full: at most about 160 KB/s for the host with eight players all near each other. | open: it could go only when it changed beyond the tolerance, a near car only while the two can meet | +| O5 | minor | `CarStatesMsg` | The own car's state is 270 bytes at 20 Hz to every client (78 KB/s for the host with eight players); since protocol 9 about 310 (370 in a contact), and up to two near cars in full: at most about 160 KB/s for the host with eight players all near each other. | dropped: the maintainer accepted more bandwidth; since protocol 17 60 states a second of at most 1372 bytes (at most 82 KB/s for each client, about 580 KB/s for a host with eight players) | | O6 | minor | MSVC against GCC | Not testable here; the noise test suggests corrections in crashes, eased away. | fixed (multiplayer-determinism.md): the simulation's maths are OpenMM2's own and every build rounds alike; a MinGW build under Wine gives the Linux build's hashes, and CI checks MSVC and Clang | | O7 | minor | replays | The shared traffic's cars still on their rails are held where the frame placed them while a client runs its samples again. | fixed (e51e9bf, 60ddfd0): where each sample met them (corrections over 10 cm driving through the traffic 3 and 60 against 194 and 205; `poseAt` 34 and 89) | | C6 | visible | `RaceScreen` (the players' cars' movers) | Each machine had its own car first among the world's movers, so two cars collided in a different order on the host and on a client. | fixed (94d542a): player order everywhere | | C7 | visible | `net::OwnCarState` | The full state left out what a sample hands the next (the force and torque set for it, the tyres' rolling resistance, a contact's impulses and pushes): a car rebuilt from it drifted from the host's in a contact. | fixed (38fcf08), protocol 9 | | C8 | minor | `netprobe syncreport` | Corrections a minute were counted over the span they fell in. | fixed (156908f) | -| O8 | minor | the shared traffic | A client pushing through a queue of traffic cars is corrected at nearly every state (by a few centimetres), whichever way the replays place them: the host knocks the cars loose, a replay meets them as walls. | open (with the shared traffic) | -| O9 | minor | the near cars | A near car is drawn where the client predicts it: in the harsh runs a client's view of the host's car reached 2.7-2.8 m at the 99th percentile (2.3-2.4 placed at its states), where the host braked or turned in the last 250 ms. | open: inherent to predicting another player; the client's own car is corrected far less in exchange | +| O8 | minor | the shared traffic | A client pushing through a queue of traffic cars is corrected at nearly every state (by a few centimetres), whichever way the replays place them: the host knocks the cars loose, a replay meets them as walls. | fixed by the shared traffic's second round (protocol 12): the cause was the cars the host knocks off their rails, which a client met as bodies of infinite mass (99.5% of the jam's corrections had one within 6 m); simulated with their mass from the host's full states, 120-216 corrections a minute by under a centimetre at the median, 2-3 over 10 cm (second round: 904 and 184; 60 and 3) | +| O9 | minor | the near cars | A near car is drawn where the client predicts it: in the harsh runs a client's view of the host's car reached 2.7-2.8 m at the 99th percentile (2.3-2.4 placed at its states), where the host braked or turned in the last 250 ms. | fixed (557ade8, 694ebfe; protocol 17): the inputs the host holds for a client's car, 60 states a second, the host's car drawn at its present unless the cars may meet; a client's view of the near cars 0.01-0.19 m at the median, at its 99th percentile at or below 0.3.1's except in the rear runs' contact (1.84-2.19 against 1.13-1.19: the contact shown ahead with the client's car) | +| C9 | visible | `CarPrediction` (new) | A client that stalled, or lost its inputs for longer than the host repeats them, stayed behind the host's samples: the host coasted its car and dropped every later input as late (537 m apart at the median in one run). | fixed (28562cf): `CarPrediction::skipTo` | +| C10 | visible | `net::OwnCarState` | The full state left out a contact's bound matrix and hardest pusher, which the next sample reads: a push against a braking car was corrected by 0.93 m. | fixed (c29326f), protocol 17 | +| C11 | minor | `CarPrediction::acknowledge` | Within the tolerance a client kept its own car's prediction while running the near cars again, and in a contact what the tolerance let pass grew. | fixed (83f5762) | +| O10 | minor | the host's own car | A client learns of the host's player braking or turning a trip after it: the host applies its own inputs at once, so it has none ahead to send (a client's car's go 50 ms ahead). | open: holding the host's inputs back by the clients' margin would give the clients those 50 ms at the cost of the host's own response | +| O11 | minor | props, the rear runs | The rear runs that corrected most did it with the client's car pushing into knocked-over props and pieces (851 of 894 corrections over 1 cm in one run). | open, with the props (multiplayer-desync-props.md, "Open") | ## Reproducing @@ -546,3 +821,10 @@ each; wall: client at (-1149.945, 111.9, 183.194), angle π/2, input race ran without `OPENMM2_DEBUG_START` (the race's grid). The two-car shunt without a network is `PlayerCars.AShunt*` in `tests/game/test_player_cars.cpp`. + +The third round's water race: a checkpoint race (`mp:mode:race`) with the +host spawned at (-1779, 3, -1040) and the client at (-1779, 3, -1130), angle +0 (`OPENMM2_DEBUG_SPAWN`). The props' scenarios are those of +multiplayer-desync-props.md ("How it was measured"); `x` lines in a client's +trace after its `k` line with another player's car as the cause count an +undone prediction. diff --git a/docs/review/multiplayer-sync.md b/docs/review/multiplayer-sync.md index dd7f51b..7a6a0ba 100644 --- a/docs/review/multiplayer-sync.md +++ b/docs/review/multiplayer-sync.md @@ -182,7 +182,7 @@ a cruise never took them, so they piled up (also capped by d7fb35b). | --- | --- | --- | --- | --- | | 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. | fixed in 5b27203 (rendering, docs/rendering.md "Drawing between simulation steps"): every simulated object is drawn between its last two steps (`game::StepHistory`, blended by `interpolationAlpha`), and the remote cars and a client's shared traffic from a sample one step further back, where that scene is; at 144 fps the host's car as a client draws it moves by 1.5 mm (median second difference per frame) instead of 112 mm. | | 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`). | superseded (protocol 5, docs/review/multiplayer-desync-cars.md): the host simulates every player's car, so collisions between players happen there once and both cars give way; since protocol 9 a client also simulates the players' cars near its own (its own collisions with them give way too, as on the host). | -| 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, the maintainer's choice for the host-authoritative model too); measured with the others placed ahead instead in docs/review/multiplayer-desync-cars.md. Since protocol 9 the cars near a client's are simulated there at its own car's time (they meet where the host's cars meet); the farther ones are still shown in the past. | +| 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, the maintainer's choice for the host-authoritative model too); measured with the others placed ahead instead in docs/review/multiplayer-desync-cars.md. Since protocol 9 the cars near a client's are simulated there (they meet where the host's cars meet); since protocol 17 drawn at their player's present unless the two cars may meet within half a second, at the client's car's time then; the farther ones are still shown in the past. | | 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). | fixed in 1523740 (docs/multiplayer.md "Damage"): the network car's CurrentDamage follows the snapshot's level and `vehCarDamage::Update` makes its smoke, exhaust and tyre wobble; what the owner's `vehCarDamage::ApplyImpact` painted and broke travels as a reliable `VehicleDamage` event (each texel patch's point and random state, the parts, the impacts' sparks, shards and sound, a new epoch on every reset) and is replayed at the time it happened, so both machines show the same dents texel for texel (two instances, same-moment screenshots; test `DamageSync.ReplayedDentsMatchTheOwnersTexelForTexel`). The shared police get the same, and a client's knocked traffic cars their wheels. Deviation: a machine no longer paints its own collisions of a network car (MM2's did). Open: no tyre tracks or wheel particles on network cars. | | 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. | fixed in 276e508 (docs/review/multiplayer-desync-cars.md C3): the host alone ends the game and tells the others. | diff --git a/src/net/PlayerCars.h b/src/net/PlayerCars.h index de146ca..1922cbd 100644 --- a/src/net/PlayerCars.h +++ b/src/net/PlayerCars.h @@ -238,7 +238,7 @@ struct OwnCarState { std::array tireResistance{}; bool contact = false; Vec3 linearImpulse, angularImpulse, linearPush, turnForce, framePush; - // A contact's bookkeeping at the sample's end (protocol 11): the bound's + // A contact's bookkeeping at the sample's end (protocol 17): the bound's // matrix as the sample's collisions saw it, when the sample's push has // moved the body past it (`hasBound`; otherwise it follows the body), and // the collider that pushed hardest in the sample (phColliderBase's last @@ -375,7 +375,7 @@ struct NearCarState { OwnCarState state; CarInputFrame input; // the last applied (its keys, applied once, left out) // A client's car: the inputs the host holds for its next samples, in - // order (protocol 11; the host's own car has none), so that the + // order (protocol 17; the host's own car has none), so that the // receiving client runs it on them instead of on `input` repeated. std::vector upcoming; }; From 7c4dcd0bc6fbb09db8e654a4c3e7a0fa23301b12 Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Sat, 10 Oct 2026 01:16:42 -0700 Subject: [PATCH 125/129] Check the near set, the inputs ahead and a contact's bookkeeping in the fuzz test The mutated CarStates the decoder accepts now also keep the near set within three cars, a near car's inputs ahead within 16 (their flags, events and extra mass in range) and every full state's carried forces, pushes, tyre resistances and bound matrix in range. Co-Authored-By: Claude Opus 5.5 (1M context) --- tests/net/test_player_cars.cpp | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tests/net/test_player_cars.cpp b/tests/net/test_player_cars.cpp index 153c5f7..79c5989 100644 --- a/tests/net/test_player_cars.cpp +++ b/tests/net/test_player_cars.cpp @@ -190,12 +190,28 @@ void checkInput(const PlayerInputMsg& m) { } } +// What a sample hands the next and a contact's bookkeeping (protocol 17). +void checkCarry(const OwnCarState& c) { + const auto within = [](const Vec3& v, float r) { + return std::abs(v.x) <= r && std::abs(v.y) <= r && std::abs(v.z) <= r; + }; + for (const Vec3& v : {c.force, c.torque, c.linearImpulse, c.angularImpulse, c.linearPush, c.turnForce, + c.framePush}) + ASSERT_TRUE(within(v, kOwnStateMaxValue)); + for (float r : c.tireResistance) + ASSERT_LE(std::abs(r), kOwnStateMaxValue); + ASSERT_TRUE(within(c.bound.m0, 2.0f) && within(c.bound.m1, 2.0f) && within(c.bound.m2, 2.0f)); + ASSERT_TRUE(within(c.bound.m3, kOwnStateMaxCoordinate)); +} + void checkStates(const CarStatesMsg& m) { ASSERT_LE(m.cars.size(), kMaxPlayers); ASSERT_LE(std::abs(m.waiting), kMaxReportedWaiting); + ASSERT_LE(m.nearIds.size(), kMaxNearCars); if (!m.hasOwn) return; const OwnCarState& o = m.own; + checkCarry(o); for (const Vec3& v : {o.matrix.m0, o.matrix.m1, o.matrix.m2}) ASSERT_TRUE(std::abs(v.x) <= 2.0f && std::abs(v.y) <= 2.0f && std::abs(v.z) <= 2.0f); for (const Vec3& v : {o.matrix.m3, o.stuckPosition}) @@ -221,6 +237,13 @@ void checkStates(const CarStatesMsg& m) { std::abs(c.matrix.m3.z) <= kOwnStateMaxCoordinate); for (const Vec3& v : {c.linearMomentum, c.angularMomentum, c.linearVelocity, c.angularVelocity}) ASSERT_TRUE(std::isfinite(v.x) && std::isfinite(v.y) && std::isfinite(v.z)); + checkCarry(c); + ASSERT_LE(n.upcoming.size(), kMaxUpcomingInputs); + for (const auto& u : n.upcoming) { + ASSERT_EQ(u.flags & ~kCarInputFlagMask, 0); + ASSERT_EQ(u.events & ~kCarInputEventMask, 0); + ASSERT_LE(u.extraMass, kMaxExtraMass); + } } } From 262c0eb33ee58616b66d32ef43553896c1c62b93 Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Sat, 10 Oct 2026 04:02:39 -0700 Subject: [PATCH 126/129] Send the pieces round a client's car in full (protocol 18) A client's car pushing knocked-over props and pieces met them where the host's messages showed them (a playout delay in the past) or as its own simulation had them, and was corrected at nearly every state. The host now sends, with the CarStates of each client (about 20 times a second), the pieces round that client's car in full (net::PropFullMsg): a moving piece's body as its simulation has it, with the bound where the sample's collisions saw it and what pushed it hardest (the sweep against that collider starts where the push left it); a resting one's exact frame. The client puts them to those states and runs them with its car when its samples run again (CarPrediction companions), so the replays reproduce the host's samples exactly. Also: a piece in full takes this machine's own piece of the same prop before the slot is shown; a piece in full knocks placed props as the host's does (a prediction, with a replay ghost); its prop's ghost goes once the host's state of it is known; up to three datagrams a state, the nearest pieces in the host's world's order. Measured in tests/game/test_prop_push.cpp (a car pushing a pile of 18 props through the rest, 60 states a second, 100 ms each way): no correction with the pieces in full, 269 without. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/app/RaceScreen.cpp | 16 ++ src/game/bangers/BangerSet.cpp | 156 +++++++++++++- src/game/bangers/BangerSet.h | 33 +++ src/game/net/NetGame.cpp | 8 + src/game/net/NetGame.h | 4 + src/game/net/NetProps.cpp | 61 +++++- src/game/net/NetProps.h | 20 ++ src/game/net/PropSync.cpp | 217 +++++++++++++++++++- src/game/net/PropSync.h | 64 +++++- src/net/PropState.h | 192 +++++++++++++++++ src/net/Protocol.h | 7 +- src/net/Session.cpp | 22 ++ src/net/Session.h | 7 + src/phys/World.cpp | 10 + src/phys/World.h | 8 + tests/game/test_prop_push.cpp | 364 +++++++++++++++++++++++++++++++++ tests/game/test_prop_sync.cpp | 64 ++++++ tests/net/test_fuzz.cpp | 56 +++++ tests/net/test_prop_state.cpp | 98 +++++++++ 19 files changed, 1393 insertions(+), 14 deletions(-) create mode 100644 tests/game/test_prop_push.cpp diff --git a/src/app/RaceScreen.cpp b/src/app/RaceScreen.cpp index d00b945..c051f79 100644 --- a/src/app/RaceScreen.cpp +++ b/src/app/RaceScreen.cpp @@ -5888,6 +5888,17 @@ class RaceScreen final : public Screen { std::vector companions; predictNearCars(newest->nearIds, newest->near, ack, companions); predictNetTraffic(ctx, newest->time, companions); + m_netProps.fullCompanions(newest->time, ctx.netGame->frameTime(), companions, m_netFullBodies, + m_player->sim().body, + [this, ack](const phys::Body* b) -> std::optional> { + // As each sample began: the one after the acknowledged one. + for (const auto& h : m_bodyHistory) + if (h.seq == ack + 1) + for (const auto& p : h.poses) + if (p.body == b) + return std::pair{p.ics, p.velocity}; + return std::nullopt; + }); m_prediction.pusherKey = [this, &ctx](std::uint8_t id) -> const void* { if (id == ctx.netGame->localId()) return m_player->sim().body.collider.key(); @@ -5990,6 +6001,7 @@ class RaceScreen final : public Screen { } m_netReplaying = false; afterNetTrafficReplay(newest->time); + m_netProps.afterReplay(newest->time); // The other players' cars: what the host's states moved the ones // simulated here by, and a switch between simulating one and placing // it at its states, drawn away (a switch more slowly). @@ -6084,6 +6096,10 @@ class RaceScreen final : public Screen { if (m_frameSteps > 0 && now - m_netCarsSentAt >= netCarStatesIntervalMs()) { sendNetCars(ctx, now); sendNetTrafficFull(ctx); // OpenMM2: the shared traffic near each client, in full + // OpenMM2: the pieces round each client's car, in full. + for (const auto& [id, rv] : m_remotes) + if (rv.simulated && rv.placed && rv.sim) + m_netProps.sendFull(*ctx.netGame, id, m_netStateTime, rv.sim->sim().body); } } else if (m_frameSteps > 0) { if (const auto msg = m_prediction.message()) { diff --git a/src/game/bangers/BangerSet.cpp b/src/game/bangers/BangerSet.cpp index 087a329..8c3e638 100644 --- a/src/game/bangers/BangerSet.cpp +++ b/src/game/bangers/BangerSet.cpp @@ -16,6 +16,7 @@ #include #include #include +#include namespace mm2::game::bangers { namespace { @@ -571,6 +572,7 @@ std::size_t BangerSet::getBanger() { m_ringHandedAt.push_back(0.0); } ++m_ringGeneration[slot]; // OpenMM2: a new prop in the slot (network games) + m_instances[m_ring[slot]].driven = false; m_ringHandedAt[slot] = m_world ? m_world->time() : 0.0; const std::size_t i = m_ring[slot]; // Its previous prop disappears. @@ -839,9 +841,11 @@ void BangerSet::unhitImpact(std::size_t i, const phys::Instance* by) { Prop& unhitProp = *m_props[i]; if (m_recordKnocks && !m_instances[i].everHit) m_knocks.push_back({i, by}); // OpenMM2: network games (game/net/PropSync) - // OpenMM2: a network client's own car broke it loose: its replays meet - // it where it stood for a while (replayGhost). - if (m_replica && m_world && !m_instances[i].everHit && by && m_localToucher && m_localToucher(*by)) + // OpenMM2: a network client's own car broke it loose (or a host's piece + // it simulates from the host's states): its replays meet it where it + // stood for a while (replayGhost). + if (m_replica && m_world && !m_instances[i].everHit && by && + ((m_localToucher && m_localToucher(*by)) || drivenBody(*by))) m_ghosts.push_back({i, m_world->time()}); if (d.numParts == 0) { @@ -1211,6 +1215,13 @@ bool BangerSet::replayGhostPiece(std::size_t i) const { }); } +bool BangerSet::drivenBody(const phys::Instance& other) const { + for (const auto& a : m_active) + if (&a->body == &other) + return a->instance >= 0 && m_instances[static_cast(a->instance)].driven; + return false; +} + bool BangerSet::isActiveBody(const phys::Instance* i) const { for (const auto& a : m_active) if (&a->body == i) @@ -1235,9 +1246,21 @@ bool BangerSet::acceptsFrom(std::size_t i, const phys::Instance& other) const { // host never had). The host's knocks bring those it makes. The host's // pieces move only for this machine's cars (two of them lying together // would otherwise keep each other moving here while the host's rest). - if (m_instances[i].mirror || !m_instances[i].everHit) - return false; - return isActiveBody(&other); + // A host's piece the host sends in full (net::PropFull) flies as the + // host's: it knocks a placed prop as the host's does (a prediction like + // its car's; measured on a car pushing a pile of 18 props through the + // rest: no correction with, 10 without). + if (!m_instances[i].everHit) + return drivenBody(other); + if (!m_instances[i].mirror) + return isActiveBody(&other); + // A host's piece the host sends in full (net::PropFull) meets the pieces + // simulated here, and they it, as on the host. + for (const auto& a : m_active) + if (&a->body == &other) + return m_instances[i].driven || + (a->instance >= 0 && m_instances[static_cast(a->instance)].driven); + return false; } void BangerSet::breakPlaced(std::size_t i) { @@ -1324,6 +1347,127 @@ void BangerSet::showMirror(std::size_t slot, const MirrorSpec& spec, const Mat34 moveToRoom(i, room); } +std::pair BangerSet::pusherOf(const void* key, const phys::Body* car) const { + using net::PropPusher; + if (!key) + return {PropPusher::None, 0}; + if (m_world && key == m_world->cityKey()) + return {PropPusher::City, 0}; + if (car && key == car->collider.key()) + return {PropPusher::Car, 0}; + if (m_world && key == m_world->staticKey(false)) + return {PropPusher::StaticA, 0}; + if (m_world && key == m_world->staticKey(true)) + return {PropPusher::StaticB, 0}; + for (std::size_t k = 0; k < m_ring.size() && k < net::kMaxPropSlots; ++k) + if (const phys::Body* b = body(m_ring[k]); b && b->collider.key() == key) + return {PropPusher::Piece, static_cast(k)}; + return {PropPusher::Other, 0}; +} + +std::optional BangerSet::bodyState(std::size_t i, const phys::Body* car) const { + // A body the world's last step did not run (beyond its movers, or not + // declared) is not simulated: a client must not run it either. + const phys::Body* b = i < m_instances.size() ? body(i) : nullptr; + if (!b || !m_world || !m_world->isActive(b)) + return std::nullopt; + const Active& a = *m_active[static_cast(m_instances[i].active)]; + const phys::InertialCS& ics = b->ics; + net::PropBodyState s; + s.matrix = ics.matrix; + s.linearMomentum = ics.linearMomentum; + s.angularMomentum = ics.angularMomentum; + s.linearVelocity = ics.linearVelocity; + s.angularVelocity = ics.angularVelocity; + s.force = ics.linearForce; + s.torque = ics.angularTorque; + s.lastPush = ics.lastPush; + s.linearImpulse = ics.linearImpulse; + s.angularImpulse = ics.angularImpulse; + s.linearPush = ics.linearPush; + s.turnForce = ics.turnForce; + s.framePush = ics.framePush; + const Vec3 zero{}; + s.contact = !(s.linearImpulse == zero && s.angularImpulse == zero && s.linearPush == zero && + s.turnForce == zero && s.framePush == zero); + s.sleepState = a.sleep.state; + s.stillUpdates = std::clamp(a.sleep.stillUpdates, 0, 1023); + s.dormantUpdates = std::clamp(a.sleep.dormantUpdates, 0, 1023); + s.age = a.age; + std::tie(s.pusher, s.pusherSlot) = pusherOf(b->collider.lastMaxPusher, car); + // The bound where the collisions saw it, when the push moved the body on. + Mat34 follows = ics.matrix; + follows.m3 = ics.matrix.transform(b->boundOrigin); + s.hasBound = !(b->boundMatrix.m0 == follows.m0 && b->boundMatrix.m1 == follows.m1 && + b->boundMatrix.m2 == follows.m2 && b->boundMatrix.m3 == follows.m3); + if (s.hasBound) + s.bound = b->boundMatrix; + return s; +} + +phys::Body* BangerSet::simulate(std::size_t i) { + if (i >= m_instances.size() || !m_instances[i].data || m_instances[i].state == State::Gone || + m_instances[i].state == State::Unhit) + return nullptr; + if (Active* a = activeOf(i)) + return &a->body; + // As a car's touch would (World::collideInstances: AttachEntity, then + // the new mover), the mirror from the host's motion. + phys::Body* b = attachEntity(i); + if (b && m_world && !m_world->contains(b)) + m_world->addNewMover(b); + return b; +} + +std::size_t BangerSet::worldOrder(std::size_t i) const { + const phys::Body* b = body(i); + return b && m_world ? m_world->order(b) : SIZE_MAX; +} + +void BangerSet::forgetGhostOf(std::size_t i) { + if (i >= m_instances.size() || !m_instances[i].everHit || m_instances[i].source < 0) + return; + const auto source = static_cast(m_instances[i].source); + std::erase_if(m_ghosts, [source](const Ghost& g) { return g.prop == source; }); +} + +bool BangerSet::setBodyState(std::size_t i, const net::PropBodyState& s, const void* pusher) { + phys::Body* b = simulate(i); + if (!b) + return false; + Active& a = *m_active[static_cast(m_instances[i].active)]; + phys::InertialCS& ics = b->ics; + ics.matrix = s.matrix; + ics.linearMomentum = s.linearMomentum; + ics.angularMomentum = s.angularMomentum; + ics.linearVelocity = s.linearVelocity; + ics.angularVelocity = s.angularVelocity; + ics.linearForce = s.force; + ics.angularTorque = s.torque; + ics.lastPush = s.lastPush; + ics.linearImpulse = s.linearImpulse; + ics.angularImpulse = s.angularImpulse; + ics.linearPush = s.linearPush; + ics.turnForce = s.turnForce; + ics.framePush = s.framePush; + a.sleep.state = std::clamp(s.sleepState, 0, 2); + a.sleep.stillUpdates = s.stillUpdates; + a.sleep.dormantUpdates = s.dormantUpdates; + a.age = s.age; + // The bound follows the body; the next sweep starts where it is; the + // instance is drawn there. + if (s.hasBound) + b->boundMatrix = s.bound; + else + b->syncBoundMatrix(); + b->collider.lastMatrix = b->boundMatrix; + b->collider.lastMaxPusher = pusher; + b->collider.maxPusher = nullptr; + b->collider.maxPush2 = 0.0f; + m_instances[i].matrix = s.matrix; + return true; +} + void BangerSet::releaseMirror(std::size_t slot) { if (slot >= m_mirrors.size() || m_mirrors[slot] == static_cast(-1)) return; diff --git a/src/game/bangers/BangerSet.h b/src/game/bangers/BangerSet.h index 5f2f218..3cc0843 100644 --- a/src/game/bangers/BangerSet.h +++ b/src/game/bangers/BangerSet.h @@ -19,6 +19,7 @@ #include "game/fx/EffectLibrary.h" #include "game/fx/ParticleRenderer.h" #include "game/fx/Particles.h" +#include "net/PropState.h" #include "phys/World.h" #include "render/Device.h" @@ -70,6 +71,7 @@ class BangerSet final : public InstanceSource { // places props without a room. Must outlive this set (or call // setWorld(nullptr) first). void setWorld(phys::World* world); + const phys::World* world() const { return m_world; } // Once per frame, after the physics world's step: dgBangerActive:: // PostUpdate (actives that sleep or fell below the city detach), then @@ -132,6 +134,9 @@ class BangerSet final : public InstanceSource { // (showMirror), where it is drawn while the host drives it and its // motion there (an active attached to it here starts with it). bool mirror = false; + // OpenMM2: one the host sends in full (net::PropFull), simulated here + // from its states: it meets the pieces simulated here as the host's. + bool driven = false; std::optional drawn; Vec3 mirrorVelocity, mirrorSpin; }; @@ -227,6 +232,9 @@ class BangerSet final : public InstanceSource { bool moving(std::size_t i) const; // Whether `i` is the body of one of this set's actives. bool isActiveBody(const phys::Instance* i) const; + // Whether `other` is the body of a piece the host sends in full (net:: + // PropFull), simulated here from its states. + bool drivenBody(const phys::Instance& other) const; // The body an active would give instance i if a car hit it now // (phys::Instance::heldInertia, for World::replaySample); false for an // instance with an active or out of the world. @@ -288,6 +296,31 @@ class BangerSet final : public InstanceSource { void releaseMirror(std::size_t slot); std::size_t mirrorCount() const { return m_mirrors.size(); } + // OpenMM2 (network games, net::PropFull): instance i's body as the host + // sends it in full (nullopt without an active the world's last step ran); + // `car`: the car of the client it is for (net::PropPusher::Car). + std::optional bodyState(std::size_t i, const phys::Body* car = nullptr) const; + // What a collider key (a body's hardest pusher) is in those terms. + std::pair pusherOf(const void* key, const phys::Body* car) const; + // Where instance i's body runs in the world's movers (World::order; + // SIZE_MAX without one). + std::size_t worldOrder(std::size_t i) const; + // Client: instance i (a mirror shown, or a piece this machine simulates) + // simulated here, given an active first when it has none (from the + // motion it has); its body, or null when it cannot be (not shown). + phys::Body* simulate(std::size_t i); + void setDriven(std::size_t i, bool driven) { + if (i < m_instances.size()) + m_instances[i].driven = driven; + } + // ... and put to the host's state (given an active first), `pusher` + // the key of its hardest pusher here. + bool setBodyState(std::size_t i, const net::PropBodyState& s, const void* pusher = nullptr); + // Client: the host's state of piece i is known (it knocked the prop + // over before then, whatever knocked it), so the replays starting from + // it meet the piece and not the prop standing (no replayGhost). + void forgetGhostOf(std::size_t i); + private: struct Active; struct ActiveBody; diff --git a/src/game/net/NetGame.cpp b/src/game/net/NetGame.cpp index 5adfcd1..31c3f60 100644 --- a/src/game/net/NetGame.cpp +++ b/src/game/net/NetGame.cpp @@ -917,6 +917,14 @@ std::vector NetGame::takePropStates() { return m_impl->session ? m_impl->session->takePropStates() : std::vector{}; } +std::size_t NetGame::sendPropFull(std::uint8_t playerId, const net::PropFullMsg& msg) { + return m_impl->session ? m_impl->session->sendPropFull(playerId, msg) : 0; +} + +std::vector NetGame::takePropFull() { + return m_impl->session ? m_impl->session->takePropFull() : std::vector{}; +} + net::PeerStats NetGame::peerStats(std::uint8_t playerId) const { return m_impl->session ? m_impl->session->peerStats(playerId) : net::PeerStats{}; } diff --git a/src/game/net/NetGame.h b/src/game/net/NetGame.h index de6f80e..6961553 100644 --- a/src/game/net/NetGame.h +++ b/src/game/net/NetGame.h @@ -346,6 +346,10 @@ class NetGame { std::size_t sendPropState(std::uint8_t playerId, const net::PropStateMsg& msg); // Client: the host's messages received since the last call. std::vector takePropStates(); + // Host: sends one player the pieces round its car in full (unreliable); + // returns the bytes sent. Client: those received since the last call. + std::size_t sendPropFull(std::uint8_t playerId, const net::PropFullMsg& msg); + std::vector takePropFull(); // --- The host's rules state (OpenMM2, net/RulesState.h, game::NetRules) ------------- // Host: sends one player its race's rules state (unreliable); returns the bytes sent. diff --git a/src/game/net/NetProps.cpp b/src/game/net/NetProps.cpp index cff9c88..8375845 100644 --- a/src/game/net/NetProps.cpp +++ b/src/game/net/NetProps.cpp @@ -125,10 +125,13 @@ void NetProps::setup(NetGame& net, bangers::BangerSet& set, void NetProps::beforeStep(NetGame& net, std::span events, double now, const PropClient::CarPartResolver& resolve) { auto messages = net.takePropStates(); + auto full = net.takePropFull(); if (!m_client || !m_set) return; for (const auto& msg : messages) m_client->receive(msg, net.frameTime()); + for (const auto& msg : full) + m_client->receiveFull(msg); for (const auto& ev : events) { if (static_cast(ev.type) != net::kPropKnocksEvent || ev.from != net::kHostPlayerId) continue; @@ -138,6 +141,56 @@ void NetProps::beforeStep(NetGame& net, std::span events, do m_client->update(*m_set, now, resolve, m_hostCar, m_ownCarAt ? m_ownCarAt() : std::nullopt); } +void NetProps::sendFull(NetGame& net, std::uint8_t id, std::uint32_t time, const phys::Body& car) { + if (!m_host || !m_set) + return; + for (const auto& msg : m_host->buildFull(*m_set, id, time, car.ics.matrix.m3, &car)) + m_fullBytes += net.sendPropFull(id, msg); +} + +void NetProps::fullCompanions(std::uint32_t time, double now, std::vector& out, + std::vector& bodies, phys::Body& car, + const Recorded& recorded) { + if (!m_client || !m_set) + return; + bangers::BangerSet* set = m_set; + const CarPrediction::Options tolerances{}; + for (const auto& piece : m_client->fullPieces(*set, time, now, &car)) { + phys::Body* body = set->simulate(piece.instance); + if (!body) + continue; + CarPrediction::Companion c; + c.body = body; + // (After the car's own state: the piece may have pushed it hardest.) + c.rebase = [set, piece, body, &car] { + set->setBodyState(piece.instance, piece.state, piece.pusher); + if (piece.pushedCar) + car.collider.lastMaxPusher = body->collider.key(); + }; + c.position = piece.state.matrix.m3; + c.velocity = piece.state.linearVelocity; + // As the car's own state is compared (CarPrediction::acknowledge). + if (const auto had = recorded ? recorded(body) : std::nullopt) { + const Mat34& a = had->first; + const Mat34& b = piece.state.matrix; + float rotation = 0.0f; + for (const auto& [u, v] : {std::pair{a.m0, b.m0}, std::pair{a.m1, b.m1}, std::pair{a.m2, b.m2}}) + rotation = + std::max({rotation, std::abs(u.x - v.x), std::abs(u.y - v.y), std::abs(u.z - v.z)}); + c.differs = a.m3.dist(b.m3) > tolerances.positionTolerance || + (had->second - piece.state.linearVelocity).mag() > tolerances.velocityTolerance || + rotation > tolerances.rotationTolerance; + } + out.push_back(std::move(c)); + bodies.push_back(body); + } +} + +void NetProps::afterReplay(std::uint32_t time) { + if (m_client) + m_client->forgetFull(time); +} + void NetProps::sendCatchUps(NetGame& net, std::uint32_t time) { for (const auto& p : net.players()) { if (p.id == net.localId() || !net.playerLoaded(p.id) || m_caughtUp.contains(p.id)) @@ -225,7 +278,7 @@ void NetProps::logStats(NetGame& net, std::uint64_t nowMs) { if (s.messages > 0 || m_sentEvents > 0) log::info("netprops: host sent {:.1f} messages/s ({:.0f} bytes/s to each of {} clients, {:.1f} " "slots and {:.1f} states a message, {:.0f}% of them near; {} deferred), ring {}, {} " - "knocks in {} events ({} bytes)", + "knocks in {} events ({} bytes); in full {:.0f} bytes/s, {} pieces", static_cast(s.messages) / seconds / static_cast(clients), static_cast(m_sentBytes) / seconds / static_cast(clients), clients, s.messages ? static_cast(s.slots) / static_cast(s.messages) : 0.0, @@ -233,9 +286,11 @@ void NetProps::logStats(NetGame& net, std::uint64_t nowMs) { s.fullStates ? 100.0 * static_cast(s.nearStates) / static_cast(s.fullStates) : 0.0, - s.deferred, m_set->ringSize(), s.knocks, m_sentEvents, m_eventBytes); + s.deferred, m_set->ringSize(), s.knocks, m_sentEvents, m_eventBytes, + static_cast(m_fullBytes) / seconds / static_cast(clients), + s.fullPieces); s = {}; - m_sentBytes = m_sentMessages = m_sentEvents = m_eventBytes = 0; + m_sentBytes = m_sentMessages = m_sentEvents = m_eventBytes = m_fullBytes = 0; } if (m_client) { const auto& s = m_client->stats(); diff --git a/src/game/net/NetProps.h b/src/game/net/NetProps.h index c57acdf..0186454 100644 --- a/src/game/net/NetProps.h +++ b/src/game/net/NetProps.h @@ -7,6 +7,7 @@ // props differ. #include "game/bangers/BangerSet.h" +#include "game/net/PlayerCars.h" #include "game/net/PropSync.h" #include @@ -110,6 +111,24 @@ class NetProps { using Classifier = std::function; void afterStep(NetGame& net, double now, std::uint64_t nowMs, const Classifier& classify); + // Host, with the CarStates it sends client `id` this frame (session time + // `time`): the pieces round that client's car in full (net::PropFull). + void sendFull(NetGame& net, std::uint8_t id, std::uint32_t time, const phys::Body& car); + // Client, in its car's acknowledgement of the CarStates at `time`: the + // host's moving pieces round its car as companions of its car's samples + // run again (put to the host's state there and simulated with it); their + // bodies join `bodies` (the race screen's companions' bodies). `now`: + // this frame's session time; `car`: this machine's car. `recorded`: where this machine had a body + // after the acknowledged sample (its frame and velocity), if it knows: a + // piece it had within the car's tolerances there (game::CarPrediction:: + // Options) needs no samples run again for it. + using Recorded = std::function>(const phys::Body*)>; + void fullCompanions(std::uint32_t time, double now, std::vector& out, + std::vector& bodies, phys::Body& car, + const Recorded& recorded = {}); + // ... and after it: those states used. + void afterReplay(std::uint32_t time); + // The trace, when OPENMM2_NET_TRACE is set; `traced()` tells whether // afterStep began a tick this frame (the race adds its cars' lines). PropTrace* trace() { return m_trace.get(); } @@ -134,6 +153,7 @@ class NetProps { bool m_traced = false; std::uint64_t m_statsAt = 0; std::uint64_t m_sentBytes = 0, m_sentMessages = 0, m_sentEvents = 0, m_eventBytes = 0; + std::uint64_t m_fullBytes = 0; }; } // namespace mm2::game diff --git a/src/game/net/PropSync.cpp b/src/game/net/PropSync.cpp index 8a3a422..93eaad4 100644 --- a/src/game/net/PropSync.cpp +++ b/src/game/net/PropSync.cpp @@ -366,6 +366,100 @@ std::vector> PropHost::build(const Ba return out; } +std::vector PropHost::buildFull(const BangerSet& set, std::uint8_t id, std::uint32_t time, + const Vec3& car, const phys::Body* carBody) { + std::vector out; + FullClient& client = m_full[id]; + if (client.sentAt && time >= *client.sentAt && time - *client.sentAt < kFullIntervalMs) + return out; + const auto& ring = set.ring(); + const std::size_t slots = std::min(ring.size(), net::kMaxPropSlots); + auto& rest = client.rest; + const float r2 = kFullRadius * kFullRadius; + std::vector> pieces; + for (std::size_t k = 0; k < slots; ++k) { + const auto& inst = set.instances()[ring[k]]; + const float d2 = inst.matrix.m3.dist2(car); + if (inst.state == BangerSet::State::Gone || !describeHit(inst) || d2 > r2) { + rest.erase(k); + continue; + } + net::PropFullPiece p; + p.slot = static_cast(k); + p.generation = static_cast(set.generation(k) % net::kPropGenerations); + if (const auto state = set.bodyState(ring[k], carBody)) { + p.moving = true; + p.body = *state; + rest.erase(k); + } else if (set.body(ring[k])) { + continue; // an active the world's last step did not run: nor does a client + } else { + // At rest: its exact frame (the messages' is quantized), three + // times (they are unreliable) after it came to rest or near. + RestSent& r = rest[k]; + const Mat34& m = inst.matrix; + const bool same = r.generation == set.generation(k) && r.matrix.m0 == m.m0 && + r.matrix.m1 == m.m1 && r.matrix.m2 == m.m2 && r.matrix.m3 == m.m3; + if (!same) + r = {set.generation(k), m, 0}; + if (r.sent >= 3) + continue; + ++r.sent; + p.moving = false; + p.body.matrix = m; + } + pieces.emplace_back(d2, p); + } + if (pieces.empty()) + return out; + client.sentAt = time; + std::ranges::sort(pieces, {}, &std::pair::first); + net::PropFullMsg header; + header.time = time; + if (carBody) + if (const auto [kind, slot] = set.pusherOf(carBody->collider.lastMaxPusher, carBody); + kind == net::PropPusher::Piece) + header.carPusher = slot; + // The nearest that fit (a smaller one further away may), then in the + // world's order: the client's car meets them in that order when its + // samples run again, as the host's world runs them. + const std::size_t empty = net::encodeMessage(header).size(); + const std::size_t room = net::kPropFullBytes - empty; + std::size_t budget = net::kMaxPropFullMessages * room; + std::vector> chosen; + for (const auto& [d2, p] : pieces) { + if (chosen.size() == net::kMaxPropFullMessages * net::kMaxPropFullPieces) + break; + net::PropFullMsg one = header; + one.pieces = {p}; + const std::size_t bytes = net::encodeMessage(one).size() - empty + 1; // + 1: the bits' rounding + if (bytes > budget) + continue; + budget -= bytes; + chosen.emplace_back(set.worldOrder(ring[p.slot]), p); + } + std::ranges::stable_sort(chosen, {}, &std::pair::first); + net::PropFullMsg msg = header; + for (const auto& [order, p] : chosen) { + msg.pieces.push_back(p); + if (msg.pieces.size() <= net::kMaxPropFullPieces && + net::encodeMessage(msg).size() <= net::kPropFullBytes) + continue; + msg.pieces.pop_back(); + out.push_back(msg); + if (out.size() == net::kMaxPropFullMessages) + break; + msg.pieces = {p}; + msg.part = static_cast(out.size()); + } + if (out.size() < net::kMaxPropFullMessages && !msg.pieces.empty()) + out.push_back(std::move(msg)); + m_stats.fullMessages += out.size(); + for (const auto& m : out) + m_stats.fullPieces += m.pieces.size(); + return out; +} + // --- Client -------------------------------------------------------------------------------------- PropClient::PropClient(std::uint32_t catalog, const Options& options) @@ -490,6 +584,111 @@ void PropClient::predicted(std::span knocks, double now, } } +void PropClient::receiveFull(const net::PropFullMsg& msg) { + if (m_mismatch) + return; + auto& kept = m_full[msg.time]; + auto& list = kept.pieces; + if (!kept.carPusher) + kept.carPusher = msg.carPusher; + for (const auto& p : msg.pieces) { + if (std::ranges::any_of(list, [&](const auto& o) { return o.second.slot == p.slot; })) + continue; // the same slot twice: the first counts + // In the host's order: after the earlier messages' pieces, whichever came first. + const auto at = + std::ranges::upper_bound(list, msg.part, {}, &std::pair::first); + list.emplace(at, msg.part, p); + if (!p.moving) + m_slots[p.slot].exactRest = std::pair{p.generation, p.body.matrix}; + } + // About a second's worth (CarStates come up to 60 times a second). + while (m_full.size() > 64) + m_full.erase(m_full.begin()); +} + +std::vector PropClient::fullPieces(BangerSet& set, std::uint32_t time, double now, + const phys::Body* car) { + std::vector out; + const auto it = m_full.find(time); + if (it == m_full.end()) + return out; + std::vector slots; + for (const auto& [part, p] : it->second.pieces) { + if (!p.moving) + continue; + const auto s = m_slots.find(p.slot); + if (s == m_slots.end()) + continue; + Slot& slot = s->second; + std::size_t instance = 0; + if (slot.shownGeneration == p.generation) { + // The generation shown here (a slot handed out again since shows + // its old prop until then). + instance = slot.standIn ? *slot.standIn : set.mirror(p.slot); + } else { + // Not shown yet (the playout delay): the piece this machine's car + // made of the same prop, if it knocked it too, stands in for it + // from now on (as update() would make it once shown). + const auto g = std::ranges::find_if(slot.generations, [&](const Generation& x) { + return x.described && x.generation == p.generation && !x.goneAt; + }); + if (g == slot.generations.end() || slot.standIn) + continue; + const auto i = findStandIn(set, g->what); + if (!i || std::ranges::any_of(m_slots, [&](const auto& o) { return o.second.standIn == i; })) + continue; + instance = *i; + slot.standIn = i; + slot.standInSince = now; + } + if (!set.simulate(instance)) + continue; + if (!slot.standIn && !slot.local) { + slot.local = true; + slot.localSince = now; + slot.hostMoved = false; + } + slot.fullUntil = now + m_options.fullKeepMs; + // The host had knocked its prop over by then (whatever knocked it + // here, and when): the samples run again meet the piece, not the + // prop standing. + set.forgetGhostOf(instance); + out.push_back({instance, p.body}); + slots.push_back(p.slot); + } + // What pushed each hardest, now that each has its body here: the host's + // slot's instance here (shown or standing in for it), the city, this + // machine's car; something else matches no collider here. + static const char kSomethingElse = 0; + const auto keyOfSlot = [&](std::uint8_t k) -> const void* { + const auto s = m_slots.find(k); + if (s == m_slots.end()) + return &kSomethingElse; + const std::size_t i = s->second.standIn ? *s->second.standIn : set.mirror(k); + const phys::Body* b = set.body(i); + return b ? b->collider.key() : &kSomethingElse; + }; + const phys::World* world = set.world(); + for (FullPiece& f : out) { + using net::PropPusher; + switch (f.state.pusher) { + case PropPusher::None: f.pusher = nullptr; break; + case PropPusher::City: f.pusher = world ? world->cityKey() : &kSomethingElse; break; + case PropPusher::Car: f.pusher = car ? car->collider.key() : &kSomethingElse; break; + case PropPusher::Piece: f.pusher = keyOfSlot(f.state.pusherSlot); break; + case PropPusher::StaticA: f.pusher = world ? world->staticKey(false) : &kSomethingElse; break; + case PropPusher::StaticB: f.pusher = world ? world->staticKey(true) : &kSomethingElse; break; + case PropPusher::Other: f.pusher = &kSomethingElse; break; + } + } + if (const auto k = it->second.carPusher) + for (std::size_t n = 0; n < out.size(); ++n) + out[n].pushedCar = slots[n] == *k; + return out; +} + +void PropClient::forgetFull(std::uint32_t time) { m_full.erase(m_full.begin(), m_full.upper_bound(time)); } + void PropClient::updateDelay(double now) { while (!m_lateness.empty() && m_lateness.front().first < now - 3000.0) m_lateness.pop_front(); @@ -674,8 +873,17 @@ void PropClient::update(BangerSet& set, double now, const CarPartResolver& carPa slot.local = false; slot.blendFrom.reset(); slot.standIn.reset(); + slot.shownGeneration.reset(); + set.setDriven(mirror, false); continue; } + slot.shownGeneration = g->generation; + // The host sends its state in full (net::PropFull): simulated here + // from it, however long, meeting the pieces simulated here. + const bool full = slot.fullUntil > now; + set.setDriven(mirror, full); + if (slot.standIn) + set.setDriven(*slot.standIn, full); net::VehicleSnapshot s, sd; if (g->buffer.sample(render, s) == net::SnapshotBuffer::Result::Empty) { set.hideMirror(index); @@ -701,7 +909,7 @@ void PropClient::update(BangerSet& set, double now, const CarPartResolver& carPa if (slot.standIn) { standIns.insert(*slot.standIn); const bool resting = !set.body(*slot.standIn); - if ((!resting || !hostResting) && now - slot.standInSince < m_options.handoverMs) { + if ((!resting || !hostResting) && (full || now - slot.standInSince < m_options.handoverMs)) { set.hideMirror(index); continue; } @@ -717,7 +925,7 @@ void PropClient::update(BangerSet& set, double now, const CarPartResolver& carPa // left where it stopped until the host has pushed it too and it rests // there (or the prediction's time is up). const auto& mine = set.instances()[mirror]; - if (mine.active >= 0 && slot.local && now - slot.localSince >= m_options.handoverMs) { + if (mine.active >= 0 && slot.local && !full && now - slot.localSince >= m_options.handoverMs) { // Still moving here after that long (creeping down a hill, which // phSleep may never stop): back to the host's. set.releaseMirror(index); @@ -753,6 +961,11 @@ void PropClient::update(BangerSet& set, double now, const CarPartResolver& carPa } Mat34 matrix = matrixOf(s); Mat34 draw = matrixOf(sd); + // At rest, the host's exact frame (net::PropFull) rather than the + // message's quantized one, where they agree. + if (hostResting && slot.exactRest && slot.exactRest->first == g->generation && + slot.exactRest->second.m3.dist2(matrix.m3) < 0.05f * 0.05f) + matrix = draw = slot.exactRest->second; if (slot.blendFrom) { const double t = (now - slot.blendStart) / m_options.blendMs; if (t >= 1.0) { diff --git a/src/game/net/PropSync.h b/src/game/net/PropSync.h index 772577c..c0ce96a 100644 --- a/src/game/net/PropSync.h +++ b/src/game/net/PropSync.h @@ -120,12 +120,31 @@ class PropHost { std::optional build(const bangers::BangerSet& set, std::uint32_t time, std::uint64_t nowMs, std::uint32_t catalog); // A client that left. - void forget(std::uint8_t id) { m_viewers.erase(id); } + void forget(std::uint8_t id) { m_viewers.erase(id); m_full.erase(id); } + + // The pieces round client `id`'s car (at `car`) in full at session time + // `time`, the time of the CarStates sent it this frame (net::PropFull), + // at most every kFullIntervalMs: the moving ones within kFullRadius, the + // nearest that fit in kMaxPropFullMessages messages, in the order the + // world runs them; the resting ones there with their exact frame, sent + // three times after they came to rest or near. Nothing when there is + // none to send. `carBody`: that car (whether it or a piece pushed the + // other hardest: net::PropPusher). + std::vector buildFull(const bangers::BangerSet& set, std::uint8_t id, + std::uint32_t time, const Vec3& car, + const phys::Body* carBody = nullptr); + static constexpr float kFullRadius = 10.0f; // m: a piece's centre to the car's (inferred) + // About 20 a second, every third of 60 CarStates: between them the + // client's car, run again only when its own state differs, meets the + // pieces as it predicted them, which the last ones made the host's + // (measured: as few corrections as with every state, a third of the bytes). + static constexpr std::uint32_t kFullIntervalMs = 45; struct Stats { std::uint64_t messages = 0, slots = 0, fullStates = 0, bytes = 0, knocks = 0; std::uint64_t nearStates = 0; // of fullStates, in the client's area std::uint64_t deferred = 0; // states due that the datagram had no room for + std::uint64_t fullMessages = 0, fullPieces = 0; // net::PropFull }; Stats& stats() { return m_stats; } @@ -144,10 +163,20 @@ class PropHost { bool sentAny = false; std::uint32_t sequence = 0; }; + struct RestSent { + std::uint32_t generation = 0; + Mat34 matrix; + int sent = 0; + }; Options m_options; std::vector m_pending; std::uint32_t m_pendingTime = 0; std::map m_viewers; + struct FullClient { + std::map rest; // by slot + std::optional sentAt; + }; + std::map m_full; // per client Stats m_stats; }; @@ -187,6 +216,9 @@ class PropClient { // it differently, or reused its slot) disappears once at rest this // long after it was made. double orphanMs = 2000.0; + // A slot whose state the host sends in full stays simulated here + // this long after the last one (inferred: a few CarStates). + double fullKeepMs = 250.0; }; // `catalog`: this machine's propCatalog. @@ -198,6 +230,27 @@ class PropClient { void receive(const net::PropStateMsg& msg, double arrival); // A knocks event from the host. void receiveKnocks(const net::PropKnocksEvent& event); + // The host's pieces round this machine's car in full (net::PropFull). + void receiveFull(const net::PropFullMsg& msg); + // The moving ones of the CarStates at `time` (this machine's car's + // acknowledgement): which instance of `set` stands for each (the host's + // ring slot shown here, or the piece this machine knocked that stands in + // for it) and the host's state of it, for the car's samples run again + // (game::CarPrediction::Companion); the slots stay simulated here while + // they come (fullKeepMs). `now`: this frame's session time. `car`: this + // machine's car: `pusher` is the key of what pushed the piece hardest + // here (BangerSet::setBodyState), `pushedCar` whether the piece pushed + // the car hardest (its collider's lastMaxPusher is then the piece's). + struct FullPiece { + std::size_t instance = 0; + net::PropBodyState state; + const void* pusher = nullptr; + bool pushedCar = false; + }; + std::vector fullPieces(bangers::BangerSet& set, std::uint32_t time, double now, + const phys::Body* car = nullptr); + // Those of the CarStates up to `time`, used. + void forgetFull(std::uint32_t time); // Which player's car a knock's toucher is (-1: none, a piece). using CarOfToucher = std::function; // The placed props this machine's cars broke loose (its own, player @@ -271,6 +324,10 @@ class PropClient { double blendStart = 0.0; std::optional standIn; // the piece simulated here shown instead double standInSince = 0.0; + double fullUntil = -1.0; // net::PropFull: simulated here from the host's states until then + std::optional shownGeneration; // the generation shown + // The host's exact frame of its generation at rest (net::PropFull). + std::optional> exactRest; }; struct Prediction { double at = 0.0; @@ -314,6 +371,11 @@ class PropClient { std::deque m_knocks; // the host's, waiting for their time std::set m_hostBroken; std::map m_predicted; + struct FullAt { + std::optional carPusher; + std::vector> pieces; // by message part + }; + std::map m_full; // by the CarStates' time std::deque m_confirmLags; // the host's knock time less this machine's, the last ones std::map m_pieces; // this machine's ring slots, by slot std::vector> m_applied; diff --git a/src/net/PropState.h b/src/net/PropState.h index 65494e6..67cf51a 100644 --- a/src/net/PropState.h +++ b/src/net/PropState.h @@ -33,10 +33,24 @@ // that broke loose and when. Every machine thus knows every // knocked prop of the race, wherever it is; a machine that // reports the race loaded gets every knock so far at once. +// PropFullMsg host -> one client, unreliable (protocol 18): the pieces +// round its car in full at the physics sample of the +// CarStates the host sends it in the same frame, about 20 +// times a second, in up to kMaxPropFullMessages datagrams +// (moving: their bodies, as net::TrafficFull's knocked +// cars, with the bound where the sample's collisions saw +// it and what pushed them hardest; at rest: their exact +// frame, three times). The client simulates the moving +// ones with its car from those states when its car runs +// again, so that its car pushes them as the host's +// simulation of it does, exactly. +#include "net/PlayerCars.h" #include "net/Protocol.h" +#include #include +#include #include namespace mm2::net { @@ -231,4 +245,182 @@ bool serialize(S& s, PropKnocksEvent& e) { return s.ok(); } +// What pushed a piece hardest in the host's sample (phColliderBase's last +// max pusher, which CopyLastMatrix reads: the next sweep against that +// collider starts where its push left the piece). +enum class PropPusher : std::uint8_t { + None, // nothing + City, // the city's collider + Car, // the car of the client the message is for + Piece, // the piece in ring slot `pusherSlot` + StaticA, // the temporary colliders phys::World lends static instances + StaticB, + Other, // something else (no collider of the client's) +}; + +// A piece's body (dgBangerActive): its rigid body, what a sample hands the +// next, its phSleep and its age. +struct PropBodyState { + Mat34 matrix; // the frame at the CG + Vec3 linearMomentum, angularMomentum, linearVelocity, angularVelocity; + Vec3 force, torque, lastPush; + bool contact = false; // the impulses and pushes below are not all zero + Vec3 linearImpulse, angularImpulse, linearPush, turnForce, framePush; + std::int32_t sleepState = 1; // phys::Sleep::State + std::int32_t stillUpdates = 0, dormantUpdates = 0; + float age = 0.0f; + PropPusher pusher = PropPusher::None; + std::uint8_t pusherSlot = 0; + // Its bound where the sample's collisions saw it, when the push moved + // the body on (as net::OwnCarState's); otherwise it follows the body. + bool hasBound = false; + Mat34 bound; +}; + +struct PropFullPiece { + std::uint8_t slot = 0; + std::uint8_t generation = 0; // modulo kPropGenerations + // Moving: its body; else at rest at `body.matrix` (the rest unused). + bool moving = true; + PropBodyState body; +}; + +inline constexpr std::size_t kMaxPropFullPieces = 8; // per message +inline constexpr std::size_t kPropFullBytes = 1200; // a message's size at most +inline constexpr std::size_t kMaxPropFullMessages = 3; // per state + +struct PropFullMsg { + static constexpr MsgType kType = MsgType::PropFull; + std::uint32_t time = 0; // session ms of the host's physics sample (its CarStates' time) + // Which of the state's messages (0 .. kMaxPropFullMessages - 1): the + // pieces come in the host's world's order, the first message's first. + std::uint8_t part = 0; + // The piece (ring slot) that pushed the car hardest in that sample, when + // one did (the car's state names no piece: net::kPusherOther). + std::optional carPusher; + std::vector pieces; +}; + +template +bool serialize(S& s, PropBodyState& b, bool moving) { + s.vec3(b.matrix.m0); + s.vec3(b.matrix.m1); + s.vec3(b.matrix.m2); + s.vec3(b.matrix.m3); + if (moving) { + s.vec3(b.linearMomentum); + s.vec3(b.angularMomentum); + s.vec3(b.linearVelocity); + s.vec3(b.angularVelocity); + // (Each sample's integration clears the forces, and a body at rest + // has had no push: mostly zero, then left out.) + const Vec3 zero{}; + bool forces = !(b.force == zero && b.torque == zero); + s.boolean(forces); + if (forces) { + s.vec3(b.force); + s.vec3(b.torque); + } else if constexpr (S::kReading) { + b.force = b.torque = {}; + } + bool pushed = !(b.lastPush == zero); + s.boolean(pushed); + if (pushed) + s.vec3(b.lastPush); + else if constexpr (S::kReading) + b.lastPush = {}; + s.boolean(b.contact); + if (b.contact) { + s.vec3(b.linearImpulse); + s.vec3(b.angularImpulse); + s.vec3(b.linearPush); + s.vec3(b.turnForce); + s.vec3(b.framePush); + } else if constexpr (S::kReading) { + b.linearImpulse = b.angularImpulse = b.linearPush = b.turnForce = b.framePush = {}; + } + s.ranged(b.sleepState, 0, 2); + s.ranged(b.stillUpdates, 0, 1023); + s.ranged(b.dormantUpdates, 0, 1023); + s.f32(b.age); + auto pusher = static_cast(b.pusher); + s.ranged(pusher, 0, static_cast(PropPusher::Other)); + b.pusher = static_cast(pusher); + if (b.pusher == PropPusher::Piece) + s.u8(b.pusherSlot); + else if constexpr (S::kReading) + b.pusherSlot = 0; + s.boolean(b.hasBound); + if (b.hasBound) { + // Mostly the push only moved it: then only its position. + bool turned = + !(b.bound.m0 == b.matrix.m0 && b.bound.m1 == b.matrix.m1 && b.bound.m2 == b.matrix.m2); + s.boolean(turned); + if (turned) { + s.vec3(b.bound.m0); + s.vec3(b.bound.m1); + s.vec3(b.bound.m2); + } else if constexpr (S::kReading) { + b.bound.m0 = b.matrix.m0; + b.bound.m1 = b.matrix.m1; + b.bound.m2 = b.matrix.m2; + } + s.vec3(b.bound.m3); + } else if constexpr (S::kReading) { + b.bound = {}; + } + } else if constexpr (S::kReading) { + b = PropBodyState{b.matrix}; + } + if constexpr (S::kReading) { + // Untrusted: every number finite (f32 refuses the rest) and in range. + const auto ok = [](const Vec3& v, float r) { + return std::abs(v.x) <= r && std::abs(v.y) <= r && std::abs(v.z) <= r; + }; + const Mat34& m = b.matrix; + const Mat34& n = b.bound; + bool good = ok(m.m0, 2.0f) && ok(m.m1, 2.0f) && ok(m.m2, 2.0f) && ok(m.m3, kOwnStateMaxCoordinate) && + ok(n.m0, 2.0f) && ok(n.m1, 2.0f) && ok(n.m2, 2.0f) && ok(n.m3, kOwnStateMaxCoordinate) && + std::abs(b.age) <= 1.0e6f; + for (const Vec3* v : {&b.linearMomentum, &b.angularMomentum, &b.linearVelocity, &b.angularVelocity, + &b.force, &b.torque, &b.lastPush, &b.linearImpulse, &b.angularImpulse, + &b.linearPush, &b.turnForce, &b.framePush}) + good = good && ok(*v, kOwnStateMaxValue); + if (!good) + return s.fail(); + } + return s.ok(); +} + +template +bool serialize(S& s, PropFullMsg& m) { + s.u32(m.time); + std::int32_t part = m.part; + s.ranged(part, 0, static_cast(kMaxPropFullMessages) - 1); + m.part = static_cast(part); + bool pushed = m.carPusher.has_value(); + s.boolean(pushed); + std::uint8_t carPusher = m.carPusher.value_or(0); + if (pushed) + s.u8(carPusher); + if constexpr (S::kReading) + m.carPusher = pushed ? std::optional(carPusher) : std::nullopt; + auto count = static_cast(std::min(m.pieces.size(), kMaxPropFullPieces)); + s.ranged(count, 0, static_cast(kMaxPropFullPieces)); + if constexpr (S::kReading) + m.pieces.resize(static_cast(count)); + for (std::int32_t i = 0; i < count; ++i) { + PropFullPiece& p = m.pieces[static_cast(i)]; + std::int32_t slot = p.slot, generation = p.generation; + s.ranged(slot, 0, static_cast(kMaxPropSlots) - 1); + s.ranged(generation, 0, static_cast(kPropGenerations) - 1); + p.slot = static_cast(slot); + p.generation = static_cast(generation); + s.boolean(p.moving); + if (!serialize(s, p.body, p.moving)) + return s.fail(); + } + return s.ok(); +} + } // namespace mm2::net diff --git a/src/net/Protocol.h b/src/net/Protocol.h index 64fe824..d3ca1b8 100644 --- a/src/net/Protocol.h +++ b/src/net/Protocol.h @@ -58,7 +58,9 @@ inline constexpr std::uint16_t kProtocolMagic = 0x4D32; // "M2" // a near client car's inputs the host holds for its next samples // (NearCarState::upcoming), and a contact's bound and hardest pusher in a // car's full state. (11, 13 and 15 on its branch.) -inline constexpr std::uint16_t kProtocolVersion = 17; +// 18: the pieces round a client's car in full with its CarStates +// (net/PropState.h: PropFull), which it simulates with its car. +inline constexpr std::uint16_t kProtocolVersion = 18; inline constexpr std::uint32_t kConnectData = (std::uint32_t{kProtocolMagic} << 16) | kProtocolVersion; // ENet channels. @@ -126,7 +128,8 @@ enum class MsgType : std::uint8_t { PropState, // net/PropState.h TrafficFull, // net/TrafficFull.h RulesState, // net/RulesState.h (protocol 14) - Last = RulesState, + PropFull, // net/PropState.h (protocol 18) + Last = PropFull, }; // Sent as ENet disconnect data and in Reject/PlayerLeft messages. diff --git a/src/net/Session.cpp b/src/net/Session.cpp index dcd6ca9..6bdfdb6 100644 --- a/src/net/Session.cpp +++ b/src/net/Session.cpp @@ -1165,6 +1165,16 @@ void Session::clientHandle(MsgType type, std::span data) { m_trafficFull.push_back(std::move(m)); return; } + case MsgType::PropFull: { + // As the cars' states: only during a race. + PropFullMsg m; + if (m_phase == SessionPhase::Lobby || !decodeMessage(data, m)) + return; + if (m_propFull.size() >= kMaxQueuedPropFull) + m_propFull.erase(m_propFull.begin()); + m_propFull.push_back(std::move(m)); + return; + } case MsgType::PropState: { // As the cars' states: only during a race. PropStateMsg m; @@ -1413,6 +1423,18 @@ std::size_t Session::sendTrafficFull(std::uint8_t playerId, const TrafficFullMsg return m_transport->send(r->peer, Channel::State, packet) ? packet.size() : 0; } +std::size_t Session::sendPropFull(std::uint8_t playerId, const PropFullMsg& msg) { + if (m_role != Role::Host || !m_transport || m_state != State::Active || m_phase == SessionPhase::Lobby || + playerId == m_localId) + return 0; + Remote* r = remoteForPlayer(playerId); + if (!r) + return 0; + const auto packet = encodeMessage(msg); + // Unsequenced: each message stands alone (the client keys it by time). + return m_transport->send(r->peer, Channel::State, packet) ? packet.size() : 0; +} + std::size_t Session::sendPropState(std::uint8_t playerId, const PropStateMsg& msg) { if (m_role != Role::Host || !m_transport || m_state != State::Active || m_phase == SessionPhase::Lobby || playerId == m_localId) diff --git a/src/net/Session.h b/src/net/Session.h index 6b58023..158d91d 100644 --- a/src/net/Session.h +++ b/src/net/Session.h @@ -310,6 +310,12 @@ class Session { // most kMaxQueuedPropStates; older ones are dropped). std::vector takePropStates() { return std::exchange(m_propStates, {}); } static constexpr std::size_t kMaxQueuedPropStates = 16; + // Host: sends one player the pieces round its car in full (unreliable, + // unsequenced). Client: those received since the last call (at most + // kMaxQueuedPropFull; older ones are dropped). + std::size_t sendPropFull(std::uint8_t playerId, const PropFullMsg& msg); + std::vector takePropFull() { return std::exchange(m_propFull, {}); } + static constexpr std::size_t kMaxQueuedPropFull = 32; // --- The host's rules state (OpenMM2, net/RulesState.h, protocol 14) --- // Host: sends one player its race's rules state on the unreliable State @@ -430,6 +436,7 @@ class Session { std::vector m_playerInputs; // host: the clients' inputs, not yet taken std::vector m_ownCarStates; // client: the host's word on its car, not yet taken std::vector m_propStates; // client: received, not yet taken + std::vector m_propFull; // client: received, not yet taken std::vector m_trafficFull; // client: received, not yet taken std::vector m_rulesStates; // client: received, not yet taken }; diff --git a/src/phys/World.cpp b/src/phys/World.cpp index 4642488..006531f 100644 --- a/src/phys/World.cpp +++ b/src/phys/World.cpp @@ -238,6 +238,16 @@ bool World::contains(const Body* body) const { return std::ranges::any_of(m_movers, [&](const Mover& m) { return m.body == body && !m.removed; }); } +std::size_t World::order(const Body* body) const { + std::size_t n = 0; + for (const Mover& m : m_movers) { + if (m.body == body && !m.removed) + return n; + n += m.removed ? 0 : 1; + } + return n; +} + bool World::probe(const Vec3& a, const Vec3& b, RayHit& hit) const { return m_static.raycast(a, b, hit); } diff --git a/src/phys/World.h b/src/phys/World.h index de1b80f..3ce07c6 100644 --- a/src/phys/World.h +++ b/src/phys/World.h @@ -198,6 +198,14 @@ class World final : public GroundQuery { void add(Body* body); void remove(Body* body); bool contains(const Body* body) const; + // OpenMM2: the body's place in the update order (the movers' count when + // it is none of them). + std::size_t order(const Body* body) const; + // OpenMM2 (network games): the keys (Collider::key) of the colliders the + // collisions lend the city and static instances, which a body's hardest + // pusher may be. + const void* cityKey() const { return m_levelCollider.key(); } + const void* staticKey(bool b) const { return b ? m_tempB.key() : m_tempA.key(); } // dgPhysManager::DeclareMover of an instance without a body (an ambient // car off its rail: aiGoalAvoidPlayer and aiGoalRegainRail declare its // aiVehicleInstance (2, 0x0a) each frame, aiGoalCollision a wreck's diff --git a/tests/game/test_prop_push.cpp b/tests/game/test_prop_push.cpp new file mode 100644 index 0000000..cec4586 --- /dev/null +++ b/tests/game/test_prop_push.cpp @@ -0,0 +1,364 @@ +// A client's car pushing knocked-over props (OpenMM2, net::PropFull): a host +// and a client in one process, each with its own world, props and the +// client's car; the client predicts its car from its inputs and corrects it +// on the host's states (as test_player_cars.cpp does), and its props are the +// host's (game::PropSync). With the pieces round its car in full, the client +// runs them with its car when its samples run again, as the host runs them +// with the host's simulation of its car. +#include "TestData.h" +#include "game/PlayerVehicle.h" +#include "game/bangers/BangerData.h" +#include "game/bangers/BangerSet.h" +#include "game/bangers/PropPlacement.h" +#include "game/net/PlayerCars.h" +#include "game/net/PropSync.h" +#include "net/PlayerCars.h" +#include "net/PropState.h" +#include "phys/Level.h" +#include "phys/World.h" +#include "vfs/DirectoryFs.h" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace mm2; +using namespace mm2::game; +using bangers::BangerSet; +using bangers::PlacedProp; + +namespace { + +constexpr double kStepMs = 1000.0 / 60.0; + +// A temporary game folder with a light prop's banger data (as test_prop_sync.cpp). +struct PushBangers { + std::filesystem::path dir = std::filesystem::temp_directory_path() / + (std::string("openmm2_proppush_") + + ::testing::UnitTest::GetInstance()->current_test_info()->name()); + vfs::Vfs vfs; + PushBangers() { + std::filesystem::remove_all(dir); + std::filesystem::create_directories(dir / "tune" / "banger"); + std::ofstream f(dir / "tune" / "banger" / "light.dgbangerdata"); + f << "type: a\ndgBangerData {\n Size 0.5 2 0.5\n CG 0 1 0\n Mass 80\n Elasticity 0.5\n" + " Friction 0.9\n ImpulseLimit2 10000\n NumParts 0\n BirthRule {\n InitialBlast 0\n }\n" + " TexNumber 0\n CollisionPrim 1\n}\n"; + f.close(); + vfs.mount(std::make_shared(dir)); + } + ~PushBangers() { + std::error_code ec; + std::filesystem::remove_all(dir, ec); + } +}; + +// One room with a floor whose objects are the set's props. +class PushLevel final : public phys::Level { +public: + explicit PushLevel(const InstanceSource& source) : m_source(source) {} + int findRoom(const Vec3&, int) const override { return 1; } + int touchedNeighbors(int*, int, int, const Vec3&, float) const override { return 0; } + void collect(const int*, int, const Vec3&, float, phys::LevelBound& out) const override { + out.clear(); + const Vec3 floor[4] = {{-500, 0, -500}, {-500, 0, 500}, {500, 0, 500}, {500, 0, -500}}; + out.addPolygon(floor, 4, {0, 1, 0}, 0); + } + void instances(int room, std::vector& out) const override { + m_source.instancesIn(room, out); + } + +private: + const InstanceSource& m_source; +}; + +// One machine: its world, its props (a pile of kRows rows of three, which +// the car's pushing knocks over one by one, and the pieces of one row the +// next) and the client's car. +constexpr int kRows = 6; + +struct PushMachine { + phys::World world; + BangerSet set; + PushLevel level{set}; + std::unique_ptr car; + NetCarDriver driver; + PushMachine(const vfs::Vfs& game, const bangers::BangerDataLibrary& lib) : set(lib) { + world.setLevel(&level); + set.setWorld(&world); + set.recordKnocks(true); + std::vector pile; + for (int row = 0; row < kRows; ++row) + for (int col = -1; col <= 1; ++col) + pile.push_back({"light", + Mat34::translation({static_cast(col) * 0.9f, 0.0f, + -8.0f - static_cast(row) * 1.6f}), + 1, PlacedProp::Source::Instance, true}); + set.add(pile); + std::string error; + car = SimVehicle::loadPlayer(game, "vpbug", &error, false); + EXPECT_TRUE(car) << error; + car->sim().options.player = true; + car->sim().setPolygonalBound(true); + car->sim().ownRandom = true; + car->sim().randomState = 1; + car->addTo(world); + driver.attach(*car); + car->setResetPos(Mat34::translation({0.0f, 0.6f, 0.0f})); + car->reset(); + } + ~PushMachine() { set.setWorld(nullptr); } +}; + +// A body round the car as a sample met it: the samples run again meet it +// there (as the race screen's bodyPoses and placeBodies). +struct BodyPose { + phys::Body* body = nullptr; + Mat34 ics, bound; + Vec3 velocity, spin, kinematicVelocity, kinematicSpin; +}; + +std::vector bodyPoses(PushMachine& m) { + std::vector near; + m.world.bodiesNear(m.car->sim().body.ics.matrix.m3, 40.0f, near); + std::vector out; + for (phys::Body* b : near) + if (b != &m.car->sim().body) + out.push_back({b, b->ics.matrix, b->boundMatrix, b->ics.linearVelocity, b->ics.angularVelocity, + b->kinematicVelocity, b->kinematicSpin}); + return out; +} + +// The bodies not run again where `poses` has them; the others out of the +// way (1 km below). +void placeBodies(PushMachine& m, const std::vector& poses, const std::vector& around, + const std::vector& run) { + for (const BodyPose& p : around) { + if (std::ranges::find(run, p.body) != run.end() || !m.world.contains(p.body)) + continue; + const auto it = std::ranges::find(poses, p.body, &BodyPose::body); + const BodyPose& q = it != poses.end() ? *it : p; + phys::Body& b = *p.body; + b.ics.matrix = q.ics; + b.boundMatrix = q.bound; + b.ics.linearVelocity = q.velocity; + b.ics.angularVelocity = q.spin; + b.kinematicVelocity = q.kinematicVelocity; + b.kinematicSpin = q.kinematicSpin; + if (it == poses.end()) { + b.ics.matrix.m3.y -= 1000.0f; + b.boundMatrix.m3.y -= 1000.0f; + b.ics.linearVelocity = b.ics.angularVelocity = b.kinematicVelocity = b.kinematicSpin = {}; + } + } +} + +template +struct InFlight { + std::uint32_t due = 0; + M msg; +}; + +// The client's input: gently into the pile, then on through it, pushing. +net::CarInputFrame pushInput(std::uint32_t seq) { + phys::PedalInput p; + p.accelerator = seq < 60 ? 0.6f : 0.35f; + p.steering = seq > 240 && seq < 300 ? 0.2f : 0.0f; + net::CarInputFrame f = inputFrame(p); + f.flags = net::kInputAutomatic; + return f; +} + +struct PushOutcome { + int corrections = 0; + float largest = 0.0f; + std::size_t knocked = 0; + std::uint64_t fullPieces = 0; +}; + +// The client drives into the pile for `samples` samples; messages take +// `latency` samples each way; the host sends its states every sample (60 a +// second, as the race does), with the pieces round the car in full when +// `full` (PropHost::buildFull: about 20 a second). +PushOutcome push(const vfs::Vfs& game, bool full, int samples, std::uint32_t latency) { + PushBangers files; + bangers::BangerDataLibrary lib(files.vfs); + PushMachine host(game, lib), client(game, lib); + const phys::Instance* clientCar = &client.car->sim().body; + client.set.setReplica([clientCar](const phys::Instance& other) { return &other == clientCar; }); + PropHost propHost; + PropClient propClient(propCatalog(client.set)); + CarPrediction prediction; + HostInputQueue queue; + bool placed = false; + struct Down { + std::uint32_t ack = 0; + net::OwnCarState own; + std::vector pieces; + std::uint32_t time = 0; + }; + struct Props { + std::optional state; + std::optional knocks; + }; + std::deque> up; + std::deque> down; + std::deque> props; + PushOutcome run; + std::deque>> history; // the client's, by sample + const float dt = phys::kFixedSampleStep; + const std::uint32_t catalog = propCatalog(host.set); + for (std::uint32_t t = 1; t <= static_cast(samples); ++t) { + const double now = 10000.0 + static_cast(t) * kStepMs; + const auto time = static_cast(now); + // Client: the host's props, then its states, then its sample. + while (!props.empty() && props.front().due <= t) { + if (props.front().msg.state) + propClient.receive(*props.front().msg.state, now); + if (props.front().msg.knocks) + propClient.receiveKnocks(*props.front().msg.knocks); + props.pop_front(); + } + propClient.update(client.set, now, nullptr, {}, client.car->sim().body.ics.matrix.m3); + while (!down.empty() && down.front().due <= t) { + Down& d = down.front().msg; + // As NetProps::fullCompanions. + std::vector companions; + std::vector replayed; + phys::Body& car = client.car->sim().body; + for (const auto& m : d.pieces) + propClient.receiveFull(m); + for (const auto& piece : propClient.fullPieces(client.set, d.time, now, &car)) { + phys::Body* body = client.set.simulate(piece.instance); + if (!body) + continue; + replayed.push_back(body); + CarPrediction::Companion c; + c.body = body; + c.rebase = [&client, &car, piece, body] { + client.set.setBodyState(piece.instance, piece.state, piece.pusher); + if (piece.pushedCar) + car.collider.lastMaxPusher = body->collider.key(); + }; + c.position = piece.state.matrix.m3; + c.velocity = piece.state.linearVelocity; + companions.push_back(std::move(c)); + } + // The bodies the samples run again may meet: those a sample met + // and those round the car now (as the race screen's). + std::vector around = bodyPoses(client); + for (const auto& [seq, poses] : history) + for (const BodyPose& p : poses) + if (seq > d.ack && std::ranges::find(around, p.body, &BodyPose::body) == around.end()) + around.push_back(p); + const auto beforeEach = [&](std::uint32_t seq) { + for (const auto& [s, poses] : history) + if (s == seq) + placeBodies(client, poses, around, replayed); + }; + const auto r = prediction.acknowledge(*client.car, client.driver, client.world, d.ack, d.own, {}, + beforeEach, companions); + placeBodies(client, around, around, replayed); + propClient.forgetFull(d.time); + if (r.corrected) { + ++run.corrections; + run.largest = std::max(run.largest, r.moved.mag()); + } + down.pop_front(); + } + if (prediction.nextSeq() == 1) + prediction.command(*client.car, {0, net::CarCommandKind::ResetTo, client.car->sim().resetPos(), + client.car->sim().resetRotation}); + history.emplace_back(prediction.nextSeq(), bodyPoses(client)); + while (history.size() > 240) + history.pop_front(); + prediction.beginSample(*client.car, client.driver, pushInput(prediction.nextSeq())); + client.world.step(dt); + client.set.update(dt); + propClient.predicted(client.set.takeKnocks(), now, 1, + [clientCar](const phys::Instance* by) { return by == clientCar ? 1 : -1; }); + prediction.endSample(*client.car, client.driver); + if (const auto m = prediction.message()) + up.push_back({t + latency, *m}); + // Host: the client's inputs, its sample, its props' messages and the + // client's state. + while (!up.empty() && up.front().due <= t) { + queue.receive(up.front().msg); + up.pop_front(); + } + if (!placed && queue.ready()) { + if (const auto c = queue.placement()) { + NetCarDriver::command(*host.car, *c); + placed = true; + } + } + if (placed) { + if (auto next = queue.next()) { + for (const auto& c : next->commands) + NetCarDriver::command(*host.car, c); + host.driver.apply(*host.car, next->frame); + } + } + host.world.step(dt); + host.set.update(dt); + propHost.knocked(host.set.takeKnocks(), time); + for (auto& e : propHost.takeKnockEvents()) { + Props p; + net::PropKnocksEvent k; + EXPECT_TRUE(net::decodePayload(e, k)); + p.knocks = k; + props.push_back({t + latency, std::move(p)}); + } + const Vec3 at = host.car->sim().body.ics.matrix.m3; + const PropHost::Viewer viewer[] = {{1, at}}; + const auto nowMs = static_cast(now); + for (auto& [id, msg] : propHost.build(host.set, time, nowMs, catalog, viewer)) { + Props p; + p.state = std::move(msg); + props.push_back({t + latency, std::move(p)}); + } + if (placed && queue.lastApplied() != 0) { + Down d; + d.ack = queue.lastApplied(); + d.own = ownCarState(*host.car, 0); + d.time = time; + if (full) { + d.pieces = propHost.buildFull(host.set, 1, time, at, &host.car->sim().body); + for (const auto& m : d.pieces) + run.fullPieces += m.pieces.size(); + } + down.push_back({t + latency, std::move(d)}); + } + } + for (std::size_t i = 0; i < placedProps(host.set); ++i) + run.knocked += host.set.standing(i) ? 0 : 1; + return run; +} + +} // namespace + +// A client's car driving on through a pile of props, pushing their pieces +// into the rest: with the host's pieces round it in full, run with it when +// its samples run again, it is (all but) never corrected; without, nearly +// every state is (it meets the host's pieces where they are shown, a +// playout delay in the past, or as its own simulation has them). +TEST(PropPush, ACarPushingPiecesIsPredictedWithTheirFullStates) { + MM2_REQUIRE_GAME_DATA(); + const PushOutcome with = push(*test::gameData(), true, 8 * 60, 6); + const PushOutcome without = push(*test::gameData(), false, 8 * 60, 6); + std::printf("[ measure ] pushing a pile: with the pieces in full %d corrections (largest %.3f m, %zu " + "props knocked, %llu pieces sent); without %d (largest %.3f m, %zu knocked)\n", + with.corrections, static_cast(with.largest), with.knocked, + static_cast(with.fullPieces), without.corrections, + static_cast(without.largest), without.knocked); + EXPECT_GT(with.knocked, 12u); + EXPECT_GT(with.fullPieces, 0u); + EXPECT_LT(with.corrections * 20, without.corrections); +} diff --git a/tests/game/test_prop_sync.cpp b/tests/game/test_prop_sync.cpp index c0a071e..b98026f 100644 --- a/tests/game/test_prop_sync.cpp +++ b/tests/game/test_prop_sync.cpp @@ -1023,6 +1023,70 @@ TEST(PropSync, ANearCarsEarlyKnockWaitsForTheHost) { r.host.world.remove(©.body); } +// The host sends the pieces round a client's car in full (net::PropFull): +// a moving one with its body as the host's simulation has it, one at rest +// with its exact frame three times, none far from the car; the client puts +// the host's piece shown there to that state (its car's samples run again +// with it, game::CarPrediction::Companion). +TEST(PropSync, PiecesRoundACarComeInFull) { + Race r; + Car host({0, 1, 3.0f}, {0, 0, -10}); // knocks prop 0 on the host + r.host.world.add(&host.body); + r.run(0.5); + r.host.world.remove(&host.body); + ASSERT_FALSE(r.host.set.standing(0)); + ASSERT_FALSE(r.host.set.ring().empty()); + const std::size_t hit = r.host.set.ring()[0]; + ASSERT_TRUE(r.host.set.body(hit)); // flying + const Vec3 piece = r.host.set.instances()[hit].matrix.m3; + const auto time = static_cast(r.now); + EXPECT_TRUE(r.propHost.buildFull(r.host.set, 1, time, piece + Vec3{100, 0, 0}).empty()); // far + const auto sent = r.propHost.buildFull(r.host.set, 1, time, piece + Vec3{2, 0, 0}); + ASSERT_EQ(sent.size(), 1u); + const net::PropFullMsg& full = sent[0]; + ASSERT_EQ(full.pieces.size(), 1u); + const net::PropFullPiece& p = full.pieces[0]; + EXPECT_TRUE(p.moving); + EXPECT_EQ(p.slot, 0); + const phys::Body& body = *r.host.set.body(hit); + EXPECT_EQ(p.body.matrix.m3, body.ics.matrix.m3); + EXPECT_EQ(p.body.linearMomentum, body.ics.linearMomentum); + EXPECT_EQ(p.body.angularVelocity, body.ics.angularVelocity); + // Not again before kFullIntervalMs. + EXPECT_TRUE(r.propHost.buildFull(r.host.set, 1, time + 20, piece + Vec3{2, 0, 0}).empty()); + + // The client: the host's piece is shown there (a playout delay later), + // then put to the host's state. + r.propClient->receiveFull(full); + const auto pieces = r.propClient->fullPieces(r.client.set, time, r.now); + ASSERT_EQ(pieces.size(), 1u); + EXPECT_EQ(pieces[0].instance, r.client.set.mirror(0)); + ASSERT_TRUE(r.client.set.setBodyState(pieces[0].instance, pieces[0].state)); + const phys::Body* mirror = r.client.set.body(pieces[0].instance); + ASSERT_TRUE(mirror); + EXPECT_EQ(mirror->ics.matrix.m3, p.body.matrix.m3); + EXPECT_EQ(mirror->ics.linearMomentum, p.body.linearMomentum); + EXPECT_EQ(mirror->ics.angularMomentum, p.body.angularMomentum); + r.propClient->forgetFull(time); + EXPECT_TRUE(r.propClient->fullPieces(r.client.set, time, r.now).empty()); + + // At rest on the host: its exact frame, three times, then no more. + r.run(6.0); + ASSERT_FALSE(r.host.set.body(hit)); + const Vec3 rest = r.host.set.instances()[hit].matrix.m3; + int restSent = 0; + for (std::uint32_t i = 1; i <= 5; ++i) { + const std::uint32_t later = time + i * PropHost::kFullIntervalMs; + for (const auto& m : r.propHost.buildFull(r.host.set, 1, later, rest)) { + ASSERT_EQ(m.pieces.size(), 1u); + EXPECT_FALSE(m.pieces[0].moving); + EXPECT_EQ(m.pieces[0].body.matrix.m3, rest); + ++restSent; + } + } + EXPECT_EQ(restSent, 3); +} + // A machine that loads the race late (or a prop knocked far from it) gets // every knock so far at once: PropHost::catchUp. TEST(PropSync, LateMachineCatchesUp) { diff --git a/tests/net/test_fuzz.cpp b/tests/net/test_fuzz.cpp index b7183df..aab1734 100644 --- a/tests/net/test_fuzz.cpp +++ b/tests/net/test_fuzz.cpp @@ -233,6 +233,34 @@ std::vector corpus() { knocked.body.stillUpdates = 40; full.cars.push_back(knocked); c.push_back(encodeMessage(full)); + // The pieces round a client's car in full (protocol 18): one moving in + // contact, one at rest. + PropFullMsg pieces; + pieces.time = 6300; + pieces.part = 2; + pieces.carPusher = 41; + PropFullPiece moving; + moving.slot = 41; + moving.generation = 3; + moving.body.matrix = Mat34::rotationY(0.7f); + moving.body.matrix.m3 = {-1153.0f, 110.4f, 57.0f}; + moving.body.linearVelocity = {0.2f, -0.1f, 0.05f}; + moving.body.contact = true; + moving.body.linearPush = {0.0f, 0.002f, 0.0f}; + moving.body.sleepState = 1; + moving.body.age = 3.5f; + moving.body.pusher = PropPusher::Piece; + moving.body.pusherSlot = 42; + moving.body.hasBound = true; + moving.body.bound = moving.body.matrix; + moving.body.bound.m3.y -= 0.002f; + pieces.pieces.push_back(moving); + PropFullPiece resting; + resting.slot = 42; + resting.moving = false; + resting.body.matrix = Mat34::translation({-1150.0f, 110.0f, 60.0f}); + pieces.pieces.push_back(resting); + c.push_back(encodeMessage(pieces)); LanAdvert advert; advert.sessionName = "Fuzz"; @@ -445,6 +473,26 @@ void checkTrafficFull(const TrafficFullMsg& m) { } } +void checkPropFull(const PropFullMsg& m) { + ASSERT_LE(m.pieces.size(), kMaxPropFullPieces); + ASSERT_LT(m.part, kMaxPropFullMessages); + for (const PropFullPiece& p : m.pieces) { + ASSERT_LT(p.generation, kPropGenerations); + ASSERT_LE(static_cast(p.body.pusher), static_cast(PropPusher::Other)); + for (const Mat34* b : {&p.body.matrix, &p.body.bound}) { + for (const Vec3& v : {b->m0, b->m1, b->m2}) + ASSERT_TRUE(std::abs(v.x) <= 2.0f && std::abs(v.y) <= 2.0f && std::abs(v.z) <= 2.0f); + ASSERT_LE(std::abs(b->m3.x), kOwnStateMaxCoordinate); + } + for (const Vec3& v : {p.body.linearMomentum, p.body.angularVelocity, p.body.linearPush}) + ASSERT_TRUE(std::abs(v.x) <= kOwnStateMaxValue && std::abs(v.y) <= kOwnStateMaxValue && + std::abs(v.z) <= kOwnStateMaxValue); + ASSERT_GE(p.body.sleepState, 0); + ASSERT_LE(p.body.sleepState, 2); + ASSERT_TRUE(std::isfinite(p.body.age)); + } +} + void checkPlayer(const PlayerInfo& p) { ASSERT_LE(p.name.size(), kMaxNameLength); ASSERT_LE(p.car.size(), kMaxShortStringLength); @@ -552,6 +600,9 @@ void decodeEverything(std::span b) { if (const auto m = decoded(b)) { checkTrafficFull(*m); } + if (const auto m = decoded(b)) { + checkPropFull(*m); + } std::uint32_t nonce = 0; (void)decodeLanQuery(b, nonce); @@ -834,6 +885,11 @@ TEST(Fuzz, ClientSurvivesMutatedTraffic) { EXPECT_LE(full.size(), Session::kMaxQueuedTrafficFull); for (const auto& m : full) checkTrafficFull(m); + // And the pieces in full. + const auto pieces = client.takePropFull(); + EXPECT_LE(pieces.size(), Session::kMaxQueuedPropFull); + for (const auto& m : pieces) + checkPropFull(m); } // The race start under mutated race messages from a joiner: whatever it diff --git a/tests/net/test_prop_state.cpp b/tests/net/test_prop_state.cpp index f716e3b..5bf8523 100644 --- a/tests/net/test_prop_state.cpp +++ b/tests/net/test_prop_state.cpp @@ -6,6 +6,7 @@ #include #include +#include using namespace mm2; using namespace mm2::net; @@ -154,6 +155,103 @@ TEST(PropState, RefusesSlotsOutOfOrder) { EXPECT_FALSE(decodeMessage(s.writer().take(), back)); } +// The pieces round a client's car in full (protocol 18): a moving piece's +// body round trips exactly; one at rest only its frame; a message of five +// pieces in contact stays one datagram; a non-finite or wild number is +// refused. +TEST(PropState, FullPiecesRoundTripExactly) { + PropFullMsg m; + m.time = 90123; + m.part = 1; + m.carPusher = 202; + for (std::uint8_t k = 0; k < 5; ++k) { + PropFullPiece p; + p.slot = static_cast(200 + k); + p.generation = 7; + p.body.matrix = + Quat::fromAxisAngle(Vec3{0.0f, 1.0f, 0.0f}, 0.3f * k).toMatrix({-1153.25f, 110.5f, 57.125f}); + p.body.linearMomentum = {1.5f, -2.25f, 3.0f}; + p.body.angularMomentum = {0.1f, 0.2f, -0.3f}; + p.body.linearVelocity = {0.03f, -0.045f, 0.06f}; + p.body.angularVelocity = {0.5f, 0.25f, -0.125f}; + p.body.force = {0.0f, -500.0f, 0.0f}; + p.body.contact = true; + p.body.linearImpulse = {7.0f, 0.0f, -1.0f}; + p.body.framePush = {0.001f, 0.0f, 0.002f}; + p.body.sleepState = 2; + p.body.stillUpdates = 14; + p.body.dormantUpdates = 3; + p.body.age = 12.75f; + p.body.pusher = k == 1 ? PropPusher::Piece : k == 2 ? PropPusher::City : PropPusher::None; + p.body.pusherSlot = k == 1 ? 204 : 0; + // The second one's bound where the push left it, the third's turned too. + p.body.hasBound = k == 1 || k == 2; + p.body.bound = p.body.hasBound ? p.body.matrix : Mat34{}; + p.body.bound.m3 = p.body.bound.m3 + Vec3{0.0f, -0.003f, 0.0f}; + if (k == 2) + p.body.bound.m0 = p.body.bound.m0 + Vec3{0.0001f, 0.0f, 0.0f}; + m.pieces.push_back(p); + } + PropFullPiece rest; + rest.slot = 3; + rest.moving = false; + rest.body.matrix = Mat34::translation({10.0f, 2.0f, -30.0f}); + m.pieces.push_back(rest); + const auto bytes = encodeMessage(m); + EXPECT_LE(bytes.size(), kPropFullBytes); + PropFullMsg back; + ASSERT_TRUE(decodeMessage(bytes, back)); + EXPECT_EQ(back.time, m.time); + EXPECT_EQ(back.part, 1); + EXPECT_EQ(back.carPusher, m.carPusher); + ASSERT_EQ(back.pieces.size(), m.pieces.size()); + for (std::size_t i = 0; i < 5; ++i) { + const auto& a = m.pieces[i].body; + const auto& b = back.pieces[i].body; + EXPECT_EQ(back.pieces[i].slot, m.pieces[i].slot); + EXPECT_TRUE(back.pieces[i].moving); + EXPECT_EQ(b.matrix.m0, a.matrix.m0); + EXPECT_EQ(b.matrix.m3, a.matrix.m3); + EXPECT_EQ(b.linearMomentum, a.linearMomentum); + EXPECT_EQ(b.angularVelocity, a.angularVelocity); + EXPECT_EQ(b.linearImpulse, a.linearImpulse); + EXPECT_EQ(b.framePush, a.framePush); + EXPECT_EQ(b.sleepState, 2); + EXPECT_EQ(b.stillUpdates, 14); + EXPECT_EQ(b.age, a.age); + EXPECT_EQ(b.force, a.force); + EXPECT_EQ(b.torque, Vec3{}); + EXPECT_EQ(b.lastPush, Vec3{}); + EXPECT_EQ(b.pusher, a.pusher); + EXPECT_EQ(b.pusherSlot, a.pusherSlot); + EXPECT_EQ(b.hasBound, a.hasBound); + if (a.hasBound) { + EXPECT_EQ(b.bound.m0, a.bound.m0); + EXPECT_EQ(b.bound.m1, a.bound.m1); + EXPECT_EQ(b.bound.m2, a.bound.m2); + EXPECT_EQ(b.bound.m3, a.bound.m3); + } + } + EXPECT_FALSE(back.pieces[5].moving); + EXPECT_EQ(back.pieces[5].body.matrix.m3, rest.body.matrix.m3); + EXPECT_EQ(back.pieces[5].body.linearVelocity, Vec3{}); + + // Wild numbers are refused. + PropFullMsg bad = m; + bad.pieces.resize(1); + bad.pieces[0].body.matrix.m3.x = 1.0e6f; + EXPECT_FALSE(decodeMessage(encodeMessage(bad), back)); + bad = m; + bad.pieces.resize(1); + bad.pieces[0].body.linearVelocity.y = std::numeric_limits::infinity(); + EXPECT_FALSE(decodeMessage(encodeMessage(bad), back)); + // Every truncation fails. + for (std::size_t n = 0; n < bytes.size(); ++n) { + const std::vector cut(bytes.begin(), bytes.begin() + static_cast(n)); + EXPECT_FALSE(decodeMessage(cut, back)) << n; + } +} + TEST(PropState, RefusesMalformedMessages) { PropStateMsg m; m.slots.push_back(flying(0)); From 1cfc71d5dda686aa5a4a635db36d174ff75d5abc Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Sat, 10 Oct 2026 05:04:44 -0700 Subject: [PATCH 127/129] Record the props' third round The pieces round a client's car in full (protocol 18) in the props' section of docs/multiplayer.md, and the round in the review: the chase's corrections are not the props' second round (the same binary corrects 0 or 112-167 times a minute from run to run, before and after it), what an exact replay of a pushed pile needed step by step, the rear-end runs before and after (corrections over 1 cm near a knocked piece 208 -> 87 a minute, the largest 2.8 m -> 0.77 m), the bandwidth, and another player's car's knocks on the merged build (31 of 50 predicted, 15 of 50 before the cars' third round). Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/multiplayer.md | 43 +++++++- docs/parity/openmm2-only.md | 4 +- docs/review/multiplayer-desync-props.md | 139 +++++++++++++++++++++++- 3 files changed, 179 insertions(+), 7 deletions(-) diff --git a/docs/multiplayer.md b/docs/multiplayer.md index f350cb6..40fd809 100644 --- a/docs/multiplayer.md +++ b/docs/multiplayer.md @@ -188,7 +188,7 @@ 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, unsequenced (late snapshots still fill the buffer; ENet's throttle never drops them) | `VehicleState` (client→host), `WorldState` (host→clients), `PropState` (host→clients), `TrafficFull` (host→clients) | +| 1 State | unreliable, unsequenced (late snapshots still fill the buffer; ENet's throttle never drops them) | `VehicleState` (client→host), `WorldState` (host→clients), `PropState` (host→clients), `TrafficFull` (host→clients), `PropFull` (host→clients) | | 2 Events | reliable, ordered | `GameEvent` | | 3 Ambient | unreliable, sequenced; a message beyond ENet's MTU in unreliable fragments | `AmbientState` (host→clients): the shared cruise traffic | @@ -227,7 +227,8 @@ version 17 `CarStates` after every host frame that ran a sample, the near set (three cars within 60 m) with as many of them in full as fit one packet, a near client car's inputs the host holds for its next samples, and a contact's bound and hardest pusher in a car's full state (11, 13 and 15 on -its own branch). +its own branch), version 18 the pieces round a client's car in full +(`PropFull`). ### Handshake @@ -281,6 +282,7 @@ attempt within `connectTimeoutMs` (8 s). | AmbientState | H→C | the shared traffic and police near the client (see "Shared traffic") | | TrafficFull | H→C | the police and knocked cars near the client in full, at the physics sample of its `CarStates` (see "Shared traffic") | | PropState | H→C | the host's ring of knocked-over props and thrown car parts, the states near the client's car first (see "Props") | +| PropFull | H→C | the pieces round the client's car in full, at the physics sample of its `CarStates` (see "Props") | `SessionSettings` holds the session name, city, mode (Cruise, Checkpoint, Circuit, Blitz, Cops & Robbers, Crash Course), race id, laps, time of day, @@ -353,7 +355,11 @@ packet to the game or discovery port, and whatever answers as the router. what it holds, ignores a prop it has not placed and, when the host's placement checksum differs from its own, every placed prop (it then simulates its props itself). A `PropKnocks` event holds at most 512 knocks - and counts only from the host. + and counts only from the host. A `PropFull` holds at most 8 pieces (slot + 0-255, generation 0-15, part 0-2, the pusher's kind 0-6), every number + finite and within the cars' full states' bounds, the sleep counters + ranged; a client keeps at most 32 unread and uses a piece only for the + slot and generation it shows or its own piece of that knock. * **Rules.** The host refuses a player's own word on a rule (its event filter: `CheckpointReached`, `LapCompleted`, `RaceFinished`, the gold events, Cops and Robbers' 0x8001-0x8003 and the rules event) and relays @@ -1157,6 +1163,37 @@ them, and dent them, through single player's code. Nothing is knocked or moved in the world by a replay. A prop a client's car hits in its own samples is the same knock on the host, sample for sample: a client driving alone through props had no correction. +* **The pieces round a client's car in full** (`PropFull`, protocol 18): + with the `CarStates` it sends a client, about 20 times a second (every + third of 60), the host sends the pieces within 10 m of its car in up to + three datagrams, the nearest that fit, in the order its world runs them: + the moving ones with their bodies (as `TrafficFull`'s knocked cars: the + rigid body, what a sample hands the next, the phSleep and the age, and as + the players' cars' full states the bound where the sample's collisions saw + it and what pushed it hardest, which the next sweep against that collider + reads), the ones at rest with their exact frame three times. When its car + runs again for that state, the client puts each moving piece it shows (the + host's slot, or its own piece of the same knock standing in, even before + the slot is shown) to the host's state and runs it with its car + (`game::CarPrediction::Companion`, only when it differs from where the + client had it there); the samples run again then reproduce the host's + (to the bit, in the test below, where nothing else differs), so that its + car pushes the pieces it leans on as the host's simulation of it does. While they come the client simulates the piece + however long; it meets the other pieces simulated here as on the host and + knocks a placed prop as the host's does (a prediction, with the replays' + ghost), and its prop's ghost goes once the host's state of it is known. A + piece at rest is drawn at the host's exact frame. Between the full states + the client runs its car again only when its own state differs, against + the pieces as it predicted them. Test: a car pushing a pile of 18 props + through the rest at 60 states a second, 100 ms each way, was corrected 269 + times without and never with (`PropPush`). In the race runs (a client's + car running into the back of the host's through props, five runs each) + its corrections over 1 cm with a knocked piece within 4 m went from 208 to + 87 a minute, the largest from 2.8 m to 0.77 m; the rest come with the other + car against it. The host sent 0.2-9 KB/s of them to a client while pieces + moved round its car (10 s averages), three 1200-byte datagrams 20 times a + second at most. The phSleep's jitter sums are not sent (they count only + for a piece almost at rest). * **A different placement** (an altered city, a mismatched build): the client follows only the cars' parts and simulates its props itself, as 0.3 did, and logs it. diff --git a/docs/parity/openmm2-only.md b/docs/parity/openmm2-only.md index 08589af..e558197 100644 --- a/docs/parity/openmm2-only.md +++ b/docs/parity/openmm2-only.md @@ -166,8 +166,8 @@ in the summary above. | OpenMM2 | MM2 | Verdict | Notes | | --- | --- | --- | --- | -| `game::PropHost`, `PropClient`, `NetProps`, `net::PropStateMsg`, `PropKnocksEvent` | (none) | openmm2 | The host simulates the props for everyone and sends the knocks (reliable, all of them to a machine that has loaded the race) and its ring of knocked-over props (`dgBangerManager`'s 40 slots); a client shows them a playout delay in the past, predicts its own car's knocks and hands them over (docs/multiplayer.md, "Props"). Since protocol 16 each client gets its own message: every occupied slot, the states near its car (150 m) first within 1100 bytes, the others less often; a client also predicts the knocks of the other players' cars it simulates (`NearCarState`), and undoes a missed one as soon as the host's messages (and, for another player's car, the host's state of it) show it missed. | -| `BangerSet::setReplica`, `phys::Instance::acceptsContact` | `dgPhysManager::CollideInstances` | deviation | A client's props take contacts from its own car and the cars it simulates along with it (the other players' near ones, `NearCarState`; the shared police and knocked traffic cars, `TrafficFull`), and its own simulated props one another (no placed prop: the host's knocks bring the chain knocks); MM2's take them from every mover. | +| `game::PropHost`, `PropClient`, `NetProps`, `net::PropStateMsg`, `PropKnocksEvent` | (none) | openmm2 | The host simulates the props for everyone and sends the knocks (reliable, all of them to a machine that has loaded the race) and its ring of knocked-over props (`dgBangerManager`'s 40 slots); a client shows them a playout delay in the past, predicts its own car's knocks and hands them over (docs/multiplayer.md, "Props"). Since protocol 16 each client gets its own message: every occupied slot, the states near its car (150 m) first within 1100 bytes, the others less often; a client also predicts the knocks of the other players' cars it simulates (`NearCarState`), and undoes a missed one as soon as the host's messages (and, for another player's car, the host's state of it) show it missed. Since protocol 18 the host also sends the pieces round a client's car in full (`net::PropFullMsg`, `PropHost::buildFull`, `PropClient::fullPieces`, `BangerSet::bodyState`, `setBodyState`), which the client runs with its car when its samples run again (with the bound where the sample's collisions saw each piece and its hardest pusher, `BangerSet::pusherOf`, `phys::World::cityKey`, `staticKey`, `order`). | +| `BangerSet::setReplica`, `phys::Instance::acceptsContact` | `dgPhysManager::CollideInstances` | deviation | A client's props take contacts from its own car and the cars it simulates along with it (the other players' near ones, `NearCarState`; the shared police and knocked traffic cars, `TrafficFull`), and its own simulated props one another (no placed prop: the host's knocks bring the chain knocks; since protocol 18 a host's piece it simulates from the host's full states does knock placed props, as the host's does); MM2's take them from every mover. | | `BangerSet::setRingGrowth` (network races) | `dgBangerManager::GetBanger` (the ring of 40, `dgBangerManager::Init(40)`) | deviation | When the ring is about to wrap onto a prop that still moves or was handed out less than 10 s ago, it grows by 40 slots instead, up to 40 a player (at least 80, at most 256); MM2 wraps and the oldest knocked-over prop disappears, in a pile-up while still flying. Off in single player. Test: `PropSync.APileUpGrowsTheRing`. | | `BangerSet::breakPlaced`, `restoreStanding`, `showMirror`, `hideMirror` | `dgUnhitBangerInstance::Impact`, `Reset`, `dgHitBangerInstance` | openmm2 | A client's placed prop leaves its room as Impact makes it leave (without a body), stands again as Reset makes it stand (a prediction the host did not confirm), and the host's pieces are hit instances of their own. | | `BangerSet::attachEntity` on a mirror | `dgBangerActive::Attach` | deviation | MM2 starts an active at rest (a placed or resting prop is); a client's mirror of a host piece in flight starts from the host's motion. | diff --git a/docs/review/multiplayer-desync-props.md b/docs/review/multiplayer-desync-props.md index ff8c509..dcbe2ed 100644 --- a/docs/review/multiplayer-desync-props.md +++ b/docs/review/multiplayer-desync-props.md @@ -5,7 +5,8 @@ with integration at 3f73059 (the host simulates every player's car), 52aa601 (the shared traffic) and cae6989 (the players' cars' final work); and in the rough edges round (protocol 16, "Rough edges round" below) at 782f469 (the players' cars' second round, the host's rules) and b33bebc (the -cross-platform determinism work), on the +cross-platform determinism work), and in a third round (protocol 18, "Third +round" below) at 7fe48bb (the players' cars' third round), on the maintainer's report that in a multiplayer cruise over the Internet "knocked-over props, parked cars, objects or car damage look different on each screen", and on the maintainer's decision that the host is the @@ -58,6 +59,10 @@ knocks of the other players' cars it runs (before, such a car drove through a prop that fell 140-400 ms later), undoes a missed prediction in 0.7-1.8 s instead of 2 s, no longer lets its own pieces knock props, and gets its own message with the states near its car first; the ring grows in a pile-up. +In the third round (protocol 18) the host sends the pieces round a client's +car in full, and a client's car pushing them is predicted exactly: in a test +of a car pushing a pile of 18 props through the rest, no correction instead +of 269; in the race runs, its corrections near knocked pieces fell by 58 %. Props down on one machine and standing on the other, at any 250 ms tick: 6-16 at the end of the 0.3.1 runs (in 72-93 % of the ticks), 2-6 at the end @@ -357,6 +362,119 @@ the car's centre came closest is one falling at the contact. Bandwidth after: 31 bytes/s to 6.4 KB/s to each client (10 s averages over the round's runs), at most 5.5 KB/s before; a message at most 1100 bytes. +## Third round (protocol 18) + +On the merged build (integration 7fe48bb: the players' cars' third round, +protocol 17, merged at f5e2f92), three items: a regression the players' cars +agent measured in its chase scenario, a client's car pushing knocked props +and pieces (O11), and the near cars' knock predictions again. + +### The chase's corrections are not the props' second round + +The players' cars agent's chase (the host leading, the client 10 m behind, +both driven by looping inputs, 60 ±20 ms each way, 2 % loss, 75 s) corrected +the client's car 114-405 times a minute on integration after the props' +second round, and not at all on integration before it. The same scenario +here, its scripts copied (port 2400), corrections a minute: + +| Build | Runs | +| --- | --- | +| 782f469 (integration, before the props' second round) | 36, 111, 152 | +| b33bebc (the determinism work) | 205, 118, 201 | +| 8e74c96 (the traffic's second round; the cars agent's "before", 0 and 0 there) | 149, 167, 112 | +| 65529a6 (the props branch before its second round) | 169, 99, 0 | +| 482ac2c (after it) | 102, 251, 113 | +| f5e2f92 (merged with the cars' third round) | 99, 34, 123, 284; with every second-round change turned back (debug switches): 152, 211; with none: 260, 8 | +| the cars' third round before it merged the props' second (the cars agent's own runs) | 426, 15 | + +The same binary gives both: 8e74c96 corrected 0 times in both the cars +agent's runs and 112-167 times a minute in all three here, and every build +has runs with almost none. The corrections are velocity errors of 3-10 cm/s +with the position 1-3 mm off (just over the 3 cm/s tolerance), most of them +with no knocked piece within 4 m and the other car farther than 6 m; once +they begin in a run they go on, and a run either has them from its first +seconds or barely at all. The client's car hits the city at the same +places, as hard, in every run, with them or without. Nothing of the props' +second round brings them; they are the players' cars agent's to find +(below). + +### Pieces round a client's car in full (O11) + +A client's car leaning on knocked-over props and pieces met them where the +host's messages showed them (a playout delay in the past), or as its own +simulation had them, and was corrected at nearly every state. The host now +sends, with each client's `CarStates`, the pieces round its car in full +(`net::PropFullMsg`, docs/multiplayer.md "Props"), and the client runs them +with its car when its samples run again. A deterministic test +(`tests/game/test_prop_push.cpp`: host and client in one process, the +client's car pushing a pile of 18 props through the rest, 100 ms each way) +found what such a replay needs to give the host's samples exactly, step by +step (20 states a second unless said; the pile's first rows knock the +next, so the farther pieces matter as the cap allows): + +| The client's replays | Corrections | +| --- | --- | +| without the pieces in full | 95 | +| the pieces' bodies, the nearest 8 within 1200 bytes | 71 | +| and their hardest pusher; the other bodies put back for each sample as the race screen puts them | 78 | +| and the bound where the sample's collisions saw it (the push moves the body after them; the next sweep starts from the bound) | 89 (a piece now about 250 bytes: 4 in a message) | +| forces and pushes sent only when not zero, the bound's turn only when it turned (about 190 bytes: 6 in a message) | 69 | +| a piece in full takes this machine's own piece of the same knock before the slot is shown | 68 | +| three messages a state, the nearest 18 | 8 | +| 60 states a second, the pieces in full 20 times; a piece in full knocks a placed prop as the host's does, with the replays' ghost | 1 (as committed: 0, and 269 without) | + +With the bound, a replay of one row of three pieces reproduced the host's +states to the last bit for the whole run (before it, the impulses already +differed after the first sample run again; the hardest pusher, also sent, +made no measurable difference there); a pile of two rows went from 95 to +none once the bound and the early stand-in were in. The pieces in full every +third of 60 states corrected the car about as rarely as at every state (at +every state and every third: two rows 0 and 0, six rows 0 and 8) with a +third of the bytes. + +In the race runs, five each of the cars agent's rear-end scenario (the +client's car into the back of the host's, knocking props on the way), on +f5e2f92 and on this round's build: + +| | Corrections a minute | Over 1 cm with a knocked piece within 4 m, a minute | Largest of those | Over 1 cm near a piece only (the other car farther than 6 m), each run | +| --- | --- | --- | --- | --- | +| before | 530 (294-744) | 208 (96-382) | 2.81 m | 148, 63, 192, 108, 129 (41 knocks) | +| after | 550 (136-1521) | 87 (0-242) | 0.77 m | 0 (no knock), 13, 8, 5, 60 (24 knocks) | + +The corrections left are the two cars against each other (the run with +1521 a minute had no knock at all). In the chase (four runs each) the +corrections over 1 cm near a piece only went from 58 to 27 (25 and 17 +knocks). The pieces still rest where the host's do (2 mm median), but a +piece the client ran itself at the end of a run was 0.14-0.21 m from the +host's, being handed back. The host sent 0.2-9 KB/s of pieces in full to a +client while they moved round its car (10 s averages: 0.2-1.6 KB/s in the +chase, 2-9 KB/s in the rear-end runs), at most three 1200-byte datagrams 20 +times a second. + +### Another player's car's knocks on the merged build (item 3) + +The seven scenarios again (slalom and follow at 80 and 150 ms, parked cars, +three machines, one client alone), on this round's build: + +| | Round 2's last batch | This round | +| --- | --- | --- | +| knocks by another player's car the client predicted, of the host's | 15 of 50, none undone | 31 of 50, 3 more undone | +| its own car's predictions confirmed | 52 of 52 | 49 of 49 | +| the police's and knocked traffic cars' | 1 of 1 | 11 of 12 | +| a piece in full knocking a placed prop | | 2 of 2 | +| resting apart (both at rest) | 3 mm (one 15 mm) | 3 mm (one 0.14 m, being handed back at the end) | + +The cars' third round (three near cars with the inputs the host holds for +them) doubled the share of another player's car's knocks a client predicts; +the three undone ones, all in the three-machine run, are knocks a client's +run-ahead of another car made and that car on the host did not. Where they +were predicted the prop fell here 165-261 ms (medians, session time) before +it fell on the host, as the car met it here. + +Checks: 1050 of 1050 tests (3 skipped), the determinism hashes unchanged; +the same with `-D_GLIBCXX_ASSERTIONS`; the sweep 515/517 finished, 507/517 +across the line (the baseline); the smoke screenshots as before. + ## For the players' cars agent * A client's props accept contacts from its own car and trailer @@ -379,6 +497,18 @@ the round's runs), at most 5.5 KB/s before; a message at most 1100 bytes. acknowledgement for the first 3-4 s after the start (0.56 m each, the sign alternating, 58-59 samples run again), with the props kept local as well: the race's start, not the props. +* The chase's corrections (third round, above): the same binary corrects a + client's car 0 or 100-170 times a minute from run to run, 3-10 cm/s at a + time, mostly far from pieces and from the other car, and once they begin + they go on. Not the props. +* A body run again with the car needs, besides its rigid body and what a + sample hands the next, the bound where the sample's collisions saw it + (the push moves the body after them) and its hardest pusher: the pieces in + full carry both; `TrafficFull`'s knocked cars carry neither, and the car's + own state names no piece as its pusher (`PropFullMsg::carPusher` does). + phSleep's jitter sums are carried by none. +* `CarPrediction::acknowledge` runs the companions in the order given; the + pieces in full come in the host's world's order, after the car. ## Open @@ -393,6 +523,11 @@ the round's runs), at most 5.5 KB/s before; a message at most 1100 bytes. * A client's car leaning on a slowly creeping piece that the host's copy of the car does not touch (once, at the end of a three-machine run): the piece is pushed here, held, handed back, pushed again, and the car's - corrections run at every acknowledgement until it drives away. + corrections run at every acknowledgement until it drives away. Since the + third round the host's piece comes in full while it moves within 10 m of + the car and is simulated here from it, without the hand-back; a piece + the host has at rest is not run with the car. +* More than about 18 pieces moving round a car at once: the farther ones + wait for room (three datagrams a state). * Props are interest-managed by distance from the client's car only, not by what its camera sees. From 7db4424b753ed80f208efcff131b124615374009 Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Sat, 10 Oct 2026 13:35:59 -0700 Subject: [PATCH 128/129] Call this version 0.4.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 b098e0e..93d4f62 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,7 +1,7 @@ cmake_minimum_required(VERSION 3.25...4.0) project(OpenMM2 - VERSION 0.3.1 + VERSION 0.4.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 56be0ec..fb736dc 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 rebuilt around the host: the host decides every car, collision, traffic and police car, prop, damage and rule, while each player's own car and the cars near it are predicted so they stay responsive. Players see the same game on every screen, and Windows and Linux players simulate identically.

+
+

Fixes a crash when an opponent or police car drove onto a road narrower than its lane offset; such cars now steer as in the original game.

From 6ba4c7988e7c22e6ad3c15a53935eec9387e87d6 Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Sat, 10 Oct 2026 13:56:09 -0700 Subject: [PATCH 129/129] Name the players' cars' wire header after what it carries src/net/PlayerCars.h and src/game/net/PlayerCars.h shared a name. For a quoted include MSVC searches the directories of every file up the include stack before the -I paths, so "net/PlayerCars.h" from a file under src/game/ found game/net/PlayerCars.h and the wire types were never declared (PR #5's Windows build: C2039 'CarInputFrame' is not a member of 'mm2::net'). The wire header is now net/PlayerCarState.h, like the other *State.h messages; no other quoted include in the tree resolves to two headers. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/multiplayer.md | 2 +- docs/parity/README.md | 2 +- docs/review/multiplayer-desync-cars.md | 2 +- src/game/net/NetGame.h | 2 +- src/game/net/PlayerCars.h | 4 ++-- src/net/{PlayerCars.h => PlayerCarState.h} | 0 src/net/PropState.h | 2 +- src/net/Protocol.h | 6 +++--- src/net/Session.cpp | 2 +- src/net/Session.h | 4 ++-- src/net/TrafficFull.h | 2 +- tests/game/test_net_water.cpp | 2 +- tests/game/test_player_cars.cpp | 2 +- tests/game/test_prop_push.cpp | 2 +- tests/net/test_player_cars.cpp | 4 ++-- tests/net/test_water_state.cpp | 4 ++-- 16 files changed, 21 insertions(+), 21 deletions(-) rename src/net/{PlayerCars.h => PlayerCarState.h} (100%) diff --git a/docs/multiplayer.md b/docs/multiplayer.md index 40fd809..0aca3a9 100644 --- a/docs/multiplayer.md +++ b/docs/multiplayer.md @@ -16,7 +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/PlayerCars.h` | The players' cars simulated by the host: `PlayerInputMsg`, `CarStatesMsg` | +| `net/PlayerCarState.h` | The players' cars simulated by the host: `PlayerInputMsg`, `CarStatesMsg` | | `net/AmbientState.h` | The shared cruise traffic: `AmbientStateMsg` | | `net/ClockSync.h` | Session clock estimation on clients | | `net/Discovery.h` | LAN beacon/scanner, broadcast addresses, small UDP socket | diff --git a/docs/parity/README.md b/docs/parity/README.md index 4d653c4..32d2f5f 100644 --- a/docs/parity/README.md +++ b/docs/parity/README.md @@ -476,7 +476,7 @@ audited in [openmm2-only.md](openmm2-only.md). | `src/net/NatPmpBackend.cpp` | 279 | O | - | | `src/net/Net.cpp` | 113 | O | - | | `src/net/Net.h` | 62 | O | - | -| `src/net/PlayerCars.h` | 315 | O | - | +| `src/net/PlayerCarState.h` | 315 | O | - | | `src/net/PortMapper.cpp` | 324 | O | - | | `src/net/PortMapper.h` | 164 | O | - | | `src/net/PropState.cpp` | 12 | O | - | diff --git a/docs/review/multiplayer-desync-cars.md b/docs/review/multiplayer-desync-cars.md index b1b02a6..199aa93 100644 --- a/docs/review/multiplayer-desync-cars.md +++ b/docs/review/multiplayer-desync-cars.md @@ -127,7 +127,7 @@ decompile and the asm of `SendPosition` and `PositionUpdate`): inputs; `CarSim::ownRandom` (each player's car draws its wheels' bump numbers from its own stream on every machine). 3. **Protocol 5** (5f2f10d): `PlayerInput` and `CarStates` - (net/PlayerCars.h), checked within ranges, with a budget. + (net/PlayerCarState.h), checked within ranges, with a budget. 4. **Game layer** (24dbd3d): `NetCarDriver`, `HostInputQueue`, `CarPrediction`, `CorrectionBlend` (game/net/PlayerCars). 5. **The race** (3f73059): the host simulates every client's car from its diff --git a/src/game/net/NetGame.h b/src/game/net/NetGame.h index 6961553..d6f0a25 100644 --- a/src/game/net/NetGame.h +++ b/src/game/net/NetGame.h @@ -316,7 +316,7 @@ class NetGame { // tag and its fields (the props', game::PropTrace). void traceLine(std::string_view line); - // --- The players' cars, simulated by the host (net/PlayerCars.h) ------------------- + // --- The players' cars, simulated by the host (net/PlayerCarState.h) ------------------- // Client: this machine's car's inputs. void sendPlayerInput(const net::PlayerInputMsg& msg); // Host: the clients' inputs received since the last call. diff --git a/src/game/net/PlayerCars.h b/src/game/net/PlayerCars.h index af42f0e..5f101ba 100644 --- a/src/game/net/PlayerCars.h +++ b/src/game/net/PlayerCars.h @@ -1,7 +1,7 @@ #pragma once // The players' cars in a network race, simulated by the host (OpenMM2; see -// docs/multiplayer.md, "Players' cars", and net/PlayerCars.h for the wire). +// docs/multiplayer.md, "Players' cars", and net/PlayerCarState.h for the wire). // // MM2 ran a network car on every machine as a vehCar pulled toward its // owner's packets (mmNetObject::PositionUpdate, Predict), so each machine @@ -28,7 +28,7 @@ // Nothing here touches the network or the race's presentation. #include "game/PlayerVehicle.h" -#include "net/PlayerCars.h" +#include "net/PlayerCarState.h" #include "net/Snapshot.h" #include "phys/World.h" diff --git a/src/net/PlayerCars.h b/src/net/PlayerCarState.h similarity index 100% rename from src/net/PlayerCars.h rename to src/net/PlayerCarState.h diff --git a/src/net/PropState.h b/src/net/PropState.h index 67cf51a..f35e372 100644 --- a/src/net/PropState.h +++ b/src/net/PropState.h @@ -45,7 +45,7 @@ // again, so that its car pushes them as the host's // simulation of it does, exactly. -#include "net/PlayerCars.h" +#include "net/PlayerCarState.h" #include "net/Protocol.h" #include diff --git a/src/net/Protocol.h b/src/net/Protocol.h index d3ca1b8..187821a 100644 --- a/src/net/Protocol.h +++ b/src/net/Protocol.h @@ -30,14 +30,14 @@ inline constexpr std::uint16_t kProtocolMagic = 0x4D32; // "M2" // RaceStart; the race in Welcome). // 4: the cars' damage (net/VehicleDamage.h events; in AmbientState the // police's damage at 10 bits and the knocked traffic cars' wheels). -// 5: the host simulates every player's car (net/PlayerCars.h: PlayerInput, +// 5: the host simulates every player's car (net/PlayerCarState.h: PlayerInput, // CarStates; damage events about a player's car from the host). // 6: in AmbientState a rail car's acceleration, curvature and speed over the // ground (the clients predict the shared traffic to their cars' time), and // up to 160 cars; no TrafficHit events. // 7: the host's props (net/PropState.h: PropState, the PropKnocks event). // 9: in CarStates the players' cars near the client's in full, with the input -// last applied to them (net/PlayerCars.h NearCarState), and in every car's +// last applied to them (net/PlayerCarState.h NearCarState), and in every car's // full state what a sample hands the next. // 10: the host's rules (net/RulesState.h: the rules message; a player's own // word on a checkpoint, a finish or the gold is refused). (8 was its number @@ -123,7 +123,7 @@ enum class MsgType : std::uint8_t { AmbientState, // net/AmbientState.h RaceLoaded, RaceStart, - PlayerInput, // net/PlayerCars.h + PlayerInput, // net/PlayerCarState.h CarStates, PropState, // net/PropState.h TrafficFull, // net/TrafficFull.h diff --git a/src/net/Session.cpp b/src/net/Session.cpp index 6bdfdb6..d00b2ff 100644 --- a/src/net/Session.cpp +++ b/src/net/Session.cpp @@ -72,7 +72,7 @@ constexpr double kEventRate = 30.0, kEventBurst = 60.0; // a crash's events never use up the race events' or the other way round. The // game sends at most ten a second. constexpr double kDamageEventRate = 15.0, kDamageEventBurst = 30.0; -// A joiner's car's inputs (net/PlayerCars.h): the game sends one message a +// A joiner's car's inputs (net/PlayerCarState.h): the game sends one message a // frame that simulated a sample, at most 60 a second; the host only queues // them, so the budget bounds the work and the queue. constexpr double kInputRate = 90.0, kInputBurst = 180.0; diff --git a/src/net/Session.h b/src/net/Session.h index 158d91d..91b5eb0 100644 --- a/src/net/Session.h +++ b/src/net/Session.h @@ -17,7 +17,7 @@ #include "net/AmbientState.h" #include "net/ClockSync.h" #include "net/Discovery.h" -#include "net/PlayerCars.h" +#include "net/PlayerCarState.h" #include "net/PropState.h" #include "net/RulesState.h" #include "net/Protocol.h" @@ -252,7 +252,7 @@ class Session { // Connection quality to the host (clients) or to a player (host). PeerStats peerStats(std::uint8_t playerId) const; - // --- The players' cars, simulated by the host (net/PlayerCars.h) --- + // --- The players' cars, simulated by the host (net/PlayerCarState.h) --- // Client: this machine's inputs to the host (during a race only). void sendPlayerInput(const PlayerInputMsg& msg); // Host: the clients' inputs received since the last call, oldest first diff --git a/src/net/TrafficFull.h b/src/net/TrafficFull.h index 65b17bc..47eba6b 100644 --- a/src/net/TrafficFull.h +++ b/src/net/TrafficFull.h @@ -17,7 +17,7 @@ // unreliably on the State channel (unsequenced: each stands alone). #include "net/AmbientState.h" -#include "net/PlayerCars.h" +#include "net/PlayerCarState.h" #include #include diff --git a/tests/game/test_net_water.cpp b/tests/game/test_net_water.cpp index 926bb5d..d0e68bb 100644 --- a/tests/game/test_net_water.cpp +++ b/tests/game/test_net_water.cpp @@ -6,7 +6,7 @@ #include "TestData.h" #include "game/PlayerVehicle.h" #include "game/net/PlayerCars.h" -#include "net/PlayerCars.h" +#include "net/PlayerCarState.h" #include "phys/World.h" #include diff --git a/tests/game/test_player_cars.cpp b/tests/game/test_player_cars.cpp index 47e76ca..3aa3f42 100644 --- a/tests/game/test_player_cars.cpp +++ b/tests/game/test_player_cars.cpp @@ -5,7 +5,7 @@ #include "TestData.h" #include "game/PlayerVehicle.h" #include "game/net/PlayerCars.h" -#include "net/PlayerCars.h" +#include "net/PlayerCarState.h" #include "phys/World.h" #include diff --git a/tests/game/test_prop_push.cpp b/tests/game/test_prop_push.cpp index cec4586..bd2fbf5 100644 --- a/tests/game/test_prop_push.cpp +++ b/tests/game/test_prop_push.cpp @@ -12,7 +12,7 @@ #include "game/bangers/PropPlacement.h" #include "game/net/PlayerCars.h" #include "game/net/PropSync.h" -#include "net/PlayerCars.h" +#include "net/PlayerCarState.h" #include "net/PropState.h" #include "phys/Level.h" #include "phys/World.h" diff --git a/tests/net/test_player_cars.cpp b/tests/net/test_player_cars.cpp index 79c5989..e62ecc2 100644 --- a/tests/net/test_player_cars.cpp +++ b/tests/net/test_player_cars.cpp @@ -1,7 +1,7 @@ -// The host-simulated players' cars on the wire (net/PlayerCars.h): the +// The host-simulated players' cars on the wire (net/PlayerCarState.h): the // clients' inputs and the host's states, their encodings, what a receiver // refuses, mutated messages, and their delivery through real sessions. -#include "net/PlayerCars.h" +#include "net/PlayerCarState.h" #include "net/Session.h" #include diff --git a/tests/net/test_water_state.cpp b/tests/net/test_water_state.cpp index 2bcb6c7..0056215 100644 --- a/tests/net/test_water_state.cpp +++ b/tests/net/test_water_state.cpp @@ -1,8 +1,8 @@ -// The water in a car's full state (protocol 14, net/PlayerCars.h +// The water in a car's full state (protocol 14, net/PlayerCarState.h // OwnCarState): the host decides when the water puts a car back, and a // client carries on from the host's state with its vehSplash and the water // handler's time. -#include "net/PlayerCars.h" +#include "net/PlayerCarState.h" #include