diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e1d4c5c..0cbb3d4 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,20 @@ 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: + # 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: | + wineboot --init + ctest --preset mingw-cross-release -R '^Determinism[.]' + - name: Package run: cpack --preset mingw-portable 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/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/docs/bangers.md b/docs/bangers.md index d9705be..b79a0fe 100644 --- a/docs/bangers.md +++ b/docs/bangers.md @@ -183,3 +183,37 @@ 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`). 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 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 + 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 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), which the world moves through + the replay only; a prop its car broke loose in the last second stands + 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 ff428a9..0aca3a9 100644 --- a/docs/multiplayer.md +++ b/docs/multiplayer.md @@ -16,7 +16,8 @@ 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/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 | | `net/PortMapper.h` | Automatic port forwarding (UPnP, PCP, NAT-PMP) | @@ -44,12 +45,21 @@ 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 host also decides the rules (protocol 10, 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 (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 @@ -178,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) | +| 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 | `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 @@ -195,7 +205,30 @@ 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 players' cars simulated by the host +(`PlayerInput`, `CarStates`, damage events about a player's car from 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, version 7 the host's props +(`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, 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), 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, 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, version 16 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 (13 on its own branch), +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), version 18 the pieces round a client's car in full +(`PropFull`). ### Handshake @@ -240,11 +273,16 @@ 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") | +| 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, @@ -290,6 +328,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 carries a command out only where the game's rules + would (`game::ResetRules`, see "Players' cars"). 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 @@ -297,16 +347,46 @@ 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, +* **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 + 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. 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 + 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 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). 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). 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 @@ -371,8 +451,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 10: the host decides them): `CheckpointReached{index, raceTime}`, `LapCompleted{lap, lapTime}`, `RaceFinished{raceTime, position}`, `GoldPickedUp/GoldDropped/GoldDelivered @@ -380,9 +462,12 @@ 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 used 0x8001 to 0x8003 until +protocol 10 (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). ### Race start @@ -491,6 +576,197 @@ 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), `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 + 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.** After every frame that ran a sample (up to 60 times a + 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 + 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, 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 + 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 + 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 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. 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 + them with its own car through the later samples (`CarPrediction`'s + 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. 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 + 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 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 An OpenMM2 extra (MM2's network cruise has no traffic and no police, @@ -501,24 +777,32 @@ 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. +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 @@ -526,37 +810,116 @@ 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'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 +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. + +**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 +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 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 +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 +the client's car as kinematic instances moving at their predicted velocities +(`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: 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, +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 @@ -577,9 +940,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 +963,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) | @@ -608,10 +978,10 @@ 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 + (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 shared traffic's drawing time), so a dent appears when the car hits the @@ -668,6 +1038,331 @@ 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 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 + 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. +* **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, 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 | 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 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 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, + as in single player (the impulse, the damage, the prop breaking loose): a + prediction (before protocol 16 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 (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 + 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 + threw. +* **Replays.** When the host's state corrects its car, a client runs the + 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 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. +* **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. + +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`): 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.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 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. 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 +(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 +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 +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, 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 + +**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 10; 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 (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 + 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 + 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. +* **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), unless + 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 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, + 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. 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 + 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 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 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. + ### Disconnect reasons `Left`, `VersionMismatch`, `ServerFull`, `BadPassword`, `GameInProgress`, @@ -807,20 +1502,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 @@ -834,27 +1530,43 @@ 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 - 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`). 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. -8. **End:** the host calls `ctx.netGame->returnToLobby()` when the race is + 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. **Props** (`game::NetProps`, see "Props"): set up once the props are + 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 + 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; @@ -878,7 +1590,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 @@ -889,11 +1602,31 @@ 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 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); `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_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 @@ -901,10 +1634,25 @@ 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 +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, 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 machine's report, countdown and Go in session time ("race N: countdown from @@ -935,7 +1683,37 @@ 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 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, 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). 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 @@ -944,7 +1722,18 @@ 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, 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, 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 @@ -960,7 +1749,61 @@ 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; +* 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", `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 + (`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`). + +`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, then the props (by +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). +`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. + +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 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 (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/README.md b/docs/parity/README.md index dcd7791..32d2f5f 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 | - | @@ -316,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 | @@ -359,6 +363,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,23 +414,49 @@ 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 | - | +| `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/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 | - | | `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 | | `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 | @@ -444,19 +476,25 @@ 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/PlayerCarState.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/RulesState.h` | 212 | O | - | | `src/net/Session.cpp` | 942 | O | - | | `src/net/Session.h` | 253 | O | - | | `src/net/Sha256.cpp` | 98 | O | - | | `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 | - | +| `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 | diff --git a/docs/parity/openmm2-only.md b/docs/parity/openmm2-only.md index 30fa0c0..e558197 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, 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. | + ## Shared traffic of a network cruise Added 2026-10-09 on the maintainer's decision (2026-10-09: the traffic and @@ -126,13 +142,38 @@ 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` | | 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). 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). | | 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"). 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. | +| `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 | File | Class | Reason | @@ -162,6 +203,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 | | 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/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 new file mode 100644 index 0000000..199aa93 --- /dev/null +++ b/docs/review/multiplayer-desync-cars.md @@ -0,0 +1,830 @@ +# Multiplayer desync: the players' cars + +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; 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 +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; 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 +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/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 + 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), 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. +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.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. 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): + +| 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 | 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 +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; 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 | 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); 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 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 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 + +* 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 (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 | +| --- | --- | --- | --- | --- | +| 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. + +## 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. + +## 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 + 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`). +* A client simulates the other players' cars near its own from the host's + 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). + +## 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. 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); 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. | 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. | 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 + +``` +# 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`. + +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`. + +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-desync-props.md b/docs/review/multiplayer-desync-props.md new file mode 100644 index 0000000..dcbe2ed --- /dev/null +++ b/docs/review/multiplayer-desync-props.md @@ -0,0 +1,533 @@ +# 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), 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), 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 +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) 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 +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; 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 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 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 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, 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) | + +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 +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 +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 + +* 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" 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 + 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; 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) | +| 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 | +| 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 +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 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 +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 (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 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 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, + 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 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: 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 1-35 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, 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 + client's props, a client's own pieces not moving the host's, a replayed + 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, 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 + San Francisco and London. +* 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. +* 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 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 (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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 +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`); 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); 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 + 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: 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 + (`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 + `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. +* `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. +* 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 + +* 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 + 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. diff --git a/docs/review/multiplayer-desync-rules.md b/docs/review/multiplayer-desync-rules.md new file mode 100644 index 0000000..de88dc2 --- /dev/null +++ b/docs/review/multiplayer-desync-rules.md @@ -0,0 +1,504 @@ +# 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). 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 +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. + +## 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, + 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). +* 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 + +| # | 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 | +| 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 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) +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`. diff --git a/docs/review/multiplayer-desync-traffic.md b/docs/review/multiplayer-desync-traffic.md new file mode 100644 index 0000000..7934b60 --- /dev/null +++ b/docs/review/multiplayer-desync-traffic.md @@ -0,0 +1,255 @@ +# 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); 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". + +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), 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 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 + +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. + +## 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, + 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 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 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, 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 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 (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`) 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 + +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 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 + +A car on its rail costs 120 bits (136 turning or changing speed, 148 when +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 | +| --- | --- | --- | --- | --- | +| 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 + +`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). + +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). diff --git a/docs/review/multiplayer-determinism.md b/docs/review/multiplayer-determinism.md new file mode 100644 index 0000000..136dcc9 --- /dev/null +++ b/docs/review/multiplayer-determinism.md @@ -0,0 +1,240 @@ +# 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, 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 | +| 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 + +* 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 + 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, 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) + 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. | 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 + +* **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); 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 + 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; 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 | 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. | +| O5 | info | The opponent sweep moved by one car (see "Single player"). | Nothing; the new values are MM2's rounding. | diff --git a/docs/review/multiplayer-sync.md b/docs/review/multiplayer-sync.md index 85b5d1d..7a6a0ba 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; 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 (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. | 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 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.

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..80916b1 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}); } @@ -903,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/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/app/RaceScreen.cpp b/src/app/RaceScreen.cpp index 82b865f..c051f79 100644 --- a/src/app/RaceScreen.cpp +++ b/src/app/RaceScreen.cpp @@ -41,11 +41,18 @@ #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/NetProps.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" #include "game/CamMirror.h" #include "game/CamPlayer.h" #include "game/CityRenderer.h" @@ -67,6 +74,7 @@ #include #include #include +#include #include #include #include @@ -78,27 +86,17 @@ 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); -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). constexpr int kNetPlayerTrackedId = 30000; constexpr int kNetPoliceId = 400; constexpr std::uint64_t kNetTrafficIntervalMs = 50; +// 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) { switch (m) { @@ -115,12 +113,18 @@ 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; // 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 } @@ -368,10 +372,9 @@ 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) - applyNetTrafficHits(); // OpenMM2: the clients' hits on the host's traffic // aiVehicleManager::Update and the rail cars' rooms, before the // collision manager runs. if (m_trafficBodies) @@ -393,22 +396,26 @@ 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 // 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) { 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 if (m_flyCamera || !m_player) updateFlyCamera(ctx, static_cast(dt)); @@ -417,13 +424,16 @@ 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), // 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; @@ -480,6 +490,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); @@ -718,7 +743,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()) + 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); @@ -726,7 +752,10 @@ 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) + 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) game::recordTrafficBodies(m_drawnPhys, *m_ai, *m_trafficBodies); if (m_bangers && m_world) game::recordProps(m_drawnPhys, *m_bangers, *m_world); @@ -752,11 +781,36 @@ 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; + // 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; + updateDrawAhead(id, rv); + rv.blend.update(m_frameDt); + 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); 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, @@ -858,8 +912,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->simulated(id))) { const auto it = m_netPhysical.find(id); return it != m_netPhysical.end() ? std::optional(it->second) : std::nullopt; } @@ -1101,6 +1156,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(); @@ -1109,6 +1165,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) { @@ -1354,6 +1419,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. @@ -1565,6 +1632,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, @@ -1737,6 +1811,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)) { @@ -2008,15 +2091,25 @@ 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 // 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. @@ -2097,11 +2190,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. - if (m_session->setup().checkpoints.empty()) - m_player->reset(); - else - m_player->respawnAt(m_session->respawnTransform()); - m_crWaterHandled = m_cr != nullptr; // mmMultiCR::HitWaterHandler drops the gold + // (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_vehicleFx) m_vehicleFx->reset(); // vehCar::Reset clearVehicleDamage(); @@ -2166,7 +2262,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; @@ -2188,13 +2284,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 @@ -2458,10 +2556,17 @@ 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). 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 @@ -2515,14 +2620,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. - 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); - 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(); @@ -2538,47 +2635,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. - if (m_regen && m_player->sim().regenerate()) - clearVehicleDamage(); - // 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}); + // mmPlayer::UpdateRegen while regeneration is on: the car's input + // carries it (net::kInputRegen; game::NetCarDriver). // 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, @@ -2589,9 +2652,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()) @@ -2599,50 +2661,93 @@ 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) + 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; + 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), {}}); + 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()); + } + 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. + // 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, {}}); + const double hostMs = ctx.netGame->frameTime() + m_netCarLead.value_or(0.0); + for (const auto& rc : m_remoteCars) { + if (!rc.hasState) continue; - m.type = Type::NewSet; - m.set = {set->bank, set->gold, set->hideout}; - } 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 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}); } - sendCr(ctx, m_cr->receive(m, ev.from, host)); + 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(); + 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)); 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, m_cr->goldPosition()); 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); @@ -2650,8 +2755,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) @@ -2665,10 +2768,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; - m_player->sim().damage.reset(); + // 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 { @@ -2689,12 +2793,37 @@ class RaceScreen final : public Screen { } case E::TimeUp: case E::PointLimit: - // 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) { @@ -2955,46 +3084,165 @@ 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. + // 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; + 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 + 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, 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 = 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; + 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. + 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); + 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. + 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); + }, + [&ctx](std::uint8_t player, const net::RulesStateMsg& state) { + ctx.netGame->sendRulesState(player, state); + }); + } + + // 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, @@ -3356,7 +3604,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( @@ -3466,6 +3721,21 @@ 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 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 -> // vehCar::Init; spawnPolice), after the subways (none in retail @@ -3544,15 +3814,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 +3846,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 +3859,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,20 +3876,37 @@ 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) + 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); + m_result.config.multiplayer ? &m_netDamage.own() : nullptr, + m_result.config.multiplayer ? Owner{{net::PropOwner::Player, m_netLocalId}} : Owner{}); + } } + // 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), + Owner{{net::PropOwner::Player, static_cast(id)}}); + } for (auto& o : m_opponents) if (o.fx) { o.fx->update(dt, o.sim->sim(), vehicleFxContext(o.sim->sim())); @@ -3622,7 +3915,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); @@ -3678,10 +3973,34 @@ class RaceScreen final : public Screen { m_player->sim().onImpactCallback = [this](const phys::CarImpact& impact) { playerImpact(impact); }; } + // OPENMM2_NET_TRACE: every player's car as this frame draws it, for the + // 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; + 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)) { + 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 // 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) + 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) @@ -3692,8 +4011,13 @@ 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 && !m_netReplaying) + if (auto* trace = m_netProps.trace()) // OPENMM2_NET_TRACE + 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 @@ -3835,12 +4159,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) @@ -3869,46 +4193,145 @@ 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() { - 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) - 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) + // 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 aiTime = netAiStateTime(ctx), bodyTime = netBodyStateTime(ctx); + std::vector players; + if (m_player) + players.push_back(m_player->sim().modelMatrix().m3); + 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. + 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); + } + } + + // 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. + // 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 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 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) + 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); + // 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. + 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); + const int target = cop.driver->target(); + const bool chasing = target > kNetPlayerTrackedId && + target - kNetPlayerTrackedId == static_cast(rc.id); + if (!within(id, d, 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; - 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; - if (m_trafficBodies->knock(car->id, impulse, point) && m_debugNetTraffic) - log::info("nettraffic: host knocks traffic car {} for player {} ({:.1f} m/s)", car->id, - ev.from, speed); + 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; } } @@ -3920,10 +4343,16 @@ 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(); - 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()) { @@ -3940,22 +4369,37 @@ 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); } 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) { + 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(); + m_stillBodies.prune(netBodyStateTime(ctx)); + 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); } @@ -3974,6 +4418,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()) @@ -3993,11 +4444,29 @@ 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); } - const std::uint32_t time = ctx.netGame->sessionTime(); + if (trace) + traceNetTraffic(ctx, trace, cars); + if (!send) + return; + // 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))); + // 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); @@ -4044,10 +4513,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; @@ -4070,52 +4542,70 @@ 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(); - for (int id : m_trafficProxies->takeHits()) { + 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. + 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); + 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"); 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 car = netTrafficCarTime(ctx, dt); // own on the host's clock + const double renderTime = netTrafficPresentTime(ctx, dt); m_trafficClient->update(renderTime); m_trafficRenderTime = renderTime; + 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 Vec3 me = m_player->sim().modelMatrix().m3; + game::traceClientView(trace, frame, car, me); + 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->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); + } + } if (const auto steps = m_trafficClient->lightSteps(renderTime)) m_ai->advanceLightsTo(*steps); m_netCars.clear(); 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) @@ -4132,13 +4622,37 @@ 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, netBodyInFull(ctx, c.id, c.generation)}); } 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; + // 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) { + 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(), 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); + } + } + if (m_trafficProxies) { + std::vector moving; + for (const ai::AmbientCar& c : m_netCars) + if (!m_netTrafficCars || + (c.goal != ai::AmbientGoal::RandomDrive && !m_netTrafficCars->simulated(c.id) && + !m_netTrafficCars->standing(c.id))) + 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. + 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)) @@ -4200,6 +4714,31 @@ 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(physicsDt(ctx, dt))) * 1000.0 : 0.0; + return ctx.netGame->frameTime() - lag; + } + // ... 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 { + 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 netTrafficCarTime(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). @@ -4225,6 +4764,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 @@ -4235,6 +4782,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(); @@ -4271,6 +4819,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( @@ -4306,14 +4858,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. @@ -4334,20 +4892,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; @@ -4385,42 +5098,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 @@ -4431,101 +5117,259 @@ 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; + // 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 (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. - m_remoteDrawn.clear(); - for (const auto& rc : ctx.netGame->remoteCars(static_cast(phys::kFixedSampleStep) * 1000.0)) + 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); }); - 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(); - // 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. MM2's network car was a simulated vehCar with a - // velocity of its own. - 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, 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, rc.damage, rc.time, rv.fresh, rv.impacts); } + dropRemoteCars(present); + 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& 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; + }(); + 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); + // 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). + sim.body.kinematic = false; + sim.body.kinematicMoves = false; + sim.body.resetCollider(); + sim.options.player = true; + sim.ownRandom = true; + // What it hits counts as hit by a player (as the simulated + // police): it knocks the shared traffic loose here too. + sim.body.player = 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(); + sim.options.player = false; + sim.ownRandom = false; + sim.body.player = false; + rv.predicted = false; + if (rc != m_remoteCars.end() && rc->hasState) + placeNetCar(rv, *rc); + } + if (rv.predicted && it != near.end()) { + rv.input = it->input; + 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); + } + } + } + // 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); + } + } + // 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). 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"; + }(); + 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 || 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); + } + // 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) + return true; + // 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. + 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) @@ -4533,6 +5377,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; @@ -4540,6 +5385,952 @@ 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); + orderPlayerCars(); + } + 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); + 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); + } + 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 reset its car (game::ResetRules, + // on this machine's simulation of it). + bool netCommandAllowed(const RemoteVehicle& rv, const net::CarCommand& c) const { + static const bool debug = std::getenv("OPENMM2_DEBUG_RESPAWN_MS") != nullptr; + game::ResetRules r; + r.placed = rv.placed && rv.sim; + r.lastMoveSeq = rv.lastMoveSeq; + r.wrecked = rv.sim && rv.sim->sim().damage.maxDamaged(); + r.debug = debug; + r.city = m_city->psdl.bounds; + return r.allows(c); + } + + // 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. + 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())); + 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); + // 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); + } + + // 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()) { + // 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; + 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(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::ClearDamage) + 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; + 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; + } + // 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.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. + 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 + // 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}); + // 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) + 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. + 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); + 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) { + if (rv.fx) + rv.fx->reset(); + if (rv.renderer) + rv.renderer->resetDamage(); + 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: 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 { + 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. + void reconcileNetCar(Context& ctx) { + if (!multiplayer(ctx) || ctx.netGame->isHost() || !m_player || !m_world) + return; + const auto updates = ctx.netGame->takeOwnCarStates(); + const net::Session::OwnCarUpdate* newest = nullptr; + std::uint32_t ack = 0; + for (const auto& u : updates) { + m_netWaiting.emplace_back(u.arrival, u.waiting); + if (u.hasOwn && u.ack > ack) { + newest = &u; + ack = u.ack; + } + 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 + // (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 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); + } + 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); + // 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->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(); + 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, + // where this frame put them (netTrafficReplay). + const NetTrafficReplay railReplay = netTrafficReplay(); + std::vector railsNow; + 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 + // 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) + 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( + *m_player, m_netDriver, *m_world, ack, newest->own, + [this] { + // 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); + recordNetCopSteps(true); + }, + [&](std::uint32_t seq) { + for (const auto& h : m_bodyHistory) { + if (h.seq != seq) + continue; + placeBodies(h.poses); + 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); + } + if (railsNow.empty() || railReplay != NetTrafficReplay::Predicted) + 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, [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; + 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). + 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); + // 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); + 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}{}{}{}{}; moved {:.4f} m", + ack, c.replayed, c.positionError, c.velocityError, c.rotationError, + 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 + // 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; + }; + 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) && !predictedBody(b)) + 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)) + continue; + 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; + } + } + + // 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 (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()) { + ctx.netGame->sendPlayerInput(*msg); + ++m_netInputsSent; + m_netInputBytes += net::encodeMessage(*msg).size(); + } + // The session time this machine's car's newest sample ended at, + // for the lead over the host (reconcileNetCar). + 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); + 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: 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 = 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 + // 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 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) + 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, rv.inputs.upcoming(net::kMaxUpcomingInputs)}); + } + 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, &rv.driver, pusherOf); + 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::stable_sort(near, {}, &std::pair::first); // ties: list order + if (near.size() > net::kMaxNearCars) + near.resize(net::kMaxNearCars); + 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, nullptr, pusherOf); + 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; + } + } + + // 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, {} 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) { + const auto& st = m_prediction.stats(); + log::info("netcars: client sent {} inputs ({:.0f} B/s); {} acks, {} corrections, {} samples " + "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, m_netReplayMs / seconds, m_netReplayWorstMs, + st.unreplayable, m_netDilation); + 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) 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.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; + } + } + 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 + // 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) { + // 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) {}; + 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; @@ -4577,7 +6368,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); } @@ -4601,7 +6392,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 @@ -4647,7 +6446,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) @@ -4656,11 +6455,36 @@ 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) { + // Simulated here: at this machine's car's time while the two may + // 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; + 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; + } 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; } 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; @@ -4694,7 +6518,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(); @@ -4713,6 +6537,188 @@ 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; + } + + // A client: what may set its props moving (the host decides what the + // 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; + } + + // 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) + 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) + } + + // 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 { + 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: 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{}", 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))); + } + } + double m_debugInputTime = 0.0; // OPENMM2_DEBUG_INPUT's phases void updatePlayer(Context& ctx, float dt) { @@ -4749,6 +6755,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.reverse ? 0.0f : c.throttle; + pedals.brake = c.reverse ? c.throttle : 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); @@ -4770,15 +6785,36 @@ 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); + 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.) @@ -4868,7 +6904,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)) { @@ -5266,6 +7313,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; @@ -5308,6 +7362,40 @@ 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::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 + 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 + // (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::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 @@ -5316,21 +7404,80 @@ 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) 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 - 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 + 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) + // 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 + 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 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 + // 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 + 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) + // 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 + // (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()). @@ -5344,8 +7491,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; @@ -5353,8 +7502,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 @@ -5368,6 +7515,9 @@ 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) + 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/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/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..7417083 --- /dev/null +++ b/src/core/Libm.h @@ -0,0 +1,66 @@ +#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 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, +// 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/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/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/TrafficBodies.cpp b/src/game/TrafficBodies.cpp index 0fddde3..5f10b20 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; @@ -354,11 +361,29 @@ 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; } +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) @@ -382,6 +407,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(); @@ -425,7 +452,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 +580,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 +621,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) @@ -646,24 +703,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). @@ -676,7 +758,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); @@ -691,20 +773,92 @@ void TrafficBodies::instancesIn(int room, std::vector& out) con out.push_back(car); } -bool TrafficBodies::knock(int carId, const Vec3& impulse, const Vec3& point) { +std::optional TrafficBodies::bodyState(int carId) const { + const RailCar* r = findRailCar(carId); + // 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; + 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->active || !r->data || !r->box || !r->collidable || !r->present || r->held) + if (!r || !r->present || !r->data || !r->box) return false; - phys::Body* body = r->attachEntity(); - m_world.addNewMover(body); - body->ics.applyImpulse(impulse, point); + 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; } -float TrafficBodies::massOf(int carId) const { - const RailCar* r = findRailCar(carId); - return r && r->data ? r->data->mass : 0.0f; +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; +} + +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 { diff --git a/src/game/TrafficBodies.h b/src/game/TrafficBodies.h index 95350e2..796a83d 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 @@ -23,6 +24,7 @@ #include #include #include +#include #include #include #include @@ -56,9 +58,40 @@ 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; + } + // 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 // 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; @@ -98,18 +131,31 @@ 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; + // 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); + // 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); } + // 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; } @@ -129,6 +175,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); @@ -137,7 +185,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/bangers/BangerSet.cpp b/src/game/bangers/BangerSet.cpp index b012cf3..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 { @@ -133,6 +134,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 +184,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 +207,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; @@ -223,12 +227,19 @@ 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.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); } bool banger = false; // lvlInstance flag 1: a placed prop still standing bool listed = false; // in m_rooms[room] @@ -413,6 +424,13 @@ 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 (ghostStands(g) && m_instances[g.prop].room == room && + m_instances[g.prop].state == State::Gone) + out.push_back(m_props[g.prop].get()); } // --- Setup ----------------------------------------------------------------------------------- @@ -530,7 +548,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 { @@ -545,7 +568,12 @@ 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_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. if (Active* a = activeOf(i)) @@ -556,6 +584,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) { @@ -569,12 +613,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; } @@ -624,6 +681,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 @@ -715,6 +773,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). @@ -767,7 +829,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 +839,14 @@ 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) + // 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) { // A hit instance from the ring takes the prop's place in its room @@ -801,6 +871,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 +928,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) { @@ -964,6 +1038,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. @@ -1017,7 +1096,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 +1114,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 +1160,334 @@ 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::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 && !replayGhost(i)) || 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::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, [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; + 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::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) + return true; + return false; +} + +void BangerSet::setReplica(std::function localToucher) { + m_replica = true; + m_localToucher = std::move(localToucher); +} + +bool BangerSet::acceptsFrom(std::size_t i, const phys::Instance& other) const { + if (!m_replica) + return true; + if (m_localToucher && m_localToucher(other)) + return true; + // 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). + // 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) { + // 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; + 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)) + 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, + const Vec3& velocity, const Vec3& spin) { + 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.mirrorVelocity = velocity; + inst.mirrorSpin = spin; + inst.state = State::Hit; + Prop& prop = *m_props[i]; + prop.audioId = spec.data->colliderId; + 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)) + 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; + 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; + 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 +1522,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..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" @@ -28,6 +29,7 @@ #include #include #include +#include #include namespace mm2::game::bangers { @@ -69,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 @@ -122,6 +125,20 @@ 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), 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; }; // The placed props (in add() order) and the hit instances (created as // the ring first hands them out). @@ -138,6 +155,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 +169,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 +195,132 @@ 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; + } + // 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. + 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. + 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, 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; + + // 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 (and what its pieces knock). + 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`, 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, + 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(); } + + // 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; @@ -201,8 +347,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(std::size_t i, const phys::Instance& other) const; const BangerDataLibrary& m_data; phys::World* m_world = nullptr; @@ -219,7 +366,25 @@ 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 + 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; + 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 + 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/Autopilot.cpp b/src/game/net/Autopilot.cpp new file mode 100644 index 0000000..c0280b4 --- /dev/null +++ b/src/game/net/Autopilot.cpp @@ -0,0 +1,134 @@ +#include "game/net/Autopilot.h" + +#include "ai/Opponent.h" +#include "ai/World.h" +#include "city/Race.h" +#include "core/Libm.h" +#include "core/Log.h" +#include "core/StringUtil.h" + +#include +#include +#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); + // 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) + 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; +} + +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; + 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; + 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) + 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, {}); + 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 = 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); + c.reverse = behind; + c.throttle = behind ? 0.5f : (speed < 20.0f ? 1.0f : 0.0f); + c.brake = 0.0f; + } + 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..b87c5a4 --- /dev/null +++ b/src/game/net/Autopilot.h @@ -0,0 +1,69 @@ +#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 +#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); + // 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); + +private: + 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/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 4f580d9..31c3f60 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" @@ -8,6 +10,7 @@ #include "net/Session.h" #include +#include #include #include #include @@ -229,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 @@ -688,6 +695,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; @@ -705,8 +720,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; @@ -726,6 +744,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(); @@ -743,6 +763,57 @@ 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; } + +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", machineMs(), + 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", machineMs(), + 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", machineMs(), m_frameTime, seq, + 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); @@ -788,6 +859,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 { @@ -803,6 +893,38 @@ 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; +} + +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{}; +} + +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 37af7af..d6f0a25 100644 --- a/src/game/net/NetGame.h +++ b/src/game/net/NetGame.h @@ -11,12 +11,15 @@ #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/Session.h" #include "net/Snapshot.h" #include "net/Transport.h" #include +#include #include #include #include @@ -282,6 +285,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). @@ -292,6 +299,32 @@ 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; + // The same trace's divergence lines (docs/review/multiplayer-desync-cars.md; + // `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). + 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); + // 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/PlayerCarState.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 @@ -301,9 +334,29 @@ 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; + // --- 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(); + // 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. + 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); @@ -322,6 +375,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/NetProps.cpp b/src/game/net/NetProps.cpp new file mode 100644 index 0000000..8375845 --- /dev/null +++ b/src/game/net/NetProps.cpp @@ -0,0 +1,304 @@ +// 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 +#include + +namespace mm2::game { +namespace { + +// The ring grows rather than wrap onto a prop knocked this recently. +constexpr double kRingBusySeconds = 10.0; + +} // namespace + +// --- Trace --------------------------------------------------------------------------------------- + +std::unique_ptr PropTrace::of(NetGame& net) { + return net.tracing() ? std::make_unique(net) : nullptr; +} + +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::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]; + 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) { + m_net.traceLine(std::format("k {:.0f} {} {} {}", t, prop, model, cause)); +} + +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 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) { + constexpr double kTick = 250.0; + if (m_next < 0.0) + m_next = std::ceil(t / kTick) * kTick; + if (t < m_next) + return false; + m_net.traceLine(std::format("t {:.0f} {:.1f}", m_next, t)); + m_next += kTick * (std::floor((t - m_next) / kTick) + 1.0); + return true; +} + +void PropTrace::broken(const bangers::BangerSet& set) { + std::string line = "b"; + const std::size_t n = placedProps(set); + for (std::size_t i = 0; i < n; ++i) + if (!set.standing(i)) + line += std::format(" {}", i); + m_net.traceLine(line); +} + +void PropTrace::shown(const net::PropDescriptor& what, const Mat34& m, bool moving, bool local) { + 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) { + m_net.traceLine(std::format("d {} {:.4f} {:.0f}", name, level, dents)); +} + +// --- NetProps ------------------------------------------------------------------------------------ + +NetProps::NetProps() = default; +NetProps::~NetProps() = default; + +void NetProps::setup(NetGame& net, bangers::BangerSet& set, + 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) + 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") { + log::info("race: props, each machine its own (OPENMM2_NETPROPS=local; {} placed, catalog {:08x})", + 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 { + 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(); + 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; + if (const auto knocks = ev.as()) + m_client->receiveKnocks(*knocks); + } + 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)) + 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); + 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, net.localId(), m_carOfToucher); + 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, {:.0f}% of them near; {} deferred), ring {}, {} " + "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, + s.messages ? static_cast(s.fullStates) / static_cast(s.messages) : 0.0, + 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, + static_cast(m_fullBytes) / seconds / static_cast(clients), + s.fullPieces); + s = {}; + m_sentBytes = m_sentMessages = m_sentEvents = m_eventBytes = m_fullBytes = 0; + } + if (m_client) { + const auto& s = m_client->stats(); + log::info("netprops: client {} messages ({} late, {} refused), host knocks {}, predicted {} " + "(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.undoneEarly, 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..0186454 --- /dev/null +++ b/src/game/net/NetProps.h @@ -0,0 +1,159 @@ +#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/PlayerCars.h" +#include "game/net/PropSync.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace mm2::game { + +class NetGame; +struct NetGameEvent; + +// 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 simulated cars' damaging impacts ("i +// "). +class PropTrace { +public: + // 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 (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); + // 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); + 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: + NetGame& m_net; + 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). + // `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, 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 + // 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); + + // 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(); } + 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 + 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; + 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/NetRules.cpp b/src/game/net/NetRules.cpp new file mode 100644 index 0000000..7ff0f45 --- /dev/null +++ b/src/game/net/NetRules.cpp @@ -0,0 +1,443 @@ +#include "game/net/NetRules.h" + +#include "core/Log.h" +#include "game/net/RulesTrace.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; + 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) { + 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: + 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)); + else + s->remoteFinished(nameOf(d.player), toSeconds(d.ms)); + } + return; + case RuleDecision::TimedOut: + if (std::exchange(m_timedOut, true)) + return; + if (s) + s->netTimedOut(); + return; + case RuleDecision::AllCounted: + if (std::exchange(m_allCounted, true)) + return; + 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, bool decisions) const { + net::RulesMsg m; + m.race = m_setup.race; + m.seq = ++c.seq; + 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) { + 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 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()); + (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))); + } + 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)); + } + 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)); + 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, + const SendState& sendState) { + 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); + } + 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, p.hitShown[traced] != 0); + } + // 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; + 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); + 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)); + } + // 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) { + 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; + // 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; + 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; cops && newest) { + 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 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, bool progress) { + session::Session* s = m_setup.session; + if (!s) + return; + 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; + } + // 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..ac289eb --- /dev/null +++ b/src/game/net/NetRules.h @@ -0,0 +1,180 @@ +#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, 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 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. + +#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 +#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 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. + static constexpr std::size_t kHitWindow = 64; + static constexpr std::size_t kStateHitWindow = 16; + + 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; } + 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` + // 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); + // 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); + // 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). + // `sendState(player, state)`: a race's state alone, unreliably + // (NetGame::sendRulesState). + using Send = std::function payload)>; + 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; } + + // --- 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 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) + }; + 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 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::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); + // `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; + 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 (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; + 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; + std::size_t m_confirmedHits = 0; // client: the host's count of its car's hits (the trace's RC) + Stats m_stats; +}; + +} // namespace mm2::game diff --git a/src/game/net/NetTrafficCars.cpp b/src/game/net/NetTrafficCars.cpp new file mode 100644 index 0000000..ff000a3 --- /dev/null +++ b/src/game/net/NetTrafficCars.cpp @@ -0,0 +1,143 @@ +// A shared-traffic client's received cars as TrafficBodies' traffic (OpenMM2 +// extra). See NetTrafficCars.h. +#include "game/net/NetTrafficCars.h" + +#include +#include +#include + +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); + 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 (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. + ++m_stats.withdrawn; + handOver(c.id, k, false); + m_knocks.erase(it); + } else { + keepLocal(c, k); + continue; + } + } + // 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) { + 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}); +} + +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() || 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; + 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; + 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; } + +} // namespace mm2::game diff --git a/src/game/net/NetTrafficCars.h b/src/game/net/NetTrafficCars.h new file mode 100644 index 0000000..d824cfa --- /dev/null +++ b/src/game/net/NetTrafficCars.h @@ -0,0 +1,123 @@ +#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, 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 +// 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 +// 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" + +#include +#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; + // 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 + // 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 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 = 3.0f; + + // 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); } + // 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 + 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; + // A car knocked loose here knocks the next one loose too, as on the host. + bool bodiesHitAsPlayer() const override { return true; } + +private: + struct Knock { + 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::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; + double m_now = 0.0; + Stats m_stats; +}; + +} // namespace mm2::game diff --git a/src/game/net/PlayerCars.cpp b/src/game/net/PlayerCars.cpp new file mode 100644 index 0000000..885fedb --- /dev/null +++ b/src/game/net/PlayerCars.cpp @@ -0,0 +1,736 @@ +// 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 "core/Libm.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; +} + +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(); + 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::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; + 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; + // 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; +} + +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 NetCarDriver* driver, + const PusherOf& pusherOf) { + 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; + 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) + 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); + // 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, const PusherKey& pusherKey) { + 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; + 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]; + 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); + if (s.splash) + sim.splash.activate(s.waterLevel); + else + sim.splash.deactivate(); + sim.splash.buoyancy = s.buoyancy; + // 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 { + 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); + case net::CarCommandKind::Reset: + case net::CarCommandKind::RespawnAt: break; + } + // The water and the fall are the host's own (NetCarDriver::checkWater). + if (!debug || !placed || c.seq < lastMoveSeq + kMoveInterval) + return false; + return c.kind != net::CarCommandKind::RespawnAt || inCity(c.position); +} + +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::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; + 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; +} + +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, const PusherKey& pusherKey) { + if (c.rebase) + c.rebase(); + else if (c.car && c.state) + applyOwnCarState(*c.car, *c.state, pusherKey); +} +// A player's car's input for the `k`th sample after the acknowledged one: +// the host's for it, then the last again. +const net::CarInputFrame& companionInput(const CarPrediction::Companion& c, std::size_t k) { + if (k < c.upcoming.size()) + return c.upcoming[k]; + return c.upcoming.empty() ? c.input : c.upcoming.back(); +} +void driveCompanion(const CarPrediction::Companion& c, std::size_t k) { + if (c.drive) + c.drive(); + else if (c.driver && c.car) + c.driver->apply(*c.car, companionInput(c, k)); +} + +} // namespace + +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& beforeEach, + std::span companions, + const std::function& companionsAt) { + 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; + 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 || + out.water; + const std::size_t later = m_history.size() - 1 - index; + 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)); + return out; + } + // 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 && replay; + 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 = (companionVelocity(c) - predicted.linearVelocity).mag() * span; + const float reach = m_options.companionReach + closing; + 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) { + rebaseCompanion(c, pusherKey); + bodies.push_back(&companionBody(c)); + } + // (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) + driveCompanion(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); + 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); + // 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, pusherKey); + driver.setWaterTime(host.waterTime); + save(base, car, driver); + } + // 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(&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(&companionBody(c)); + for (const Companion& c : companions) + rebaseCompanion(c, pusherKey); + 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() - + static_cast(later) * 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) + NetCarDriver::command(car, c); + driver.apply(car, e.input); + for (const Companion& c : companions) + driveCompanion(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. + for (const auto& c : m_pending) + NetCarDriver::command(car, c); + out.corrected = differs; + out.rebased = !companions.empty(); + out.moved = car.sim().modelMatrix().m3 - before; + if (differs) + ++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 = libm::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..5f101ba --- /dev/null +++ b/src/game/net/PlayerCars.h @@ -0,0 +1,369 @@ +#pragma once + +// The players' cars in a network race, simulated by the host (OpenMM2; see +// 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 +// 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), 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 +// 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/PlayerCarState.h" +#include "net/Snapshot.h" +#include "phys/World.h" + +#include +#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). + // 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 ------------------------------------------------------------------------ + +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; + // 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; + + // 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(); + // 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; } + std::uint64_t skipped() const { return m_skipped; } + +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::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; +}; + +// 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: 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). 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, 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 + bool wrecked = false; // past its maximum damage (mmPlayer::IsMaxDamaged) + bool debug = false; + Aabb city; // the city's bounds (200 m round them allowed) + + static constexpr std::uint32_t kMoveInterval = 15; // samples + bool allows(const net::CarCommand& c) const; +}; + +// 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). `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 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. +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 + // 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) {} + + // 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 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 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; + + 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, 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) + }; + // 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; + // 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; + // 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 + // there and runs the later samples again on their inputs (alone: + // 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). With `companions` the + // 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 = {}, + const std::function& companionsAt = {}); + + // 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/game/net/PropSync.cpp b/src/game/net/PropSync.cpp new file mode 100644 index 0000000..93eaad4 --- /dev/null +++ b/src/game/net/PropSync.cpp @@ -0,0 +1,1021 @@ +// 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; + +// 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; + bool moving = false; + bool slow = false; +}; + +} // 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 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); + std::vector current(slots); + for (std::size_t k = 0; k < slots; ++k) { + const auto& inst = set.instances()[ring[k]]; + RingSlot& 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; + } + 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; + + // 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])) { + 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; + } + 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)); + } + 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) + : 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(); + 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(); + } + } + } +} + +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; + 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; + } +} + +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(); + 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, const HostCar& hostCar, + std::optional ownCarAt) { + 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; + } + // 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). + // 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; + 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; + 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 (!p.confirmed && (passed || now - p.at >= timeout)) { + set.restoreStanding(it->first); + ++m_stats.undone; + 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, + 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(); + 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); + 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) && (full || 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 && !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); + 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.hostMoved = false; + } + slot.local = true; + 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) { + 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); + // 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) { + 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, s.linearVelocity, s.angularVelocity); + 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]; + // 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) + 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..c0ce96a --- /dev/null +++ b/src/game/net/PropSync.h @@ -0,0 +1,385 @@ +#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 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 (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. + +#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 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 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{}) {} + 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): 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); 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; } + +private: + struct SlotSeen { + 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; + }; + 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; +}; + +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; 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 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; + 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; + 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; + // 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. + 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 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 + // `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, {}); } + + // 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&)>; + // 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. `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; } + // 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 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 + }; + 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 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 + 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; + 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; + 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; + 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; + Stats m_stats; +}; + +} // namespace mm2::game diff --git a/src/game/net/RulesTrace.cpp b/src/game/net/RulesTrace.cpp new file mode 100644 index 0000000..4f3e765 --- /dev/null +++ b/src/game/net/RulesTrace.cpp @@ -0,0 +1,69 @@ +#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, bool shown) { + if (f) + 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) { + 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 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, + gold.x, gold.y, gold.z); +} + +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..bb52c50 --- /dev/null +++ b/src/game/net/RulesTrace.h @@ -0,0 +1,59 @@ +#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 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 +// 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 +// 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 +#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, 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); + +} // namespace mm2::game diff --git a/src/game/net/TrafficPrediction.cpp b/src/game/net/TrafficPrediction.cpp new file mode 100644 index 0000000..f349b9c --- /dev/null +++ b/src/game/net/TrafficPrediction.cpp @@ -0,0 +1,166 @@ +// The shared traffic predicted forward on a client (OpenMM2 extra). See +// TrafficPrediction.h. +#include "game/net/TrafficPrediction.h" + +#include "core/Libm.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 = 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 = libm::cos(angle), s = libm::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 * libm::sin(angle * 0.5f) / angle) : s; +} + +} // namespace + +float groundHeading(const Vec3& forward) { return libm::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, 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 = 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) + 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; + 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; +} + +// --- 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) { + PredictedPose out; + dt = std::max(dt, 0.0f); + // 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 ((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 = (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); + 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..5f22690 --- /dev/null +++ b/src/game/net/TrafficPrediction.h @@ -0,0 +1,112 @@ +#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, 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, 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). + // 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; + float ground = 0.0f; + double time = 0.0; + RailMotion motion; + bool seen = false; + }; + 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); + +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 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. +PredictedPose predictBody(const Mat34& transform, const Vec3& velocity, const Vec3& spin, float dt); + +} // namespace mm2::game diff --git a/src/game/net/TrafficProxies.cpp b/src/game/net/TrafficProxies.cpp index 8b0692b..210381e 100644 --- a/src/game/net/TrafficProxies.cpp +++ b/src/game/net/TrafficProxies.cpp @@ -75,21 +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::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; + 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 6af35a4..b41b7d7 100644 --- a/src/game/net/TrafficProxies.h +++ b/src/game/net/TrafficProxies.h @@ -1,20 +1,21 @@ #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" #include "phys/Bound.h" #include "phys/Level.h" +#include #include #include #include @@ -35,9 +36,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; @@ -52,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 diff --git a/src/game/net/TrafficSync.cpp b/src/game/net/TrafficSync.cpp index ec2aef2..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 @@ -30,6 +31,18 @@ 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); + 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; @@ -94,10 +107,12 @@ net::AmbientStateMsg TrafficHost::build(const TrafficViewer& viewer, std::span candidates; const float enter2 = m_options.enterRadius * m_options.enterRadius; @@ -114,42 +129,80 @@ 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; + 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; + 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); + // 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; - net::AmbientEntity e = toEntity(*c.car); - // A far car on its rail, already known to the client: its state - // every other message, the ids alternating between messages. - const bool full = c.chasing || c.distance2 < near2 || !set.contains(c.car->id) || - c.car->kind != net::AmbientKind::Traffic || - (c.car->flags & net::kAmbientOffRail) != 0 || - ((sequence + static_cast(c.car->id)) & 1u) == 0; - if (!full) - e.hasState = false; - const std::size_t bits = net::ambientEntityBits(e); + 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; } @@ -177,6 +230,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,6 +264,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) || + !std::isfinite(e.accel) || !std::isfinite(e.curvature) || !std::isfinite(e.groundSpeed) || !listed.insert(id).second) { ++m_stats.refused; continue; @@ -263,6 +321,10 @@ 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 = 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; @@ -296,19 +358,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(libm::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; @@ -344,6 +451,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); @@ -372,6 +480,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; @@ -456,12 +565,94 @@ 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::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()) 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 4973352..4ee6b5a 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,9 +65,13 @@ 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 + // 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; @@ -98,10 +103,19 @@ 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; - // The message's size, bytes (the header included): one unfragmented - // UDP datagram. - std::size_t maxBytes = 1100; + float fullRateRadius = 120.0f; + // ... and beyond this every fourth (the client predicts it the while). + 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. + float keepMetres = 25.0f; }; TrafficHost() = default; @@ -128,10 +142,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; @@ -147,8 +170,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 { @@ -171,9 +196,10 @@ 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 + 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. @@ -181,8 +207,21 @@ 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; + // 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; } @@ -195,6 +234,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; } @@ -218,10 +258,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 new file mode 100644 index 0000000..3563b76 --- /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" + +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)); +} + +// 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; + 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); +} + +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, generationOf(generation), player); +} + +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 new file mode 100644 index 0000000..607c851 --- /dev/null +++ b/src/game/net/TrafficTrace.h @@ -0,0 +1,45 @@ +#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 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 +// 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 +// 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 +// 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 +// too), its drawing `off` metres from them then +// +// (fx, fz) is the car's forward direction (-m2) on the ground plane. + +#include "game/net/TrafficSync.h" + +#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 traceHandover(std::FILE* f, double time, int id, bool confirmed, float off); + +} // namespace mm2::game diff --git a/src/game/session/CopsAndRobbers.cpp b/src/game/session/CopsAndRobbers.cpp index dbf69fe..975ca0d 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) { @@ -309,10 +318,253 @@ 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) { + // The carrier's own impacts (its vehCarDamage::ApplyImpact). + for (const auto& im : impacts) { + 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); + 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, 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; + // 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. + 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 42ffe84..72c8120 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 { @@ -79,15 +83,33 @@ 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 // 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 @@ -112,11 +134,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 @@ -125,9 +151,60 @@ 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); + // --- 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, 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); 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 + // 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; } @@ -158,6 +235,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; @@ -173,6 +253,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/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/RaceReferee.cpp b/src/game/session/RaceReferee.cpp new file mode 100644 index 0000000..92d6da7 --- /dev/null +++ b/src/game/session/RaceReferee.cpp @@ -0,0 +1,236 @@ +#include "game/session/RaceReferee.h" + +#include "game/session/RaceSetup.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)); + 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 + // 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; + } +} + +std::optional RaceReferee::lastCleared(std::uint8_t id) const { + const Player* p = player(id); + if (!p) + return std::nullopt; + return p->wp.lastCleared; +} + +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..d8c60a0 --- /dev/null +++ b/src/game/session/RaceReferee.h @@ -0,0 +1,135 @@ +#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 + 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; + 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; } + // 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; + +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/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/game/session/Session.cpp b/src/game/session/Session.cpp index 4b95a85..104645d 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 {}; } } @@ -209,7 +214,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; @@ -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; @@ -275,8 +283,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 +316,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 +516,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); + m_wp.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); -} - -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; - 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. +void Session::displayCleared(int index, int count) { + // mmWaypoints::DisplayHUDMessage (the waypoint was marked cleared). + 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. 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,118 +552,247 @@ void Session::displayCleared(int index) { } } +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); + } +} + +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 + } + } +} + void Session::updateWaypoints(const PlayerState& player) { + std::vector 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 WaypointRule r = rule(); - if (n < 2 || r == WaypointRule::None || m_wp.stopped) + 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); + } + // 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. + 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) { + if (!netRules()) 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; - }; + m_rank = std::max(1, place); + m_netRacerCount = std::max(1, racers); +} - 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; +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); + // (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) { + 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 { - m_wp.finished = true; // the last checkpoint ends a Blitz + setMessage(nt.raceOver, "Race over", 5.0f, false); + endRace(false, false, 3.0f, PlayerHold::Undrivable); } } - break; + return; } - case WaypointRule::CheckpointRace: - case WaypointRule::AnyOrderEnd: { - const int idx = firstHit(); - if (idx < 0 || m_wp.finished) + // 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; - 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; } - 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; - } - break; - case WaypointRule::None: 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 && m_phase != Phase::Countdown)) + 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); } } @@ -844,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(); @@ -870,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: @@ -1076,7 +1144,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; } @@ -1097,6 +1166,15 @@ 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()); @@ -1142,8 +1220,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; } @@ -1205,7 +1289,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. @@ -1742,8 +1827,10 @@ void Session::updateRules(float dt, const PlayerState& player, std::span @@ -67,6 +68,14 @@ struct SessionOptions { // Multiplayer: this machine hosts the session (the modes' host and client // lines differ: e.g. "finished in" 152 / 150). bool netHost = false; + // Multiplayer races under the host's authority (OpenMM2, + // docs/multiplayer.md "Rules"): the session predicts the player's + // checkpoints from its car and takes the host's word on them + // (applyNetProgress); the finish, the standings, the finish timeout and + // the end are the host's (netFinished, setNetStanding, netTimedOut, + // netAllCounted). Without it every machine decides its own (MM2's + // peers: remoteFinished and NetFinished). + bool netRules = false; }; // The game's cheat flag (bCheating): mmGame::SendChatMessage's "/blubber" @@ -246,6 +255,9 @@ class Session { Vec3 position; // the car's (inertial) position bool present = true; // its car is in the race (mmNetObject +0x118 / +0x11c) bool finished = false; + // SessionOptions::netRules: its icon's number as the host ranks it + // on this machine (0 no icon). + int hostPlace = 0; }; void setNetRacers(std::vector racers) { m_netRacers = std::move(racers); } // Another player's finish (or kNetDnf) arrived (mmMulti*::GameMessage @@ -255,29 +267,58 @@ 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; } + // 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: - // 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 +343,6 @@ class Session { void resetRace(); void beginEvent(int index); - void resetWaypoints(); void updateCountdown(float dt); void enableLessonOpponents(); void go(); @@ -311,10 +351,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 +389,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; @@ -421,6 +462,18 @@ 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 m_netWord = false; // showing what the host's word brought + bool netRules() const { return m_options.netRules && multiplayer(); } // Crash course. int m_lessonEvent = 0; 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 new file mode 100644 index 0000000..496dde1 --- /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 diff --git a/src/net/AmbientState.h b/src/net/AmbientState.h index 21c61fe..fd01092 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. @@ -34,11 +36,13 @@ 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 = 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; 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,16 @@ 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; + // 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 @@ -139,7 +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 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 = {}; @@ -207,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/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/PlayerCarState.h b/src/net/PlayerCarState.h new file mode 100644 index 0000000..1922cbd --- /dev/null +++ b/src/net/PlayerCarState.h @@ -0,0 +1,458 @@ +#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); +// 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). + +#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, + 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 = 0x7f; +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, +}; +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; + // 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, 7); + 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), 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; + 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) + // 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. + Vec3 force, torque; + std::array tireResistance{}; + bool contact = false; + Vec3 linearImpulse, angularImpulse, linearPush, turnForce, framePush; + // 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 + // 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; +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); + // (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) + 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 = {}; + } + 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) { + 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) && + 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.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) + 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], o.buoyancy, + o.waterTime}) + good = good && std::abs(v) <= kOwnStateMaxValue; + good = good && std::abs(o.waterLevel) <= kOwnStateMaxCoordinate; + if (!good) + return s.fail(); + } + 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) + // A client's car: the inputs the host holds for its next samples, in + // 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; +}; +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; + 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; + // 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; + +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); + } + 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) + 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; + 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(); +} + +} // namespace mm2::net diff --git a/src/net/PropState.cpp b/src/net/PropState.cpp new file mode 100644 index 0000000..02ab1a1 --- /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, std::int32_t previous) { + WriteStream s; + PropSlot copy = p; + serializePropSlot(s, copy, previous); + 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..f35e372 --- /dev/null +++ b/src/net/PropState.h @@ -0,0 +1,426 @@ +#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, 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 -> 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 +// 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/PlayerCarState.h" +#include "net/Protocol.h" + +#include +#include +#include +#include + +namespace mm2::net { + +inline constexpr std::uint32_t kMaxPropIds = 32768; // placed props 0..32767 +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 +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(); +} + +// `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, std::int32_t previous = -1) { + std::int32_t slot = p.slot, generation = p.generation; + 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); + 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); + 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 (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 +// (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(); +} + +// 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 8d0fbed..187821a 100644 --- a/src/net/Protocol.h +++ b/src/net/Protocol.h @@ -30,7 +30,37 @@ 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/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/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 +// on its branch.) +// 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). +// 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. +// 16: 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. (13 was its number on its branch; 15 the players' cars'.) +// 17: CarStates after every host frame that ran a sample (up to 60 a second), +// three near cars within 60 m (as many in full as fit one packet, in turn), +// 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.) +// 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. @@ -93,7 +123,13 @@ enum class MsgType : std::uint8_t { AmbientState, // net/AmbientState.h RaceLoaded, RaceStart, - Last = RaceStart, + PlayerInput, // net/PlayerCarState.h + CarStates, + PropState, // net/PropState.h + TrafficFull, // net/TrafficFull.h + RulesState, // net/RulesState.h (protocol 14) + PropFull, // net/PropState.h (protocol 18) + Last = PropFull, }; // Sent as ENet disconnect data and in Reject/PlayerLeft messages. diff --git a/src/net/RulesState.h b/src/net/RulesState.h new file mode 100644 index 0000000..ab28551 --- /dev/null +++ b/src/net/RulesState.h @@ -0,0 +1,231 @@ +#pragma once + +// The host's rules in a network race (OpenMM2's own protocol, version 10; 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(); +} + +// 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 0a320d0..d00b2ff 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/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; // 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 @@ -235,6 +239,7 @@ void Session::resetState() { m_lastSnapshotSent = 0; m_lastPlayoutUpdate = -1.0; m_ambientStates.clear(); + m_trafficFull.clear(); } void Session::leave() { @@ -758,6 +763,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 +1102,34 @@ 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()); + // 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 (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)}); + return; + } case MsgType::GameEvent: { GameEventMsg m; if (decodeMessage(data, m)) @@ -1109,6 +1155,46 @@ 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::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; + 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; + } + 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; } } @@ -1250,13 +1336,20 @@ 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, + VehicleSnapshot& out) const { + return sampleRemoteAt(playerId, sessionTime, out, m_config.maxExtrapolationMs); } -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, 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, @@ -1290,6 +1383,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) @@ -1301,6 +1411,54 @@ 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::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) + 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; +} + +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 45b2e6a..91b5eb0 100644 --- a/src/net/Session.h +++ b/src/net/Session.h @@ -17,8 +17,12 @@ #include "net/AmbientState.h" #include "net/ClockSync.h" #include "net/Discovery.h" +#include "net/PlayerCarState.h" +#include "net/PropState.h" +#include "net/RulesState.h" #include "net/Protocol.h" #include "net/Snapshot.h" +#include "net/TrafficFull.h" #include "net/Transport.h" #include @@ -228,8 +232,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, @@ -245,6 +252,39 @@ 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/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 + // (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 + // 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, {}); } + 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). @@ -253,6 +293,39 @@ 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 + // 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; + // 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 + // 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 @@ -267,7 +340,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 +433,12 @@ 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 + 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 }; // 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..47eba6b --- /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/PlayerCarState.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/src/net/VehicleDamage.h b/src/net/VehicleDamage.h index 9dad166..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" @@ -40,8 +41,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 @@ -87,13 +93,13 @@ 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) { 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/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/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) 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/Level.h b/src/phys/Level.h index 5fd5d68..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 @@ -68,6 +69,16 @@ 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; } + // 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/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/World.cpp b/src/phys/World.cpp index a131e13..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); } @@ -522,6 +532,9 @@ void World::step(float dt) { return; if (m_stepObserver) m_stepObserver(); + if (m_beforeSample) + m_beforeSample(); + m_replayBodies.clear(); const float invDt = 1.0f / dt; // datTimeManager::SetTempOverSampling: Seconds is the sample's length. sampleTime() = {dt, invDt}; @@ -618,6 +631,197 @@ 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; + 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) + 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; + m_replaying = false; + m_replayTime += static_cast(dt); +} + +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 + // 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; + 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 the world is not changed. A simulated body (a + // 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(); + 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) { + 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(); + 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); + else if (entity) + m_tempB.init(boundB, &m_tempMatrixB, nullptr); + else + m_tempB.initStatic(boundB, &m_tempMatrixB); + m_tempB.id = b.audioId; + 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; + 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); + // 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); + } + return true; +} + +void World::beginReplay(double from) { + m_replayBodies.clear(); + m_replayTime = from; } bool World::trivialCollide(const Instance& a, const Instance& b) const { @@ -727,6 +931,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/src/phys/World.h b/src/phys/World.h index b9123dd..3ce07c6 100644 --- a/src/phys/World.h +++ b/src/phys/World.h @@ -18,6 +18,7 @@ #include #include #include +#include #include namespace mm2::phys { @@ -197,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 @@ -238,6 +247,36 @@ 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); + // 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), 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; bool probe(const Vec3& a, const Vec3& b, RayHit& hit) const override; // dgPhysManager::Collide(segment, mask 0x20) as vehWheel::ComputeDwtdw @@ -312,6 +351,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 +364,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 @@ -332,6 +374,14 @@ 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) + 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/src/phys/vehicle/CarSim.cpp b/src/phys/vehicle/CarSim.cpp index 67661e9..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) { @@ -331,6 +332,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 +468,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/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 be8fe01..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 @@ -179,6 +180,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); } @@ -220,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&) { @@ -247,7 +286,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/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/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/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 +} 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/game/test_determinism.cpp b/tests/game/test_determinism.cpp new file mode 100644 index 0000000..49c0902 --- /dev/null +++ b/tests/game/test_determinism.cpp @@ -0,0 +1,590 @@ +// 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, 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" +#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/Material.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 +#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; +} + +// 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}, 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. + 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::uint8_t m_ground; + 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. +// 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 + } 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 + wobble; + } else if (t > 38.0f) { + steerTarget = -0.3f + wobble; + } + 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 + float racerProgress = 0.0f; // metres the racer drove along its course +}; + +ScenarioRun runScenario() { + ScenarioRun run; + // Props (from memory) and the physics world. + vfs::Vfs files; + files.mount(bangerFiles()); + game::bangers::BangerDataLibrary bangerData(files); + const phys::MaterialTable table = materials(); + ScenarioLevel level(table.find("cobbles")); + phys::World world(table); + 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; + + // 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) { + 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); + 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); + 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())); + 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()) { + 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; + 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}; 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, 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, 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 +// 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); + 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]; + 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_net_race_rules.cpp b/tests/game/test_net_race_rules.cpp new file mode 100644 index 0000000..fc7e141 --- /dev/null +++ b/tests/game/test_net_race_rules.cpp @@ -0,0 +1,963 @@ +// 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); +} + +TEST(NetRaceRules, RefereeNamesTheCheckpointTheWaterPutsACarBackAt) { + // mmGameMulti::HitWaterHandler puts the car back at the last checkpoint + // 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_EQ(r.lastCleared(1), 1); + EXPECT_EQ(r.lastCleared(7), std::nullopt); // nobody's +} + +// --- 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)); + // 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); + // ... 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 ---------------------------------------------------------------------------- + +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, 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; + 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); + // 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()); +} 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()); +} diff --git a/tests/game/test_net_traffic_cars.cpp b/tests/game/test_net_traffic_cars.cpp new file mode 100644 index 0000000..7a11327 --- /dev/null +++ b/tests/game/test_net_traffic_cars.cpp @@ -0,0 +1,222 @@ +// 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 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, moved.m3); + EXPECT_TRUE(cars.takeHandovers().empty()); + // 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); // 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, rest.m3); + 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); + 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)); +} + +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_net_water.cpp b/tests/game/test_net_water.cpp new file mode 100644 index 0000000..d0e68bb --- /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/PlayerCarState.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_netgame.cpp b/tests/game/test_netgame.cpp index 150f9c5..8d61ced 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 10, + // 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 diff --git a/tests/game/test_netgame_start.cpp b/tests/game/test_netgame_start.cpp index 3e85251..c94db2e 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; } @@ -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); } diff --git a/tests/game/test_opponent_race.cpp b/tests/game/test_opponent_race.cpp index 9fe176a..c63f3b8 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; + 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); + } + 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"; +} diff --git a/tests/game/test_player_cars.cpp b/tests/game/test_player_cars.cpp new file mode 100644 index 0000000..3aa3f42 --- /dev/null +++ b/tests/game/test_player_cars.cpp @@ -0,0 +1,515 @@ +// 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/PlayerCarState.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; + 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 +// `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, 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); + 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 (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; + 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}); + 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); + 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; +} + +// 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, + 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 + 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; + 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; + 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); + 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, + d.near.upcoming, 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, 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)); + 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()); + } + 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, leaderAhead.empty() ? leader : leaderAhead.front()); + if (!leaderAhead.empty()) + leaderAhead.pop_front(); + } + 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, &host.db, pusherOf); + d.near.id = 0; + d.near.state = ownCarState(*host.a, 0, &host.da, 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) + d.near.upcoming.push_back(leaderInput(t + k, 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; 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)); + 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_LE(relayed.corrections, run.corrections); + 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}}; + 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 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}; + EXPECT_FALSE(r.allows(reset)); + 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)); + // The development hook lets any reset in the city through, not four times + // a second. + ResetRules d = r; + d.debug = true; + 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) { + 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, 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; + 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, 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. + const Outcome run = simulate(*test::gameData(), 600, 6, 6, 4); + EXPECT_LE(run.corrections, 1); + EXPECT_EQ(run.missed, 0u); +} diff --git a/tests/game/test_prop_push.cpp b/tests/game/test_prop_push.cpp new file mode 100644 index 0000000..bd2fbf5 --- /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/PlayerCarState.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 new file mode 100644 index 0000000..b98026f --- /dev/null +++ b/tests/game/test_prop_sync.cpp @@ -0,0 +1,1338 @@ +// 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/NetGame.h" +#include "game/net/NetProps.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 +#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); + } +}; + +// 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}; + }; + // 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; + 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 || &other == nearCar; }); + } + // 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, [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, 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) + 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, 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); + 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)); // 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)); +} + +// 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); +} + +// 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. +// 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; + 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; + 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.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 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 +// 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); + 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)); + 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(from); + 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); + 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(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 + 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; + 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 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 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, 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}); + 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 + 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); + 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); +} + +// 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) { + 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)); +} + +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 + +// 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) { + 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 +// 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(); + 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. + 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/game/test_traffic_bodies.cpp b/tests/game/test_traffic_bodies.cpp index 659d5c8..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 @@ -14,6 +15,7 @@ #include #include #include +#include #include #include @@ -283,3 +285,131 @@ 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)); + // 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/tests/game/test_traffic_net.cpp b/tests/game/test_traffic_net.cpp index 729aaf9..ff5be6b 100644 --- a/tests/game/test_traffic_net.cpp +++ b/tests/game/test_traffic_net.cpp @@ -130,27 +130,63 @@ 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; + // 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& e : host.takeGameEvents()) - got.push_back(std::move(e)); - return !got.empty(); + for (auto& m : client.takeTrafficFull()) + fulls.push_back(std::move(m)); + return !fulls.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()); + 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_prediction.cpp b/tests/game/test_traffic_prediction.cpp new file mode 100644 index 0000000..a215318 --- /dev/null +++ b/tests/game/test_traffic_prediction.cpp @@ -0,0 +1,325 @@ +// 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}; + } +}; + +// 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 * nominal; + c.velocity = -c.transform.m2 * c.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); +} + +// 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, 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}); + 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; + // 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)); + 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 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 + 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), i == 2 ? 0.8f : 1.0f)); + 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/game/test_traffic_sync.cpp b/tests/game/test_traffic_sync.cpp index cdc8a9d..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)); @@ -358,6 +386,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 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 + 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, {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; + 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})})); diff --git a/tests/net/test_ambient_state.cpp b/tests/net/test_ambient_state.cpp index 3072c34..0aec4a3 100644 --- a/tests/net/test_ambient_state.cpp +++ b/tests/net/test_ambient_state.cpp @@ -57,6 +57,10 @@ 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.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; @@ -103,6 +107,12 @@ 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); + 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); @@ -132,6 +142,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 +155,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 +244,17 @@ 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), 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); diff --git a/tests/net/test_fuzz.cpp b/tests/net/test_fuzz.cpp index c8ca8e4..aab1734 100644 --- a/tests/net/test_fuzz.cpp +++ b/tests/net/test_fuzz.cpp @@ -4,9 +4,11 @@ // 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" +#include "net/TrafficFull.h" #include "net/VehicleDamage.h" #include @@ -159,6 +161,106 @@ std::vector corpus() { car.gear = 3; ambient.entities.push_back(car); c.push_back(encodeMessage(ambient)); + // 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; + 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}; + 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; + rest.what.source = PropSource::Prop; + rest.moving = false; + props.slots.push_back(rest); + PropSlot still; + still.slot = 200; + still.generation = 2; + still.hasState = false; + props.slots.push_back(still); + 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)); + // 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)); + // 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"; @@ -316,6 +418,81 @@ 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); + 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 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 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); @@ -417,6 +594,15 @@ void decodeEverything(std::span b) { if (const auto m = decoded(b)) { checkAmbient(*m); } + if (const auto m = decoded(b)) { + checkProps(*m); + } + 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); @@ -441,8 +627,15 @@ 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->subject, kDamageLastSubject); ASSERT_LE(e->patches.size(), kMaxDamagePatches); ASSERT_LE(e->first + e->patches.size(), kMaxDamageRecord); ASSERT_LE(e->impacts.size(), kMaxDamageImpacts); @@ -682,6 +875,21 @@ 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); + // 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); + // 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_player_cars.cpp b/tests/net/test_player_cars.cpp new file mode 100644 index 0000000..e62ecc2 --- /dev/null +++ b/tests/net/test_player_cars.cpp @@ -0,0 +1,603 @@ +// 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/PlayerCarState.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; + 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}; + o.hasBound = true; + o.bound = Mat34::rotationY(0.69f); + o.bound.m3 = {-1149.9f, 112.2f, 163.1f}; + o.pusher = 3; + return o; +} + +CarStatesMsg sampleStates(std::size_t cars, std::size_t near = 0) { + 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); + } + 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]; + 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; +} + +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); + 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)); + 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. +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); + } +} + +// 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}) + 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); + 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)); + 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); + } + } +} + +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(), 1250u); + 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, NearCarsTravelInFullWithoutTheirKeys) { + // 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); + 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); + 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); + 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), 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); + 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; + EXPECT_FALSE(decodeMessage(encodeMessage(m), out)); +} + +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.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()); + 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)), encodeMessage(sampleStates(2, 2))}; + 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()); + 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, 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); + 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()); + 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}, [&] { + 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); + 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) { + 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)), + encodeMessage(sampleStates(2, 2))}; + 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, {}, u.nearIds, u.near}); + } + } + EXPECT_EQ(p.host.state(), Session::State::Active); + EXPECT_EQ(p.client.state(), Session::State::Active); +} diff --git a/tests/net/test_prop_state.cpp b/tests/net/test_prop_state.cpp new file mode 100644 index 0000000..5bf8523 --- /dev/null +++ b/tests/net/test_prop_state.cpp @@ -0,0 +1,311 @@ +// The host's props on the wire (net/PropState.h): the ring's state and the +// knocks event. + +#include "net/PropState.h" + +#include + +#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 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; + 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; + 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)); +} + +// 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)); + 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)); +} diff --git a/tests/net/test_rules_state.cpp b/tests/net/test_rules_state.cpp new file mode 100644 index 0000000..528b52a --- /dev/null +++ b/tests/net/test_rules_state.cpp @@ -0,0 +1,296 @@ +// The host's rules message (net/RulesState.h, protocol 10): 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); +} + +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_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; + } + } +} diff --git a/tests/net/test_water_state.cpp b/tests/net/test_water_state.cpp new file mode 100644 index 0000000..0056215 --- /dev/null +++ b/tests/net/test_water_state.cpp @@ -0,0 +1,69 @@ +// 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/PlayerCarState.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)); +} 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); +} 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/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 diff --git a/tools/mm2tool/cmd_nettrace.cpp b/tools/mm2tool/cmd_nettrace.cpp new file mode 100644 index 0000000..e62809f --- /dev/null +++ b/tools/mm2tool/cmd_nettrace.cpp @@ -0,0 +1,317 @@ +// 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; +}; + +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])}); + } + } + 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, 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]; + 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) + (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 && + fr.own < hit.time + 1000.0; + })) + 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) { + 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); + // 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). + 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("police moving > 2 m/s", policeMoving); + row("police simulated here", policeSimulated); + row("any car within 40 m", near); + 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); + 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); + // 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; + }); + }); + 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(), knockedToo, host.knocks.size()); + 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 diff --git a/tools/netprobe/CMakeLists.txt b/tools/netprobe/CMakeLists.txt index 446d76b..6c2f93f 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 RulesReport.cpp SyncReport.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..2e3728f --- /dev/null +++ b/tools/netprobe/PropDiff.cpp @@ -0,0 +1,332 @@ +// 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 +// 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 level and dents at the common ticks +// 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" + +#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; + // 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) { + 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 car, cause; + s >> time >> car >> cause >> value >> damage; + ++t.impacts[car][cause]; + t.impactValue[car][cause] += value; + t.damageAfter[car] = damage; + } + } + 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 + +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("\nA {}, B {}: no session time in common", a.name, b.name); + return; + } + 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). + 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]); + } + // 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; + 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)); + }; + 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 + +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 new file mode 100644 index 0000000..5b0f06f --- /dev/null +++ b/tools/netprobe/PropDiff.h @@ -0,0 +1,13 @@ +#pragma once + +#include +#include + +namespace mm2::netprobe { + +// 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/RulesReport.cpp b/tools/netprobe/RulesReport.cpp new file mode 100644 index 0000000..c36cb6b --- /dev/null +++ b/tools/netprobe/RulesReport.cpp @@ -0,0 +1,553 @@ +// 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" +// 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 +// +// 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 +#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, shown = 1; +}; +struct Finish { + double clock = 0; + unsigned ms = 0; +}; +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 Water { + double clock = 0; + int player = 0; + unsigned sample = 0; + double x = 0, y = 0, z = 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; + 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; +}; + +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; + 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; + 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) { + s >> h.shown; + 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 == "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) + m.standings.emplace_back(place, racers); + } else if (tag == "CG") { + Gold g{clock}; + 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)) + 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; + 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.) + 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; + 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, confirmedAfter; + 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 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()) + ++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)); + 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) { + 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"); +} + +// 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 10, 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. + 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"); + // 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)); + } +} + +} // 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 (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); + backing(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/SyncReport.cpp b/tools/netprobe/SyncReport.cpp new file mode 100644 index 0000000..ea71e23 --- /dev/null +++ b/tools/netprobe/SyncReport.cpp @@ -0,0 +1,430 @@ +// 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, 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 +// comparison is what two screens side by side show. + +#include "SyncReport.h" + +#include "PropDiff.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; +} + +// 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) { + 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); + } + } + + // 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"); + 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, 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)); + 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, 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; " + "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()); + } + } + } + + // 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; + } + // 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}", + 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)); + } + + // 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; +} + +} // 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..423a100 100644 --- a/tools/netprobe/main.cpp +++ b/tools/netprobe/main.cpp @@ -7,8 +7,13 @@ // 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] +// 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" #include "net/Discovery.h" @@ -48,7 +53,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 +384,17 @@ 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]\n" + " rulesreport ..."); return 2; } @@ -415,5 +424,17 @@ 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); + } + if (cmd == "rulesreport") { + netprobe::RulesReportOptions o; + o.traces = args.positional; + return netprobe::rulesReport(o); + } return usage(); }