diff --git a/CMakeLists.txt b/CMakeLists.txt index d4be2fd..cac790f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,7 +1,7 @@ cmake_minimum_required(VERSION 3.25...4.0) project(OpenMM2 - VERSION 0.2.0 + VERSION 0.3.0 DESCRIPTION "Open source reimplementation of Midtown Madness 2" HOMEPAGE_URL "https://github.com/lowlevelmetal/OpenMM2" LANGUAGES C CXX) diff --git a/docs/architecture.md b/docs/architecture.md index 7c0d256..d9fb1b5 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -44,8 +44,9 @@ which is parsed as data. ## Timing The simulation runs on a fixed time step; rendering interpolates between the -last two simulation states so any refresh rate works. Multiplayer exchanges -simulation state at a fixed rate independent of the frame rate. +last two simulation states so any refresh rate works (rendering.md, "Drawing +between simulation steps"). Multiplayer exchanges simulation state at a fixed +rate independent of the frame rate. Each frame's time step is the real time since the previous frame, held to 0.0001–0.1 s as MM2's `datTimeManager::Update` does, so a long hitch slows diff --git a/docs/effects.md b/docs/effects.md index 135b16c..8119b98 100644 --- a/docs/effects.md +++ b/docs/effects.md @@ -193,6 +193,12 @@ unless the other party is a vehicle. Then: engine), above 75 mph two random wheel/hub pairs and a fender, above 50 mph one pair, thrown at 1.3 times the car's speed. +In a network race the other players' cars and a client's shared police show +their owner's damage: the smoke from the replicated level, and the texel +damage, broken parts, sparks and shards replayed from the owner's events +(multiplayer.md, "Damage"); they lay no tyre tracks and throw no wheel +particles. + Ejected parts take their physics from `tune/banger/_.dgBangerData` (`vehBreakableMgr::Create`). Retail data has these for every car's wheels and for some cars' BREAK parts, sirens and fenders, but none for hubs. MM2 still diff --git a/docs/gamemodes.md b/docs/gamemodes.md index 2f0c2f4..166386a 100644 --- a/docs/gamemodes.md +++ b/docs/gamemodes.md @@ -91,8 +91,10 @@ penalties (below). Circuits and checkpoint races start the countdown only once the pre-race camera has finished (`UpdateGame` state 0 waits for mmPlayer +0xE5A; Blitz -does not), and the multiplayer races only after the host's start message -(OpenMM2: 2.5 s before the shared start time). Each countdown line plays +does not), and the multiplayer races only after the host's start message, +which comes once every player has reported its race loaded (OpenMM2: a +shared start time, the countdown's lines following the time left to it; see +docs/multiplayer.md, "Race start"). Each countdown line plays "Startracelow", "Go!" plays "Startracehigh". ### Checkpoints @@ -366,14 +368,18 @@ by the game. Falling into the water fails the event 0.5 s later. HUD, car): * **Start**: no countdown; "Go!" (113) for 2 s at the top with - "Startracehigh". Players start at random intersections drawn from a - stream seeded with their id (`mmGame::RespawnXYZ`). + "Startracehigh". MM2's host starts at once and each joiner when the host's + game state reaches it; OpenMM2's machines wait for everyone and go together + at the shared start (docs/multiplayer.md, "Race start"). Players start at + random intersections drawn from a stream seeded with their id + (`mmGame::RespawnXYZ`). * **Machines**: each machine runs the rules for its own car and tells the others (`CopsAndRobbers::updateNetwork` / `receive`): a client that reaches free gold asks the host (0x25e) and the gold waits; the host grants it (0x25a); the carrier's machine reports a drop (0x259) and a delivery (600); the host draws the next set and sends its places (0x261). - OpenMM2 seeds the first set with the shared start time. + OpenMM2 seeds the first set with the time the host ordered the race, + which every machine knows when it loads. * **Places**: `race//multicopwaypoints.csv` is a pool of places (at least 3; the last row is never picked). Each set, at the start and after diff --git a/docs/multiplayer.md b/docs/multiplayer.md index f0d68a6..ff428a9 100644 --- a/docs/multiplayer.md +++ b/docs/multiplayer.md @@ -40,7 +40,8 @@ on the worker thread, so the UI must marshal it to its own thread (or just poll ## Topology Sessions are client/server. The host is a player too, and it is authoritative -for the lobby: settings, the player list, ready flags, kicks and the countdown. +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 @@ -53,10 +54,10 @@ for each car, what its snapshots needed to arrive in time over the last 3 s (50-500 ms; about 70 ms on a LAN, 150 ms over a 100 ms round trip, 250 ms from one client to another through the host). When packets stop coming, a remote car is extrapolated for up to 250 ms and then held still. Cars are replicated -during a race only (its countdown and the game): a state submitted in the -lobby is ignored, a late one arriving there is dropped, and the countdown and -the return to the lobby both clear every car's buffer, so nothing of one race -reaches the next. +during a race only (its loading, countdown and the game): a state submitted +in the lobby is ignored, a late one arriving there is dropped, and the race's +order and the return to the lobby both clear every car's buffer, so nothing +of one race reaches the next. There is no host migration. If the host leaves, every client gets `Disconnected{HostShutdown}`. @@ -192,7 +193,9 @@ The ENet connect request carries `kConnectData = 'M2' << 16 | kProtocolVersion`. A host disconnects mismatched peers right away with `VersionMismatch`, so 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. +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`). ### Handshake @@ -224,14 +227,16 @@ attempt within `connectTimeoutMs` (8 s). | --- | --- | --- | | Challenge | H→C | nonce, password required | | Hello | C→H | protocol version, build, name, car, colour, team, password proof | -| Welcome | H→C | your id, settings, player list, phase, host time, countdown end | +| Welcome | H→C | your id, settings, player list, phase, host time, the race's number and order time (in the lobby the last race's); during a race its start (if set) and the players who have loaded it | | Reject | H→C | reason, text | | PlayerJoined / PlayerUpdate | H→C | `PlayerInfo` | | PlayerLeft | H→C | id, reason | | PlayerRequest | C→H | car, colour, team, ready | | Chat | both | sender id (set by host), text ≤ 200 bytes | | Settings | H→C | `SessionSettings` | -| Countdown | H→C | session time at which the race starts | +| RaceLoad | H→C | GO DRIVE: the race's number (the host's, from 1) and the session time of the order | +| RaceLoaded | both | C→H: this machine has loaded race N; H→C: player P has (see "Race start") | +| RaceStart | H→C | race N starts (its countdown ends) at this session time | | ReturnToLobby | H→C | — (ready flags reset) | | Kick | H→C | reason text (then disconnect `Kicked`) | | TimeRequest / TimeResponse | C↔H | client send time / + host time | @@ -277,13 +282,21 @@ packet to the game or discovery port, and whatever answers as the router. `kInvalidPlayerId`, and refuses a `Welcome` that does not list it. * **Floods.** The host relays what one joiner sends to every other player, so each joiner has a budget: chat 2 lines a second (burst 8), player updates 10 - (burst 20), game events 30 (burst 60). Chat and events beyond it are dropped; + (burst 20), game events 30 (burst 60), and apart from those a car's damage + events 15 (burst 30, see "Damage"). Chat and events beyond it are dropped; a `PlayerRequest` is always applied and its `PlayerUpdate` relayed once the budget allows, so the latest car, colour, team and ready state still arrives. * **Floats.** Snapshot fields are quantized to fixed ranges and event floats must be finite (`ReadStream::f32`), so no NaN or infinity reaches physics or rendering. +* **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 + scale, sound ids 0-1000). A receiver takes a police car's damage from the + 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, 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 @@ -301,7 +314,7 @@ The host's clock (ms since the session started) is the shared timeline. Clients estimate it NTP-style: offset = hostTime + rtt/2 − receiveTime. They send a quick burst of six requests after joining, then one every 2 s. Of the last 8 samples, the one with the lowest RTT is used, since it has the least -asymmetry error. Countdown start, snapshot times and game event times are all +asymmetry error. The race start, snapshot times and game event times are all in session time. The clock a client shows (`Session::time()` / `timeMs()`, sub-millisecond) @@ -368,7 +381,115 @@ types, with payload structs in `Protocol.h`: 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. +cruise traffic's hit report (`TrafficHitEvent`, client to host) 0x8010, a +car's damage (`VehicleDamageEvent`, see "Damage") 0x8020. + +### Race start + +MM2's network races do not start on a timer (`mmMultiRace`, `mmMultiCircuit`, +`mmMultiBlitz::UpdateGame` state 0, `GameMessage`, `SystemMessage`; +`mmGameMulti::SendRaceReady`, `GameMessageCB`): + +* After loading, a machine sits on the grid with every car held + (`InitNetworkPlayers`: `vehCar::SetDrivable(0, 1)`) and the other players' + cars not shown, and shows nothing. +* It counts 5 s of frames, then sends RaceReady (0x1f6, with its car's CRC; + 0x213 after a restart) to every player. Every machine starts its count of + the others at the number of players less one, takes one off for each + RaceReady (a second one from the same player is not counted) and one for + each player who leaves meanwhile (`SystemMessage` 0x2d), and on a RaceReady + places that player's car on its grid and shows it + (`mmGameMulti::GameMessageCB` 0x1f6). +* Once it has sent its own and while its count is above 0, it shows "Waiting + for N players" (strings 31-37, "Waiting for 1 player" ... "Waiting for 7 + players", picked from a `|`-separated list by the count) for 5 s, every + frame. +* When the host's count reaches 0 it sends the start (0x20f) and begins its + countdown; every other machine begins its own when 0x20f arrives: Ready... + for 1.25 s, Set... for 1.25 s (each with the low start sound), then Go! with + the high one, when the racers are enabled and the HUD timers start + (`EnableRacers`, `StartTimers`, `ResetTimers`). Each machine's race time is + its own from its Go. +* There is no time limit on the wait: only a player leaving the session (or + DirectPlay dropping one) releases it. A player who leaves after its + RaceReady is taken off the count a second time, so the host can then start + while another machine is still loading; that machine finds 0x20f waiting + in state 0, starts its countdown at once and never sends its RaceReady. +* Cops and Robbers (`mmMultiCR::Init`, `UpdateGame`, `GameMessage` 0x25c / + 0x25d) does not wait: the host starts at once, and each joiner when the + host's game state reaches it, with "Go!" and no countdown. A network cruise + (`mmMultiRoam::Init`, `UpdateGame`) reports its machine loaded + (`PlayerFinishedLoading`, `SendGameSet` 0x1fa) and lets the car go at once; + each machine shows "" / "has joined" (string 42) with the net alert + as another's report arrives (`GameMessageCB` 0x1fa). + +OpenMM2 (`net::Session`, `game::NetRaceStart`) does the same over its own +protocol, with a shared start time: + +1. GO DRIVE: the host numbers the race (`raceNumber`, from 1, the same on + every machine) and sends `RaceLoad` with it and the session time of the + order; every machine loads the race (`SessionPhase::Countdown`) and keeps + its session serviced between the load steps. +2. Loaded: a race machine counts 5 s of frames, as MM2 does, then reports + (`reportLoaded`: `RaceLoaded` with the race number); Cops and Robbers and + cruise report at once. The host marks the player and relays the report + (`RaceLoaded` with the player's id) to the others. Every machine knows who + has loaded (`playerLoaded`), shows "Waiting for N players" while another + player still in the session has not (from its own report on), and shows a + network car only once its player has reported. +3. Start: once every player still in the session has reported, the host sets + the start, the session time of the Go: now, plus a lead for the message to + reach every machine (twice the slowest round trip plus 100 ms, 200 ms to + 1 s), plus the mode's countdown (2.5 s in the races, none in Cops and + Robbers and cruise), and sends it (`RaceStart`). Every machine runs its + countdown on its session clock up to that time (Ready... while more than + 1.25 s are left, Set... while any are, Go! at it), so every countdown ends + together, whenever the message arrived; the session's phase turns to + InGame there. MM2's machines begin their countdown when 0x20f arrives, + one trip apart. +4. A player who leaves, is ejected or times out no longer holds the start; + neither does a joiner who quits the race or cannot load it + (`NetGame::sendLeftRace` reports it first). A host that leaves ends the + session as before. +5. Upper wait (OpenMM2's own: MM2 has none): 60 s after the order + (`SessionConfig::loadWaitMs`) the host starts without the players still + loading, logging who. Such a machine already has the start when it + finishes loading (its session is serviced while it loads); it reports at + once, so the others show its car, and runs the whole countdown on its own + from then, joining the running race: what an MM2 machine does with a start + message that waited in its queue. Its race time is its own from its Go, as + every MM2 machine's is; the race's finish timeout (60 s in a race, 120 s + in a circuit after the first finish) still ends a race it never joins. This + keeps a stalled machine from holding everyone without dropping a player + who is only slow; removing the player was the alternative. +6. A report for another race (one sent before a return to the lobby reached + the player), a repeat, or one in the lobby is dropped; a report that comes + after the start no longer changes it but still tells the others the + player is in. A client ignores a start or a report out of turn, a second + start, a report for a player it does not know or for itself, and a + `RaceLoad` that does not number a newer race. +7. A machine still loading leaves the race when the host takes everyone back + to the lobby or the session ends (the loading screen checks between its + steps). + +Cops and Robbers waits for everyone too in OpenMM2 (deviation): its rules, +time limit and warnings run on the shared start's clock, and the first set +of places is seeded with the race's order time, which every machine knows +when it loads. Cruise never waits. + +Measured on loopback with a joiner loading 4 s more slowly +(`OPENMM2_DEBUG_LOAD_DELAY_MS=4000`), a circuit, twice in one session with the +cars changed in between: race 1 countdown from session time 20209 (host) and +20213 (joiner), Go at 22712 and 22710 for a start at 22709; race 2 countdown +from 47474 and 47477, Go at 49979 and 49976 for 49974 (a frame is about 6 +ms). Version 0.2.0's fixed start (6 s after GO DRIVE), measured the same way: +the joiner's countdown began 0.5 s late and it went 560 ms after the host +(17512 against 16952); 6 s slower, it went 2.55 s after the host (19523 +against 16969). Through `netprobe relay` (60 ms each way, 20 ms jitter, 5 % loss) the +lead was 358 ms, the start reached the joiner 145 ms after it left the host, +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. ### Shared traffic @@ -388,9 +509,11 @@ it (`game::TrafficHost`, `src/game/net/TrafficSync.h`): | orientation | smallest-three quaternion, 32 bits | | flags | brake, horn, indicators left / right (both: hazards), off its rail, wreck (police: out of action), siren, pursuit | | motion | a car on its rail: its speed along its heading (11 bits, ±64 m/s; the AI's velocity is exactly that); off its rail and police: velocity 3 × 12 bits (±96 m/s) and spin 3 × 11 bits (±32 rad/s) | -| police | target player (5 bits, 16 = none), damage, rpm, throttle, gear (27 bits) | +| 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 204. Each client gets the +A car on its rail takes 119 bits, 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 @@ -407,7 +530,9 @@ 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). +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 @@ -433,6 +558,116 @@ 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). +### Damage + +**MM2.** `mmNetObject::SetPositionData` puts the car's +`vehCarDamage::CurrentDamage` in every position packet, and +`mmNetObject::PositionUpdate` sets the network car's to it, calling +`vehCar::ClearDamage` when it drops from above 1 to below 0.0001 (the owner's +reset). Nothing else about damage travels: every machine simulates the other +players' cars as `vehCar`s with their damage on (`mmGameMulti::EnableRacers`), +so that machine's own collisions of the car paint its dents +(`vehCarDamage::ApplyImpact`, `fxTexelDamage::ApplyDamage`), break its parts +(`vehBreakableMgr::Impact`) and make its sparks and shards, and the level +drives the rest: `vehCarDamage::Update`'s smoke above MedDamage in four +levels, each with its own frame of `fxpt8` (the last two the black smoke of a +car about to die; MM2 draws no flames), the tyre wobble +(`vehSurfaceAudio::UpdateTireWobble`), and at MaxDamage the wheels, hubs and +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: + +* **The level** is the snapshot's 10-bit fraction between MedDamage and + MaxDamage (as before; the police's in `AmbientState`, now also 10 bits). + The receiving machine sets the car's CurrentDamage to MedDamage + fraction + × (MaxDamage − MedDamage) and runs `vehCarDamage::Update` on it: the same + smoke level, frame and exhaust smoke, the same tyre wobble. Nothing shows + below MedDamage, which is why the fraction is enough. `kVehicleWrecked` is + the wreck (engine and police explosion sounds as before). +* **What the owner's `vehCarDamage::ApplyImpact` painted and broke** travels + as a reliable game event, `VehicleDamage` (0x8020): + + | Field | Encoding | + | --- | --- | + | subject | 0-512: 512 the sender's own car, below it a shared police car's id (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) | + | parts | 20 bits: every part broken off since the reset (BREAK0-3, BREAK01/12/23/03, the paint job's VARIANT, WHL0-3, HUB0-3, FNDR0-1, ENGINE), and the delay of the newest | + | impacts | up to 8 damaging impacts: delay, point (3 × 12 bits), normal (3 × 8 bits), the impact's running total and AudImpact's strength (10 bits each, log scale), the car's speed (9 bits, 0-128 m/s), the sound's id (0-1000) | + +* **The same dents.** The owner paints each patch at the point as it + travels (`net::quantizeDamagePoint`) and sends the random state its + `fxTexelDamage` starts from; the receiver's paints the same patches from + the same numbers (`VehicleRenderer::applyDamage(point, radius, seed)`), + texel for texel (test `DamageSync.ReplayedDentsMatchTheOwnersTexelForTexel`). + 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. +* **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 + wall, not a playout delay before; an entry waits at most 2 s, and its + sparks, shards, sound and flying parts show only within 0.5 s of its time. +* **Convergence.** Each car has one record since its last reset: its patches + in order and the parts it lost. Events are reliable and ordered; each + carries every part broken since the reset, so the parts converge with any + later event; an event's patches fill the record from `first` (a gap, an + event the host's budget dropped, is counted and the rest kept). A car shown + afresh (created when its player's first snapshot arrives, a police car that + comes into view or into a reused slot) replays its whole record, so damage + that came before its first snapshot or while it was out of view shows. A + player cannot join a race that has started (`allowJoinInProgress` is never + set), so every machine in a race has had every event of it. +* **Resets.** Every `vehCar::ClearDamage` of the owner's car (a respawn, + `mmPlayer::Reset` at the water, the damage-out penalty's reset, Cops and + Robbers' repair at the bank or hideout and the end of a regeneration, + `DamageReset`) starts a new epoch: the others clear the car (dents and + parts) when they draw it at the reset's time. A police car's reset (the + race's restart) does the same. +* **Rate.** The owner sends what happened every 100 ms at most, keeping to + its own budget of 10 events a second (burst 20; what does not fit waits). + The host relays a joiner's damage events with a budget of their own, 15 a + second (burst 30), apart from the race events' 30. + +**Knocked traffic cars' wheels.** While a traffic car has a physics body on +the host (aiVehicleActive), `aiVehicleInstance::Draw` draws its wheels where +its `vehWheelCheap`s put them: each wheel's pivot moved up by the spring's +travel and back by 0.2 and 0.3 of the tyre's sideways and forward +deflection. `AmbientState` now carries, for every car off its rail, one bit +for whether it has a body and then the four wheels' offsets from their +pivots (x and z 6 bits over ±0.25 m, y 8 bits over ±1 m: 80 bits); WHL4 and +WHL5 follow WHL2 and WHL3 as on the host. A client draws such a car on them +(and lays its shadow as the host's physical cars', `trafficShadowMatrix`), +blending them between messages. + +**Deviations.** A receiving machine does not paint its own collisions of a +network car, as MM2's did: MM2's machines each showed the dents of their own +collisions with their own random numbers, OpenMM2 shows the owner's +everywhere. A network car's wheels are not simulated, so it lays no tyre +tracks and throws no wheel particles (MM2's simulated network car did; open). + +**Bandwidth**, measured with `OPENMM2_DEBUG_NETDAMAGE`: a heavily crashing +car (a cab ramming walls every 4 s for 5 minutes, two wrecks) sent 53 events, +2-4 every 10 s, 4-15 bytes a second of payload (about 90 bytes on the wire +each with the event header, ENet and UDP, relayed by the host to each other +player). The sender's budget bounds a car at 10 events a second (16 patches +and 8 impacts each: about 3 KB/s at worst). The shared traffic grows by 4 +bits a police car and 1 bit a knocked car per message, and 80 more bits for +each knocked car with a body. Measured (the host's `nettraffic` log, a bus +ramming the downtown San Francisco traffic with the client 40 m behind, 75 +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. + ### Disconnect reasons `Left`, `VersionMismatch`, `ServerFull`, `BadPassword`, `GameInProgress`, @@ -466,7 +701,7 @@ if (session.phase() == net::SessionPhase::InGame) { * `test_net`: serialization round-trips and malformed input, SHA-256 vectors, PCP/NAT-PMP byte layouts, the interpolation buffer, clock sync, and loopback sessions (join, chat, lobby updates, password, full session, version - mismatch, unreachable host, countdown, snapshot delivery, event relay and + mismatch, unreachable host, the race start, snapshot delivery, event relay and filtering, kick, host shutdown, join-in-progress). It also covers `PortMapper` against fake backends: failure reporting, fallback, port conflicts, renewal and loss, crash cleanup, other-gateway records, double NAT @@ -539,19 +774,23 @@ widgets and the network dialogs follow tune/widget.csv (menus 12, 14, 25, are selectable in multiplayer (**inferred**: joiners' progress cannot gate the host's choice). -When the host presses GO DRIVE, `NetGame::startRace()` starts a 6 s -countdown in the session clock. `FrontendScreen` sees `takeRaceStart()` on -every machine and switches to `makeRaceScreen(ctx, netGame->raceConfig())` -(with `config.multiplayer = true`); the race itself starts at -`raceStartTime()`. A `RaceResult` with `config.multiplayer` brings the player +When the host presses GO DRIVE, `NetGame::startRace()` orders the race +(`RaceLoad`, with the mode's countdown for the start: 2.5 s in the races). +`FrontendScreen` sees `takeRaceStart()` on every machine and switches to +`makeRaceScreen(ctx, netGame->raceConfig())` (with `config.multiplayer = +true`). Each machine reports the race loaded (`reportLoaded()`, from +`game::NetRaceStart`), the host sends the start once all have +(`raceStartKnown()`, `raceStartTime()`), and the race starts there (see +"Race start"). A `RaceResult` with `config.multiplayer` brings the player back to the lobby instead of the results screen. -Every countdown counts a race (`NetGame::raceNumber()`, from 1); the race -screen keeps the number of its race. A return to the lobby ends the race it -arrives in (`backToLobby(number)`), even when the player is in the menus by -then (the host's own return, which reaches the menus as an event after the -race screen has gone, or a joiner who quit the race early). Game events still -queued when a countdown starts are from an earlier race and are dropped. +The host numbers the races (`NetGame::raceNumber()`, from 1, the same on +every machine); the race screen keeps the number of its race. A return to +the lobby ends the race it arrives in (`backToLobby(number)`), even when the +player is in the menus by then (the host's own return, which reaches the +menus as an event after the race screen has gone, or a joiner who quit the +race early). Game events still queued when a race is ordered are from an +earlier race and are dropped. ### Per-frame contract for RaceScreen @@ -559,9 +798,15 @@ When `config.multiplayer && ctx.netGame`: 1. **Every frame, first:** `ctx.netGame->update()`. Without it the session times out. -2. **Start:** keep the player's car held at its start position while - `ctx.netGame->secondsToStart() > 0` (show the countdown from it; Ready / Set / - Go are the last 3 s). `raceStarted()` becomes true at the start time. +2. **Start:** once the race has loaded, a `game::NetRaceStart` for the mode + runs every frame (`update(dt, *ctx.netGame)`): it reports the race loaded + (after 5 s in the races), gives the "Waiting for N players" count, the + seconds left until the Go for the session's countdown + (`Session::setNetStart`) and whether the car is held (until the Go; never + in cruise), and in cruise the players who have just joined. + `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 @@ -575,11 +820,16 @@ When `config.multiplayer && ctx.netGame`: past, extrapolated up to 250 ms when packets are late), velocities, controls, damage and flags, sampled at the frame's session time less `simLagMs` (the remainder the frame's fixed steps will leave, - `phys::World::remainderAfter`). Use that one sample for everything in the - frame: the local car, the camera and every simulated object move in whole - steps, and a car sampled at the frame's own time shakes against them by up - to a step's travel (29 cm rms at 35 m/s and 144 fps; at 60 fps, a whole - step on 4-10% of frames). Draw cars with `hasState`; spawn one + `phys::World::remainderAfter`). 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 + 144 fps; at 60 fps, a whole step on 4-10% of frames). The race draws its + scene between the last two steps, one step (16.7 ms) behind the frame + (docs/rendering.md, "Drawing between simulation steps"), so it draws the + cars from a second sample, `remoteCars(16.7)`, and their simulated wheels + and trailers blended like its own cars'. Draw cars with `hasState` (a + player who has not reported the race loaded has none yet); spawn one `VehicleRenderer` per `car.car.vehicle` / `car.car.color`. For collisions, use them as kinematic bodies driven by the transform/velocity: their colliders take the body's velocity, so a car touching one meets it at @@ -597,7 +847,14 @@ When `config.multiplayer && ctx.netGame`: A client runs no traffic or police: before its physics step it takes `takeAmbientStates()` and places the received cars, and its light sets follow the host's (`World::advanceLightsTo`). -7. **End:** the host calls `ctx.netGame->returnToLobby()` when the race is +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 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; @@ -626,9 +883,34 @@ shared cruise traffic), and the pages `hostoptions`, `address`, `hostsettings`, the host (every message) and on the client (twice a second), with the hits and knocks, for comparison; `OPENMM2_DEBUG_NET_SHOT_MS=` ends a network race at that session time, so each machine's `--screenshot` shows the -same moment. The script survives a race: after it the menus carry on with the -commands after the one that started it, and `wait:race` waits until a race -has been driven. Example: one process hosts +same moment, and `OPENMM2_DEBUG_NET_SHOT_MS=+` that long after the race +was ordered, also before it has started (each machine's race of the same +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_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 +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 +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` +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 +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 +session time ...", "race N: GO at session time ..."). The script survives a +race: after it the menus carry on with the commands after the one that +started it, and `wait:race` waits until a race has been driven. Example: one process hosts (`profile:A;page:sessions;mp:host;wait:900`), another joins (`profile:B;page:sessions;mp:join:127.0.0.1;wait:100;mp:ready`); a second race after changing cars in the lobby: @@ -639,13 +921,30 @@ Lobby) and `profile:B;mp:join:127.0.0.1;wait:100;mp:ready;wait:race;wait:100;mp: `test_game` (`tests/game/test_netgame.cpp`) runs a host and a client `NetGame` in one process: settings round trip, join by address, LAN discovery, password refusal, chat, car and ready changes, settings -propagation, countdown, vehicle state replication, game events, return to -lobby, host shutdown and eject. Port forwarding is disabled in these tests. +propagation, the race start, vehicle state replication, game events, return +to lobby, host shutdown and eject. The race start is tested by +`tests/net/test_race_start.cpp` (every order of three reports, the shared +start, a player leaving, ejected or timed out while the others wait, the +load wait and a late report, a report for an earlier race and repeats), the +race messages in `test_hostile_input.cpp` and `test_fuzz.cpp`, and +`tests/game/test_netgame_start.cpp` (`NetRaceStart`'s 5 s, waiting count, +countdown, late machine, cruise and Cops and Robbers; a slow loader both +countdowns wait for and end together with; a loader that never finishes +joining later; a joiner leaving the race). Port forwarding is disabled in these tests. The shared traffic is tested by `tests/net/test_ambient_state.cpp` (the message, malformed input, delivery per client), `tests/game/test_traffic_sync.cpp` (interest, budget, spawn, update, despawn, loss, reordering, recycled slots, hostile messages), `tests/ai/test_shared_traffic.cpp` (several players' roads, avoidance, the light steps) and `tests/phys/test_kinematic_motion.cpp`. +The damage by `tests/net/test_vehicle_damage.cpp` (the event's encoding, the +point arriving bit for bit, non-finite values, malformed events, the police +damage and wheels in `AmbientState`), `tests/game/test_damage_sync.cpp` (the +recorder's batches, splits, resets and budget; each entry at its time, the +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`. ### Diagnosing replication diff --git a/docs/parity/mm2/game-flow.md b/docs/parity/mm2/game-flow.md index bf16629..cf795f5 100644 --- a/docs/parity/mm2/game-flow.md +++ b/docs/parity/mm2/game-flow.md @@ -317,7 +317,7 @@ movers, `aiMap::Update`), `Reset` (a restart). | --- | --- | --- | --- | | `mmGameMulti::mmGameMulti`, `~mmGameMulti`, `` `scalar_deleting_destructor' `` | not needed | — | | | `mmGameMulti::Init` | ported (new) | `RaceScreen::loadAi`, `loadRaceSetup` | traffic, cop and opponent densities 0 and no rail cars; the race modes clear mmGame's AI flag (no AI map at all) | -| `mmGameMulti::InitGameStrings` | ported (new) | `RaceScreen::updateNetPlayers` | "has left the game" (38); the "Waiting for N players" lines belong to the ready gate (replaced) | +| `mmGameMulti::InitGameStrings` | ported (new) | `RaceScreen::updateNetPlayers`, `NetRaceStart::waitingText` | "has left the game" (38); "Waiting for 1 player" ... "Waiting for 7 players" (31-37) while others load | | `mmGameMulti::InitOtherPlayers` | replaced | `RaceScreen::updateRemoteCars` | | | `mmGameMulti::InitRoster` | ported (new) | `Popup::Roster` | | | `mmGameMulti::StartXYZ` | ported | `multiplayerGridOffset`, `RaceScreen::startSlot` | slot = the place in the host's player list (new; was the player id); slots 8-15 have no place (OpenMM2 allows 16) | @@ -339,7 +339,7 @@ movers, `aiMap::Update`), `Reset` (a restart). | `mmGameMulti::BootPlayerCB` | ported (new) | `Popup::Roster` -> `NetGame::kick` | | | `mmGameMulti::RegisterMapNetObjects`, `ActivateMapNetObject`, `DeactivateMapNetObject` | ported (new) | `RaceScreen` blips (HUD audit), places 0 / 10 | the other players on the map and their cards, by start slot | | `mmGameMulti::GetInactiveNetObjectIndex`, `GetNetObject`, `GetNetObjectIndex`, `ClearNetObjects` | replaced | `m_remotes` | | -| `mmGameMulti::PlayerFinishedLoading`, `PlayerClearLoaded`, `SendRaceReady`, `SendGameSet`, `SendTimeMsg` | replaced | the shared start time, ClockSync | no ready gate (deviation, session record) | +| `mmGameMulti::PlayerFinishedLoading`, `PlayerClearLoaded`, `SendRaceReady`, `SendGameSet`, `SendTimeMsg` | ported (new) | `NetGame::reportLoaded` (`RaceLoaded`), `net::Session` (the loaded flags, cleared at each race and return), `NetRaceStart`, ClockSync | the ready gate (docs/multiplayer.md, "Race start"); the start is a shared session time and the host stops waiting after 60 s (deviations); no car CRC; `SendTimeMsg` replaced by ClockSync | | `mmGameMulti::SendPosition`, `SendMsg` | replaced | snapshots, `sendEvent` | | | `mmGameMulti::GetCarCRC`, `SendCheater` | open | — | the tuning CRC check (low) | | `mmGameMulti::BroadCastCarTuning`, `SendCarTuning` | not needed | — | `SendCarTuning` is empty | @@ -361,8 +361,8 @@ movers, `aiMap::Update`), `Reset` (a restart). | `mmMultiRoam::InitHUD`, `mmMultiRace::InitHUD`, `mmMultiCircuit::InitHUD`, `mmMultiBlitz::InitHUD` | ported | `Hud` | the place readout now counts the network players (new); Blitz's place readout and no checkpoint cards (HUD audit) | | `mmMultiRoam::Reset` | ported | `cruiseStart` | at the start (from mmGameMulti::InitOtherPlayers) its RespawnXYZ is the first of the two seeded picks; no in-race restart in a network game (round 3: [cruise-spawn](../round3/cruise-spawn.md)) | | `mmMultiRace::Reset`, `mmMultiCircuit::Reset`, `mmMultiBlitz::Reset` | replaced | the lobby | no in-race restart | -| `mmMultiRoam::UpdateGame`, `SwitchState` | ported | `Session::start` / `updateRace` | "Go!" and the car released at once (new: no shared start in cruise) | -| `mmMultiRace::UpdateGame`, `SwitchState`, `mmMultiCircuit::UpdateGame`, `SwitchState`, `mmMultiBlitz::UpdateGame`, `SwitchState` | ported | `Session::updateRace`, `updateNetRace` | countdown, wreck penalty (no warning, finish or clock check during it, new), finish line, the wait for all finishers and the timeout (new); the ready gate and the end taper are replaced (session record) | +| `mmMultiRoam::UpdateGame`, `SwitchState` | ported | `Session::start` / `updateRace`, `NetRaceStart` | "Go!" and the car released at once (new: no shared start in cruise); "" / "has joined" as each other machine reports (`GameMessageCB` 0x1fa) | +| `mmMultiRace::UpdateGame`, `SwitchState`, `mmMultiCircuit::UpdateGame`, `SwitchState`, `mmMultiBlitz::UpdateGame`, `SwitchState` | ported | `Session::updateRace`, `updateNetRace` | countdown, wreck penalty (no warning, finish or clock check during it, new), finish line, the wait for all finishers and the timeout (new); the ready gate (state 0: 5 s, RaceReady, "Waiting for N players", the start; `NetRaceStart`, on a shared start time); the end taper is replaced (session record) | | `mmMultiRace::SetTimeoutOn`, `SetTimeoutOff` | ported (new) | `Session::addNetResult`, `updateNetRace` | 60 s from the first finish ("Race over", DNF) | | `mmMultiBlitz::PlayTimerWarning` | ported | `Session::timerWarning` | | | `mmMultiRoam::GameMessage`, `mmMultiRace::GameMessage`, `mmMultiCircuit::GameMessage`, `mmMultiBlitz::GameMessage` | ported (new) | `updateNetRace`, `Session::remoteFinished` | the finish lines (host 152 / 110 / 99, client 150 / 107 / 96) with Messagenote; the time-up / restart / host-quit messages are replaced | diff --git a/docs/parity/openmm2-only.md b/docs/parity/openmm2-only.md index 1235a57..30fa0c0 100644 --- a/docs/parity/openmm2-only.md +++ b/docs/parity/openmm2-only.md @@ -130,6 +130,8 @@ counted in the summary above. | `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. | ## Files @@ -160,6 +162,7 @@ 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/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 | | | `src/net/ClockSync.cpp` | confirmed O | Host clock estimate. | diff --git a/docs/parity/round3/cruise-spawn.md b/docs/parity/round3/cruise-spawn.md index 3f4c904..00b1816 100644 --- a/docs/parity/round3/cruise-spawn.md +++ b/docs/parity/round3/cruise-spawn.md @@ -142,7 +142,7 @@ also changes every later single-player start of the run. | `Traffic::populate`, `Pedestrians::populate`, `Random::state` | `AdjustAmbients` / `AdjustPedestrians` from room 0 | fixed | the populations themselves were audited by the ai areas (draw for draw); with no ambient cars or pedestrians MM2 skips them and OpenMM2's draw nothing | | `city::addShortcuts`: centre | `aiIntersection::CreateRoadMap` | fixed | a shortcut road's end intersections move to their room's bound-sphere centre (`sdlRoomBoundSphere`; up to 0.21 m in San Francisco) before their road lists are sorted; RespawnXYZ starts there (ai-vehicles' open row, commit ef670f6) | | C&R picker (`RaceScreen::setupCopsAndRobbers` `randomIntersection`) | `GetRandomPoints`' helper: `RespawnXYZ(false, false, false)` less 2 m | fixed | the room by `FindRoomId`, only 0x24 rejected, no road rules, MSVC draws | -| C&R picker: stream and draws | the host's global stream, (counter + 1) draws | deviation | every OpenMM2 machine draws the first set itself from the shared start time, one draw a pick (the counter is each machine's own, so using it could split the machines) | +| C&R picker: stream and draws | the host's global stream, (counter + 1) draws | deviation | every OpenMM2 machine draws the first set itself from the race's order time, one draw a pick (the counter is each machine's own, so using it could split the machines) | | Cruise restart | `mmSingleRoam::Reset` -> `mmGameSingle::Reset` -> `mmGame::Reset` | verified | no new pick: `asNode::Reset` puts the car back at its reset place (`Session::restart`, RaceScreen's Restart); network games have no restart | | `CarSim::setResetPos` | `vehCarSim::SetResetPos` | verified | the CG added unrotated (+0x210) | | reset angle | +0x250 written from RespawnXYZ's angle | verified | 0: the body faces -Z (`SimVehicle::setResetPos(pos, 0)`) | diff --git a/docs/parity/round3/random-streams.md b/docs/parity/round3/random-streams.md index b346bdb..92f9d2e 100644 --- a/docs/parity/round3/random-streams.md +++ b/docs/parity/round3/random-streams.md @@ -170,7 +170,7 @@ this reason). | `vehWheel::GetBumpDisplacement` (via `vehDrivetrain::Update`) | the wheels' own stream (`phys/vehicle/Wheel`) | deviation | Physics; per sample. | | `vehCarModel::EjectOneshot`, `vehBreakableMgr::Eject` (also via `aiVehicleActive::Impact`) | RaceScreen and BangerSet eject streams | deviation | On impacts. | | `mmPlayer::UpdateFF`, `FFImpactCallback` | `ForceFeedback` stream | deviation | Already recorded as inferred own. | -| `mmMultiCR::GetRandomIndex` (global), the Cops and Robbers point picker (secondary, clock-seeded) | `CopsAndRobbers` (its own LCG of the same form, seeded from the race start time) | deviation | Network; MM2's picker is seeded from the clock, so nothing to reproduce. The LCG constants differ from MSVC's (session record). | +| `mmMultiCR::GetRandomIndex` (global), the Cops and Robbers point picker (secondary, clock-seeded) | `CopsAndRobbers` (its own LCG of the same form, seeded from the race's order time) | deviation | Network; MM2's picker is seeded from the clock, so nothing to reproduce. The LCG constants differ from MSVC's (session record). | | `ptxGlass::AddShards`, `ptxGlass::Update` | — | not needed | No retail banger is glass (BillFlags 0x100). | | `pedActive::FirstImpactCallback` | — | not needed | No ragdolls exist in MM2 (pedRagdollMgr is never built). | diff --git a/docs/parity/session.md b/docs/parity/session.md index fc1c5d7..e0f6c40 100644 --- a/docs/parity/session.md +++ b/docs/parity/session.md @@ -208,7 +208,7 @@ Behaviour is documented in `docs/gamemodes.md`. Tests: | `setRaceConfig`, `setPassword`, `kick`, `returnToLobby` | `NetArena` host | openmm2 | | | `setMaxPlayers`, `maxPlayers` | DirectPlay session (8 players) | deviation | OpenMM2 allows 2-16 | | `setGoldMass`, `goldMass` | `HostRaceMenu::GetGoldMassVal` (cnr_goldMass) | openmm2 | the setting's transport; the default is the host menu's (frontend-ui) | -| `startRace`, `raceStartTime`, `secondsToStart`, `raceStarted` | `mmGameMulti` start message | deviation | a shared start time instead of the host's start message; the session starts the 2.5 s countdown 2.5 s before it | +| `startRace`, `reportLoaded`, `playerLoaded`, `raceStartTime`, `secondsToStart`, `raceStarted` | `mmGameMulti::SendRaceReady`, `mmMultiRace` / `mmMultiCircuit` / `mmMultiBlitz::UpdateGame` state 0, `GameMessage` 0x1f6 / 0x213 / 0x20f, `SystemMessage` 0x2d | fixed | the ready gate (docs/multiplayer.md, "Race start"); deviations: the start is a shared session time, which the countdown follows, instead of the host's start message, and the host stops waiting after 60 s | | `startPortMapping`, `stopPortMapping`, `portMappingStatus` | — | openmm2 | UPnP / NAT-PMP | | `submitLocalState`, `remoteCars` | `mmNetObject` | openmm2 | snapshots with interpolation | | `sendEvent`, `sendCheckpoint`, `sendLap`, `sendFinish`, `sendGold`, `sendCollision`, `sendDamage`, `addSystemLine` | `mmGameMulti::SendMsg`, `mmMultiCR::Send*` | openmm2 | own messages | @@ -281,7 +281,7 @@ Behaviour is documented in `docs/gamemodes.md`. Tests: | `updateRemoteCars`, `drawRemoteCars` | `mmNetObject::Init`, `Update` | fixed | network cars as kinematic bodies at their snapshots (deviation: MM2 simulates them with the remote inputs), the polygonal bound, a vpcop on the Mustang's tune, trailers except in multiplayer cruise and Cops and Robbers, type-3 movers | | `multiplayerGridOffset`, spawn | `mmGameMulti::StartXYZ`, `mmGame::RespawnXYZ` (seed), `mmGame::FindGroundPos` | fixed | the eight-slot grids (slot = player id, inferred), the cruise start seeded by the player id, spawns dropped with the wheels' probe | | `CopsAndRobbers::updateNetwork`, `receive` | `mmMultiCR::UpdateGame`, `UpdateGold`, `UpdateBank`, `UpdateHideout`, `ImpactCallback`, `GameMessage` (0x259, 0x25a, 0x25e, 600, 0x261) | fixed | new: each machine runs its own car, the host grants pickups and draws sets | -| `setupCopsAndRobbers`, `updateCopsAndRobbers`, `fondleMass`, `crTeam` | `mmMultiCR::Init`, `InitMyPlayer`, `FondleCarMass`, `mmPlayer::UpdateRegen`, `mmGame::UpdateSteeringBrakes` | fixed | new: the mode is played (places, teams, messages, mass, throttle cap in every network game, regeneration, repair at delivery, HUD lines, limits, end after 3 s); the shared first set is seeded by the start time (OpenMM2) | +| `setupCopsAndRobbers`, `updateCopsAndRobbers`, `fondleMass`, `crTeam` | `mmMultiCR::Init`, `InitMyPlayer`, `FondleCarMass`, `mmPlayer::UpdateRegen`, `mmGame::UpdateSteeringBrakes` | fixed | new: the mode is played (places, teams, messages, mass, throttle cap in every network game, regeneration, repair at delivery, HUD lines, limits, end after 3 s); the shared first set is seeded by the race's order time (OpenMM2) | | `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` | diff --git a/docs/physics.md b/docs/physics.md index 65b1042..4023527 100644 --- a/docs/physics.md +++ b/docs/physics.md @@ -51,6 +51,18 @@ scheme. (Several terms below scale with the sample length, e.g. the drivetrain's AngInertia * dt, so the original drove slightly differently at other frame rates.) +The fixed step stays, for accuracy (the original at 60 fps) and determinism +(network play, tests, the opponent sweep). A frame then runs no sample or +several, and what is shown between two samples is OpenMM2's presentation: +`World::step` calls a step observer before anything moves, the race screen +keeps each body's state from before the sample (`game::StepHistory`), and +the drawing blends it towards the current state by `interpolationAlpha()` +(the unstepped remainder / the step; rendering.md, "Drawing between +simulation steps"). The observer only reads; `CarSim::resets` (how many +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. + ## Rigid body (phInertialCS) — MM2 * Every physics entity's update first adds its weight, Mass * -19.6 on y diff --git a/docs/rendering.md b/docs/rendering.md index afbab36..d1a6e7d 100644 --- a/docs/rendering.md +++ b/docs/rendering.md @@ -105,3 +105,120 @@ Parts that break off (effects.md) are no longer drawn on the car until it is reset. Not ported yet: suspension and engine parts (SHOCK, ARM, SHAFT, AXLE, ENGINE need the suspension matrices; no retail car has them). Traffic turn signals (SLIGHT0/1, blinking on a frame counter) are not ported. + +## Drawing between simulation steps + +**MM2.** The original ran its whole game once per rendered frame with that +frame's measured time: `datTimeManager::Update` sets `Seconds` to the time +since the last frame (clamped), `mmGame::Update` and the AI (`aiMap::Update`) +use it, and `dgPhysManager::Update` splits it into equal samples of at most +1/35 s, at most three (`mmGame::Init`'s SampleStep and MaxSamples; +physics.md). Each +frame then drew the state its own update had reached, so motion never +juddered at any frame rate, but the simulation itself changed with the frame +rate. + +**OpenMM2** keeps its simulation independent of the frame rate, for +accuracy (it is the original's at 60 fps) and determinism (network play, +tests, the opponent sweep): the physics runs whole 1/60 s samples +(`phys::World::advanceFixed`) and the AI whole 1/30 s steps +(`ai::World::update`), each carrying the rest of the frame's time to the next +frame. A frame can therefore run no step or several. Drawing the last step's +state made the cars, the camera and the props move in 60 Hz steps against the +frame above 60 fps (and at 60 fps whenever a frame ran 0 or 2 samples), and +the traffic and pedestrians in 30 Hz steps at any frame rate. + +Instead, the drawing blends each simulated object from its state when the +last step began to its current state, by alpha = the time the simulation has +not stepped yet / its step (`game::StepHistory`, `src/game/Interpolation`). +`phys::World` and `ai::World` call a step observer at the start of every +step; the race screen's observers record what they move into one history +each, and the renderers ask the history for each object's drawn state. This +is presentation only: the physics, the AI, the rules, the audio, the random +streams and the network snapshots read the stepped state as before (tests +`StepHistory.TheSimulationIsTheSameWhateverTheFrameTimes` and +`TheTrafficIsTheSameWhateverTheFrameTimes` compare every step at 60, 90, 144 +and 240 fps and with uneven frames). + +| What | Drawn | Evidence | +|---|---|---| +| Blend | positions lerped, orientations slerped the short way, a scaled instance's row lengths lerped; alpha 1 gives the current state exactly | OpenMM2 | +| Player's car, trailer, opponents, police | their vehicle poses (body and simulated wheels); the wheels blended in the body's frame and spun by the difference of their accumulated turns (vehWheel's rotation), so a wheel turning more than half a revolution a sample still turns forwards | OpenMM2 | +| Shadow, lights, headlight beams, sirens, lens flares | from the drawn pose (VehicleRenderer) | OpenMM2 | +| Cameras | every car camera follows the drawn car (`CameraTarget::matrix`), so the camera, the mirror (`mmMirror`'s frame on the drawn car) and the dashboard move with it; camera cuts and resets stay instant | OpenMM2 (camCarCS tracks vehCarSim's matrix) | +| HUD | the opponent icons, the map and its player arrow follow the drawn cars (their phInertialCS matrices from the drawn model matrices) | OpenMM2 (mmIcons, mmHudMap follow the cars' matrices) | +| Traffic with a body, props an active simulates in the world | their bodies' matrices (and the traffic's cheap wheels) blended | OpenMM2 | +| Traffic on its rails, pedestrians | blended over the AI's 1/30 s steps, the rail cars' tyre turn too; pedestrians keep whole animation frames, as MM2 draws them | OpenMM2 | +| Network players' cars | the snapshot sampled one physics sample further back than the sample the physics and the rules use (the frame's session time less the playout delay less 16.7 ms: alpha x step is the remainder, so that is where the rest of the scene is drawn); the kinematic car's simulated wheels blended in its body's frame; its trailer blended | OpenMM2 | +| A client's shared traffic and police | sampled 16.7 ms before the time their physics proxies are placed at (`TrafficClient::transformAt`) | OpenMM2 | + +**Never blended.** An object not recorded at the start of the last step (new +since, or not simulated in it) is drawn where it is, and so is one recorded +under another generation: a car's reset count (`CarSim::resets`, every +`vehCar::Reset` and placement: respawns, a race reset, an opponent or police +car put back, a network car's first state), a recycled traffic slot (the +slot's spawn count), a pedestrian put on a road in the last step; a race +restart clears both histories. Any move longer than 5 m or a turn of more +than 1.6 rad in one step counts as a jump too (300 m/s at 60 Hz). A traffic +car that takes a body (it is hit) changes from the AI's history to the +physics' unblended, so it moves on by up to an AI step's travel at that +moment (half a metre at 15 m/s, while the hit throws it); one that gives its +body back is at rest. Camera cuts are instant since the camera itself is not +blended. + +**Effects.** The effects run MM2's per-frame updates at a fixed 60 Hz of +their own (`fx::FixedTicker`). The particle systems (the wheels' dust and +smoke, the engine and exhaust smoke, the props' debris, the rain and snow) +are drawn back along each particle's velocity by the time since their last +update would have been due (`FixedTicker::behind`): an update moves a +particle by its new velocity, so that is where it was between its last two +updates, as the rest of the scene is drawn (the rain falls at 35 m/s: 58 cm +an update). The tyre tracks, the shards and the sparks +stay as updated: a track's newest end is under the car, shards are brief, +and the sparks move in steps of at least 1/30 s in MM2 too +(`asLineSparks::Update`). What the effects are born from is the simulation's +state, up to a step ahead of the drawn car. The gizmos, cable cars and sky +already move once per frame with its time. + +**Latency.** The drawn state reaches the newest simulated one only just +before the next step, and right after a step it is the one before: on average +half a step later than before, 8.3 ms for what the physics moves and 16.7 ms +for the AI's traffic and pedestrians (which are drawn 16.7 ms behind the +physics' objects). That is the usual price of drawing between steps and is +accepted. The ways to draw at the frame's own time were rejected: +extrapolating from the current state by the remainder shows cars inside +walls before each contact and snaps them back (up to 0.67 m at 40 m/s); +simulating a throwaway copy of the world for the remainder (a partial +sample) would show states the simulation never reaches, since every sample's +terms scale with its length, would draw from the shared random streams, and +doubles the physics' cost; a shorter fixed step changes the simulation away +from the original's 60 fps behaviour. + +`OPENMM2_DEBUG_DRAW_TRACE=` (a development aid, off unless set) writes +a line a frame: the frame, its time, the physics samples it ran, alpha, the +player's car as drawn, a point 40 m ahead of it and 6 m to its right (chosen +again once behind the camera; the segment number counts them), the car's +speed and the nearest other car as drawn, all in the camera's frame. + +**Measured** with it (and a scratch script): vppanozgt flat out +in the San Francisco cruise from 20 m/s until its first contact (about 38 +m/s), `[Display] VSync=off` and `FrameCap`, 640 x 360. The second difference +per frame of the drawn positions in the camera's frame (millimetres; real +bumps of the road show in both builds): + +| fps | car, median / rms before | after | roadside point, median / rms before | after | +|---|---|---|---|---| +| 60 | 35 / 59 | 1.6 / 29 | 402 / 503 | 6.0 / 20 | +| 90 | 36 / 49 | 0.65 / 12 | 388 / 391 | 3.2 / 8.9 | +| 144 | 39 / 48 | 0.24 / 7.8 | 442 / 437 | 2.1 / 7.0 | +| 240 | 13 / 36 | 0.09 / 3.8 | 12 / 339 | 1.3 / 4.1 | + +At a 60 fps cap 38% of the frames ran no sample or two before (the frame +time hovers about the step); the remaining rms after is the car's real +bounces on the road's bumps, which the camera follows with a lag. At 144 fps +the nearest opponent in the San Francisco checkpoint race moved by 186 / 226 +mm before and 1.2 / 11 mm after; in a network cruise with shared traffic +(host and client on UDP, both flat out) the host's car as the client draws +it by 112 / 118 mm before and 1.5 / 5.6 mm after (114 / 118 and 1.3 / 5.8 +mm through `netprobe relay` at 50 +- 15 ms each way, 1% loss and +reordering), the client's roadside point by 367 / 359 and 1.3 / 2.5 mm. diff --git a/docs/review/multiplayer-lobby.md b/docs/review/multiplayer-lobby.md index ff31589..0368304 100644 --- a/docs/review/multiplayer-lobby.md +++ b/docs/review/multiplayer-lobby.md @@ -133,7 +133,7 @@ Severity: crash / gameplay-breaking / visible / minor / code quality. | # | Severity | Location | Scenario | Status / what it needs | | --- | --- | --- | --- | --- | -| O1 | gameplay (parity) | `NetGame::startRace`, `Session::startCountdown` | The race starts a fixed 6 s after GO DRIVE whatever the loading. MM2 waits: every machine sends RaceReady (0x1f6, `SendRaceReady`) 5 s into its state 0, the host shows "waiting for " until all have, then sends 0x20f and every machine counts Ready / Set / Go (`mmMultiRace::UpdateGame`). A machine that needs more than 3.5 s to load misses part of the countdown (Ready / Set / Go are its last 2.5 s), and one that needs more than 6 s starts behind the others (here a race loads in 0.6 s). | plausible; needs a "loaded" message from each machine and a start time the host sends when all have loaded (a protocol bump), then `RaceScreen`'s start signal from it | +| O1 | gameplay (parity) | `NetGame::startRace`, `Session::startCountdown` | The race starts a fixed 6 s after GO DRIVE whatever the loading. MM2 waits: every machine sends RaceReady (0x1f6, `SendRaceReady`) 5 s into its state 0 and shows "Waiting for N players" (strings 31-37; every machine, not only the host) while another's has not arrived, then the host sends 0x20f and every machine counts Ready / Set / Go (`mmMultiRace::UpdateGame`). A machine that needs more than 3.5 s to load misses part of the countdown (Ready / Set / Go are its last 2.5 s), and one that needs more than 6 s starts behind the others (here a race loads in 0.6 s). | **fixed** (0096f5b, tests 23ccba0): protocol 3's handshake (`RaceLoad`, `RaceLoaded`, `RaceStart`; `game::NetRaceStart`). Each machine reports its race loaded as MM2 does, the host starts once every player still in the session has, at a shared session time a lead ahead, and every countdown ends together (two processes, a joiner loading 4 s more slowly: Go at 22712 / 22710 for 22709, and in a second race after car changes 49979 / 49976 for 49974; 0.2.0 measured the same way: the joiner went 560 ms after the host, 2.55 s when 6 s slower). A player who leaves or times out no longer holds the start; after 60 s (OpenMM2's limit, MM2 has none) the host starts without the players still loading, who join the running race on their own countdown. docs/multiplayer.md, "Race start" | | O2 | minor | `PagesMulti.cpp` `LobbyPage::update` | A joiner's READY is cleared on a settings change only while the lobby page is on top (MM2's `MessageCallback` clears it on any page); the host can also press GO before a joiner's clear arrives. Both machines still load the same race. | plausible; the host could clear the joiners' ready flags itself when it changes the settings | | O3 | minor | `PagesOptions.cpp` | A ready joiner may open OPTIONS from the lobby; if the race starts there, changes made on an option page are in effect but neither saved nor cancelled. | by reading; leave as is or clear ready on OPTIONS as on SELECT VEHICLE | | O4 | minor | `Frontend::cityIndex` | A session city the data lacks is shown as San Francisco (with its map) in the lobby; loading it now fails with a notice. | by reading; show the name as given and no map | diff --git a/docs/review/multiplayer-sync.md b/docs/review/multiplayer-sync.md index 5208fbe..85b5d1d 100644 --- a/docs/review/multiplayer-sync.md +++ b/docs/review/multiplayer-sync.md @@ -180,19 +180,21 @@ 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. | outside this area (rendering): needs the drawn poses blended by `interpolationAlpha`, and then remote cars sampled at the blended time. | +| 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. | | S4 | minor | `Session.cpp` (snapshots) | A car whose snapshots stop (a crashed client) is held still as a solid obstacle until ENet times the player out (5-15 s). | could drop a car silent for a few seconds; not done because a stalled link would then reload the car when it comes back. | -| S5 | visible (parity) | `RaceScreen::drawRemoteCars` | A remote car's damage (replicated, 0-1) does not show: no dents, smoke or fire. MM2 sets the remote `vehCarDamage` from each packet (`PositionUpdate`, clearing it when it drops to 0). | needs `VehicleEffects` per remote car fed from the damage value. | +| 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. | | S7 | minor | `RaceScreen::sendLocalState` | The local player has no siren (a police car's horn key in MM2 toggles it), so `kVehicleSiren` is never sent; the remote audio and lights would play it. | outside this area (player controls). | Lobby review O1 (the race starts a fixed 6 s after GO DRIVE instead of after -everyone's RaceReady) touches this area only in that a slow loader starts its -own race countdown late: its clock and its finish time are measured from its -own start, while Cops and Robbers' clock (finding 13) runs from the shared -start time. Nothing here depends on it otherwise. +everyone's RaceReady) touched this area only in that a slow loader started +its own race countdown late. It is fixed (docs/multiplayer.md, "Race start"): +every machine's countdown ends at the start the host sets once everyone has +loaded, which Cops and Robbers' clock (finding 13) runs from; only a machine +the host stopped waiting for (60 s) counts down on its own, its race time +measured from its own start as in MM2. ### MM2 rules compared diff --git a/packaging/linux/io.github.lowlevelmetal.OpenMM2.metainfo.xml b/packaging/linux/io.github.lowlevelmetal.OpenMM2.metainfo.xml index 8c5e795..2b936ab 100644 --- a/packaging/linux/io.github.lowlevelmetal.OpenMM2.metainfo.xml +++ b/packaging/linux/io.github.lowlevelmetal.OpenMM2.metainfo.xml @@ -44,6 +44,11 @@ 768 + + +

Smooth motion at any frame rate (drawn between the simulation steps), network races that start together once every player has loaded, and other players' and the police's damage shown in multiplayer.

+
+

Multiplayer fixes: races after the first in a session no longer send everyone back to the lobby, and other players' cars no longer jitter. Multiplayer cruise gains shared traffic and police, run by the host.

diff --git a/src/ai/World.cpp b/src/ai/World.cpp index 933b6ad..0ddc3e1 100644 --- a/src/ai/World.cpp +++ b/src/ai/World.cpp @@ -245,6 +245,8 @@ std::unique_ptr World::create(const city::CityData& city, const vfs::Vfs& } void World::step(const PlayerCar& player) { + if (m_stepObserver) + m_stepObserver(); // aiMap::Update: the player's room from the room it was last in (MM2 // keeps that in aiVehiclePlayer; inferred equivalent: the last room // found); 0 off every room leaves the populations alone. diff --git a/src/ai/World.h b/src/ai/World.h index 68efe90..f48760c 100644 --- a/src/ai/World.h +++ b/src/ai/World.h @@ -21,6 +21,7 @@ #include "vfs/Vfs.h" #include +#include #include #include #include @@ -81,6 +82,13 @@ class World { // Advances by `dt` seconds in fixed steps (at most 8 per call). void update(float dt, const PlayerCar& player); + // The share of a step update() has not run yet: what is drawn between + // two steps blends the last two states by it (game::StepHistory). + float interpolationAlpha() const { return m_accumulator / kAiStepSeconds; } + // OpenMM2 presentation: called at the start of every step, before + // anything moves, so that the drawing can keep the cars' and the + // pedestrians' places from before the step. It must not change the AI. + void setStepObserver(std::function observer) { m_stepObserver = std::move(observer); } void update(float dt, const Vec3& playerPos, const Vec3& playerVel) { update(dt, PlayerCar::at(playerPos, playerVel)); } @@ -186,6 +194,7 @@ class World { std::unique_ptr m_peds; std::vector m_signals; float m_accumulator = 0.0f; + std::function m_stepObserver; float m_vehicleClock = 0.0f; // aiVehicleManager's summed time (never reset) bool m_lightsDeferred = false; int m_pendingLightSteps = 0; diff --git a/src/app/App.cpp b/src/app/App.cpp index 45f6d1f..d8f8d36 100644 --- a/src/app/App.cpp +++ b/src/app/App.cpp @@ -220,12 +220,24 @@ int run(const CommandLine& cl) { dev.endOverlay(); const bool lastFrame = (cl.frames && frame + 1 >= *cl.frames) || ctx.lastFrameRequested; - if (lastFrame && cl.screenshot) + const int tag = std::exchange(ctx.captureTag, -1); + if ((lastFrame || tag >= 0) && cl.screenshot) dev.requestCapture(); limiter.wait(); dev.endFrame(); ++frame; + if (tag >= 0 && !lastFrame && cl.screenshot) { + auto path = str::toPath(*cl.screenshot); + path.replace_filename( + std::format("{}-{}{}", path.stem().string(), tag, path.extension().string())); + render::Image image; + if (dev.readCapture(image) && render::writePng(path, image)) + log::info("screenshot: {}", path.string()); + else + log::error("screenshot: capture failed"); + } + if (lastFrame) { if (cl.screenshot) { render::Image image; diff --git a/src/app/CMakeLists.txt b/src/app/CMakeLists.txt index ef31cd5..c0e3f71 100644 --- a/src/app/CMakeLists.txt +++ b/src/app/CMakeLists.txt @@ -12,6 +12,7 @@ add_library(mm2_app STATIC App.cpp SetupScreen.cpp RaceScreen.cpp + DrawTrace.cpp IntroScreen.cpp ${app_frontend_sources}) target_link_libraries(mm2_app diff --git a/src/app/Context.h b/src/app/Context.h index b700bb4..36ae571 100644 --- a/src/app/Context.h +++ b/src/app/Context.h @@ -69,6 +69,9 @@ struct Context { // Automation: this frame is the last (as --frames' last, with its // --screenshot); set by OPENMM2_DEBUG_NET_SHOT_MS in a network race. bool lastFrameRequested = false; + // Automation: --screenshot's picture of this frame as well, saved as + // "-." (OPENMM2_DEBUG_NET_SHOT_MS's earlier times). + int captureTag = -1; // Writes settings (including [Display]) to disk. void saveSettings(); diff --git a/src/app/DrawTrace.cpp b/src/app/DrawTrace.cpp new file mode 100644 index 0000000..e211d0f --- /dev/null +++ b/src/app/DrawTrace.cpp @@ -0,0 +1,45 @@ +#include "app/DrawTrace.h" + +#include "core/Log.h" + +#include + +namespace mm2::app { + +std::unique_ptr DrawTrace::fromEnvironment() { + const char* path = std::getenv("OPENMM2_DEBUG_DRAW_TRACE"); + if (!path || !*path) + return nullptr; + std::FILE* f = std::fopen(path, "w"); + if (!f) { + log::warn("race: cannot write the draw trace {}", path); + return nullptr; + } + std::fprintf(f, "# frame dt steps alpha car(x y z) point(x y z) segment speed other(x y z)\n"); + return std::make_unique(f); +} + +void DrawTrace::frame(double dt, int steps, float alpha, const Mat34& camera, const Mat34& car, float speed, + const std::optional& other) { + // The roadside point stands 40 m ahead of the car and 6 m to its right + // until it is behind the camera or far from it; a new point starts a new + // segment (the analysis differences positions within one). + const Vec3 ahead = car.m3 - car.m2 * 40.0f + car.m0 * 6.0f; + if (m_point) { + const Vec3 v = camera.untransform(*m_point); + if (v.z > -2.0f || v.mag() > 80.0f) + m_point.reset(); + } + if (!m_point) { + m_point = ahead; + ++m_segment; + } + const Vec3 c = camera.untransform(car.m3); + const Vec3 p = camera.untransform(*m_point); + const Vec3 o = other ? camera.untransform(other->m3) : Vec3{}; + std::fprintf(m_file.get(), "%ld %.6f %d %.4f %.5f %.5f %.5f %.5f %.5f %.5f %d %.3f %d %.5f %.5f %.5f\n", + m_frame++, dt, steps, alpha, c.x, c.y, c.z, p.x, p.y, p.z, m_segment, speed, + other ? 1 : 0, o.x, o.y, o.z); +} + +} // namespace mm2::app diff --git a/src/app/DrawTrace.h b/src/app/DrawTrace.h new file mode 100644 index 0000000..5b46953 --- /dev/null +++ b/src/app/DrawTrace.h @@ -0,0 +1,35 @@ +#pragma once + +// Development aid: OPENMM2_DEBUG_DRAW_TRACE= writes, once a frame, +// where the race drew the player's car, a roadside point and the nearest +// other car, in the camera's frame, to measure how smoothly they move (see +// docs/rendering.md, "Drawing between simulation steps"). Off unless set. + +#include "core/Math.h" + +#include +#include +#include + +namespace mm2::app { + +class DrawTrace { +public: + // A trace when OPENMM2_DEBUG_DRAW_TRACE names a file it can write. + static std::unique_ptr fromEnvironment(); + explicit DrawTrace(std::FILE* file) : m_file(file, &std::fclose) {} + + // One frame: its time (s), the simulation steps it ran and the blend + // between the last two (0 before interpolation), the camera, the car as + // drawn and its speed (m/s), and the nearest other car as drawn. + void frame(double dt, int steps, float alpha, const Mat34& camera, const Mat34& car, float speed, + const std::optional& other); + +private: + std::unique_ptr m_file; + std::optional m_point; // the roadside point (world) + int m_segment = 0; // counts the points chosen + long m_frame = 0; +}; + +} // namespace mm2::app diff --git a/src/app/RaceScreen.cpp b/src/app/RaceScreen.cpp index c5bae52..82b865f 100644 --- a/src/app/RaceScreen.cpp +++ b/src/app/RaceScreen.cpp @@ -1,5 +1,6 @@ // A session in the city. #include "app/Controls.h" +#include "app/DrawTrace.h" #include "app/ForceFeedback.h" #include "app/GameInput.h" #include "app/Screens.h" @@ -40,13 +41,16 @@ #include "game/fx/SkidMarks.h" #include "game/fx/VehicleEffects.h" #include "game/fx/Weather.h" +#include "game/net/DamageSync.h" #include "game/net/NetGame.h" +#include "game/net/RaceStart.h" #include "game/net/TrafficProxies.h" #include "game/net/TrafficSync.h" #include "game/CamMirror.h" #include "game/CamPlayer.h" #include "game/CityRenderer.h" #include "game/CityLevel.h" +#include "game/Interpolation.h" #include "game/PlayerVehicle.h" #include "game/VehicleRenderer.h" #include "game/world/CableCars.h" @@ -63,6 +67,7 @@ #include #include #include +#include #include #include #include @@ -191,6 +196,7 @@ class RaceScreen final : public Screen { void update(Context& ctx, double dt) override { m_time += dt; m_frameDt = static_cast(dt); + m_frameSteps = 0; if (m_state == State::ShowLoading) { // BeginPhase: the loading picture with the bar at 10 %, drawn // once before the loading blocks. @@ -203,9 +209,16 @@ class RaceScreen final : public Screen { // A network race keeps its session serviced between the parts // (acknowledgements and pings), so that the others do not time // this machine out while it loads more slowly than they do. What - // arrives meanwhile waits for the first frame of the race. - if (multiplayer(ctx)) + // arrives meanwhile waits for the first frame of the race, except + // the end of the race (the host took everyone back, or left). + if (multiplayer(ctx) && !ctx.nextScreen) { ctx.netGame->update(); + if (ctx.netGame->backToLobby(m_netRace) || !ctx.netGame->inSession()) { + log::info("race: the network race is over before it has loaded ({})", + ctx.netGame->inSession() ? "back to the lobby" : "the session ended"); + leaveRace(ctx, m_result); + } + } return; } // GameLoop: AudManager::Update before the game's update, then again @@ -266,10 +279,26 @@ class RaceScreen final : public Screen { m_hud->updateChat(static_cast(dt)); // Development aid: OPENMM2_DEBUG_NET_SHOT_MS= ends a // network race at that session time (with --screenshot: every - // machine's picture of the same moment). - if (const char* shot = std::getenv("OPENMM2_DEBUG_NET_SHOT_MS"); shot && multiplayer(ctx) && - ctx.netGame->raceStarted() && ctx.netGame->sessionTime() >= str::parseInt(shot).value_or(0)) - ctx.lastFrameRequested = true; + // machine's picture of the same moment); "+" counts from the + // race's order (GO DRIVE), and also before the race has started. + // Several times, comma-separated: the earlier ones save numbered + // pictures (-1.png, ...) and the race goes on. + if (const char* shots = std::getenv("OPENMM2_DEBUG_NET_SHOT_MS"); shots && multiplayer(ctx)) { + const auto list = str::split(shots, ','); + const auto k = static_cast(m_netShotsTaken); + const std::string_view shot = k < list.size() ? list[k] : std::string_view{}; + const bool fromOrder = shot.starts_with('+'); + const long long at = str::parseInt(fromOrder ? shot.substr(1) : shot).value_or(0) + + (fromOrder ? static_cast(ctx.netGame->raceOrderTime()) : 0LL); + const auto now = static_cast(ctx.netGame->sessionTime()); + if (!shot.empty() && (fromOrder || ctx.netGame->raceStarted()) && now >= at) { + if (++m_netShotsTaken == static_cast(list.size())) + ctx.lastFrameRequested = true; + else + ctx.captureTag = m_netShotsTaken; + } + } + debugRespawn(ctx); if (multiplayer(ctx)) { ctx.netGame->update(); if (ctx.netGame->backToLobby(m_netRace) || !ctx.netGame->inSession()) { @@ -279,6 +308,7 @@ class RaceScreen final : public Screen { return; } takeNetEvents(ctx); + updateNetStart(ctx, static_cast(dt)); } if (ctx.input.keyPressed(platform::Key::F2)) m_flyCamera = !m_flyCamera; @@ -338,6 +368,8 @@ class RaceScreen final : public Screen { } updateRemoteCars(ctx, static_cast(dt)); updateNetTraffic(ctx, static_cast(dt)); // OpenMM2: a client's shared traffic + if (multiplayer(ctx)) + m_netDamage.settle(ctx.netGame->frameTime()); // OpenMM2: the damage of cars not drawn if (netTrafficHost(ctx) && m_ai) applyNetTrafficHits(); // OpenMM2: the clients' hits on the host's traffic // aiVehicleManager::Update and the rail cars' rooms, before the @@ -361,7 +393,8 @@ class RaceScreen final : public Screen { if (!m_flyCamera && (in.keyDown(Key::LCtrl) || in.keyDown(Key::RCtrl)) && in.keyPressed(Key::B)) phys::Trailer::breakKeyPressed = true; } - if (m_world && m_world->advanceFixed(static_cast(dt)) > 0) + m_frameSteps = m_world ? m_world->advanceFixed(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. @@ -375,6 +408,7 @@ class RaceScreen final : public Screen { if (multiplayer(ctx)) sendLocalState(ctx); } + updateDrawnPoses(); // OpenMM2: between the last two simulation steps sendNetTraffic(ctx); // OpenMM2: the host's shared traffic if (m_flyCamera || !m_player) updateFlyCamera(ctx, static_cast(dt)); @@ -382,11 +416,15 @@ class RaceScreen final : public Screen { updateCarCamera(ctx, static_cast(dt)); updateAudio(ctx, static_cast(dt)); updateEffects(static_cast(dt)); + sendNetDamage(ctx); // OpenMM2: what this machine's cars painted and broke updateSession(ctx, static_cast(dt)); updateCopsAndRobbers(ctx, static_cast(dt)); // Development aid: OPENMM2_DEBUG_FOCUS=ped|car frames the nearest - // pedestrian or traffic car (for screenshots). - if (const char* focus = std::getenv("OPENMM2_DEBUG_FOCUS"); focus && m_ai) { + // 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. + 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; float best = 1e30f; @@ -397,12 +435,58 @@ class RaceScreen final : public Screen { target = m; } }; - if (std::string_view(focus) == "ped") + const std::string_view what(focus); + if (what.starts_with("net:") && multiplayer(ctx)) { + const int id = str::parseInt(what.substr(4)).value_or(-1); + if (id == ctx.netGame->localId() && m_player) + consider(m_drawPose.body); + for (const auto& rc : m_remoteCars) + if (rc.id == id && rc.hasState) + consider(netCarDrawn(rc.id).value_or(rc.transform)); + } else if (what.starts_with("police:")) { + const int id = str::parseInt(what.substr(7)).value_or(-1); + const auto i = static_cast(id - kNetPoliceId); + if (id >= kNetPoliceId && i < m_cops.size()) + consider(drawnPose(game::Drawn::Police, i, *m_cops[i].sim).body); + if (const auto it = m_netCops.find(id); it != m_netCops.end() && it->second.sim) + consider(netCopDrawn(id).value_or(it->second.state.transform)); + } else if (what.starts_with("knocked:") && multiplayer(ctx)) { + // The knocked traffic car with a body within 60 m of a + // player's car with the lowest id (shared traffic: the host's + // simulated, a client's received: the same car on both). + const int id = str::parseInt(what.substr(8)).value_or(-1); + std::optional player; + if (id == ctx.netGame->localId() && m_player) + player = m_drawPose.body; + else if (id >= 0 && id < 256) + player = netCarDrawn(static_cast(id)); + if (player && (m_trafficClient || m_ai)) { + const Vec3 at = player->m3; + int lowest = std::numeric_limits::max(); + for (const auto& c : m_trafficClient ? m_netCarsDrawn : m_ai->cars()) { + const auto physical = physicalTrafficCar(c.id); + const bool near = c.transform.m3.dist(at) < 60.0f; + if (physical && physical->active && c.id < lowest && near) { + lowest = c.id; + target = physical->transform; + } + } + } + } else if (what.starts_with("traffic:") && (m_trafficClient || m_ai)) { + // A traffic car by id (shared traffic: the same car on every machine). + const int id = str::parseInt(what.substr(8)).value_or(-1); + for (const auto& c : m_trafficClient ? m_netCarsDrawn : m_ai->cars()) + if (c.id == id) { + const auto physical = physicalTrafficCar(c.id); + target = physical ? physical->transform : c.transform; + } + } else if (what == "ped" && m_ai) { for (const auto& p : m_ai->peds()) consider(p.transform); - else + } else if (m_ai) { for (const auto& c : m_ai->cars()) consider(c.transform); + } if (target) { const Vec3 eye = target->m3 + target->transformDir({2.5f, 1.6f, -3.5f}); m_camera.transform = game::Camera::lookAt(eye, target->m3 + Vec3{0, 0.9f, 0}); @@ -435,12 +519,16 @@ class RaceScreen final : public Screen { clear.color = {0.0f, 0.0f, 0.0f, 1.0f}; dev.beginScene(clear); std::vector blips = hudBlips(ctx); + // OpenMM2: the map and the dashboard follow the car as drawn. + game::session::PlayerState shown = m_playerState; + if (m_player) + shown.transform = drawnIcs(m_drawPose.body, *m_player); // mmGameManager::Cull draws the full-screen map before the level. const bool hudShown = m_hud && m_session && m_player && !m_flyCamera; const bool mapShown = hudShown && (m_popup == Popup::None || m_popup == Popup::Chat); const bool fullMap = mapShown && m_cams.mapMode() == game::MapMode::FullScreen; if (fullMap) - m_hud->drawMap(*m_session, m_playerState, blips, m_frameDt); + m_hud->drawMap(*m_session, shown, blips, m_frameDt); if (letterbox) { dev.setViewport({static_cast(band.x), static_cast(band.y), static_cast(band.width), static_cast(band.height)}); @@ -467,6 +555,9 @@ class RaceScreen final : public Screen { game::VehicleRenderer::setLensFlareTarget(&viewProj, proj.aspect, &flares); drawLevel(ctx, m_camera, frustum, playerBody, m_frameDt); game::VehicleRenderer::setLensFlareTarget(nullptr, 1.0f, nullptr); + if (m_drawTrace && m_player) + m_drawTrace->frame(m_frameDt, m_frameSteps, m_drawnPhys.alpha(), m_camera.transform, + m_drawPose.body, m_player->sim().speed(), nearestDrawnCar(ctx)); if (!flares.empty()) { game::fx::drawLensFlares(dev, *m_textures, flares); dev.setFrameConstants(frame); @@ -476,7 +567,7 @@ class RaceScreen final : public Screen { m_hud->setViewProjection(frame.view * frame.proj); m_hud->setDashFrame(frame, Mat44::perspective(proj.fovY, proj.aspect, 0.01f, m_camera.farPlane, true)); // mmDashView turns the wheel by the recorded steering (mmPlayer +0x2264). - m_hud->drawWorld(*m_session, m_camera, m_playerState, m_steerApplied, blips); + m_hud->drawWorld(*m_session, m_camera, shown, m_steerApplied, blips); } if (letterbox) { dev.setScissor(nullptr); @@ -485,7 +576,7 @@ class RaceScreen final : public Screen { } // mmPopup::ProcessEscape deactivates the map (the chat line does not). if (mapShown && !fullMap) - m_hud->drawMap(*m_session, m_playerState, blips, m_frameDt); + m_hud->drawMap(*m_session, shown, blips, m_frameDt); // mmGameManager::Update declares the mirror after the dashboard and // the HUD map. drawMirror(ctx, frame); @@ -513,11 +604,12 @@ class RaceScreen final : public Screen { std::vector hudBlips(Context& ctx) const { using game::session::MapBlip; std::vector blips; - // mmHudMap and mmIcons follow the cars' phInertialCS matrices; the - // icons show the opponents' places (mmSingleRace / mmSingleCircuit - // ::UpdateScore). + // mmHudMap and mmIcons follow the cars' phInertialCS matrices (OpenMM2: + // as drawn); the icons show the opponents' places (mmSingleRace / + // mmSingleCircuit ::UpdateScore). for (std::size_t i = 0; i < m_opponents.size(); ++i) { - MapBlip b{m_opponents[i].sim->sim().body.ics.matrix, MapBlip::Kind::Opponent}; + const auto& car = *m_opponents[i].sim; + MapBlip b{drawnIcs(drawnPose(game::Drawn::Opponent, i, car).body, car), MapBlip::Kind::Opponent}; if (m_session) b.place = m_session->opponentPlace(m_opponents[i].sessionIndex); blips.push_back(std::move(b)); @@ -542,7 +634,8 @@ class RaceScreen final : public Screen { if (players[k].id != ctx.netGame->localId()) ++other; } - MapBlip b{rc.transform, MapBlip::Kind::Remote}; + const auto drawn = m_remoteDrawn.find(rc.id); + MapBlip b{drawn != m_remoteDrawn.end() ? drawn->second : rc.transform, MapBlip::Kind::Remote}; b.slot = slot; b.name = rc.name; b.iconColor = game::session::hud::netIconColor(slot); @@ -562,9 +655,12 @@ class RaceScreen final : public Screen { } // mmHudMap::DrawCops: the police in pursuit (aiPoliceOfficer::InPersuit: // state 0x977a not 0, which also holds for a wrecked, out-of-action cop). - for (const auto& c : m_cops) - if (c.driver->mode() != ai::PoliceCar::Mode::Parked) - blips.push_back({c.sim->sim().body.ics.matrix, game::session::MapBlip::Kind::Police}); + for (std::size_t i = 0; i < m_cops.size(); ++i) { + const auto& car = *m_cops[i].sim; + if (m_cops[i].driver->mode() != ai::PoliceCar::Mode::Parked) + blips.push_back({drawnIcs(drawnPose(game::Drawn::Police, i, car).body, car), + game::session::MapBlip::Kind::Police}); + } // OpenMM2's shared traffic: the host's police in pursuit, on a client. for (const auto& [id, c] : m_netCops) if (c.sim && (c.state.flags & net::kAmbientPursuit)) @@ -572,6 +668,97 @@ class RaceScreen final : public Screen { return blips; } + // OPENMM2_DEBUG_DRAW_TRACE: the other car drawn nearest the player's. + std::optional nearestDrawnCar(Context& ctx) const { + std::optional best; + float bestDist = 1e30f; + auto consider = [&](const Mat34& m) { + const float d = m.m3.dist2(m_pose.body.m3); + if (d < bestDist) { + bestDist = d; + best = m; + } + }; + for (std::size_t i = 0; i < m_opponents.size(); ++i) + consider(drawnPose(game::Drawn::Opponent, i, *m_opponents[i].sim).body); + for (std::size_t i = 0; i < m_cops.size(); ++i) + consider(drawnPose(game::Drawn::Police, i, *m_cops[i].sim).body); + if (multiplayer(ctx)) + for (const auto& [id, transform] : m_remoteDrawn) + consider(transform); + return best; + } + + // --- OpenMM2: drawing between simulation steps (game/Interpolation.h) --- + // + // The physics' step observer: the state of everything it moves, as it + // stands before the sample. + void recordStepPoses() { + using game::Drawn; + using game::drawnKey; + m_drawnPhys.beginStep(); + if (m_player) { + const std::uint32_t resets = m_player->sim().resets; + m_drawnPhys.record(drawnKey(Drawn::Player, 0), m_player->pose(), resets); + if (m_player->trailer()) + m_drawnPhys.record(drawnKey(Drawn::PlayerTrailer, 0), m_player->trailerPose(), resets); + } + for (std::size_t i = 0; i < m_opponents.size(); ++i) + m_drawnPhys.record(drawnKey(Drawn::Opponent, i), m_opponents[i].sim->pose(), + m_opponents[i].sim->sim().resets); + for (std::size_t i = 0; i < m_cops.size(); ++i) + m_drawnPhys.record(drawnKey(Drawn::Police, i), m_cops[i].sim->pose(), + m_cops[i].sim->sim().resets); + // A network car's body is placed once a frame (updateRemoteCars) and + // its wheels follow in the samples: they are kept with the body they + // were simulated on. + std::map stepBodies; + for (const auto& [id, rv] : m_remotes) { + if (!rv.sim) + continue; + game::VehiclePose pose = rv.sim->pose(); + stepBodies[id] = pose.body; + if (const auto it = m_remoteStepBodies.find(id); it != m_remoteStepBodies.end()) + pose.body = it->second; + const std::uint32_t resets = rv.sim->sim().resets; + m_drawnPhys.record(drawnKey(Drawn::RemoteCar, id), pose, resets); + if (rv.sim->trailer()) + m_drawnPhys.record(drawnKey(Drawn::RemoteTrailer, id), rv.sim->trailerPose(), resets); + } + m_remoteStepBodies = std::move(stepBodies); + 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); + } + + // A car's phInertialCS matrix from its model matrix `model` (vehCarSim:: + // SetWorldMatrix's offset taken off). + static Mat34 drawnIcs(const Mat34& model, const game::SimVehicle& car) { + Mat34 m = model; + m.m3 = model.m3 - model.transformDir(car.sim().centerOfGravity); + return m; + } + + // A simulated car between the physics' last two samples. + game::VehiclePose drawnPose(game::Drawn kind, std::uint64_t id, const game::SimVehicle& car) const { + return m_drawnPhys.pose(game::drawnKey(kind, id), car.pose(), car.sim().resets); + } + + // After the frame's steps: how far the simulations are into their next + // steps, and the player's car as drawn (the cameras follow it). + void updateDrawnPoses() { + if (m_world) + m_drawnPhys.setAlpha(m_world->interpolationAlpha()); + if (m_ai) + m_drawnAi.setAlpha(m_ai->interpolationAlpha()); + 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); + } + // The level as lvlLevel::Draw draws it for one view: the city, traffic, // props, the cars and their effects (lvlLevel's callbacks) and the rain. // `playerBody` false hides the player's car; `dt` advances the shared @@ -589,7 +776,10 @@ class RaceScreen final : public Screen { const bool night = m_result.config.timeOfDay == game::TimeOfDay::Night; if (m_bangers) m_bangers->draw(dev, *m_models, *m_textures, m_cards, frustum, camera, - {m_detail.objects, night, &m_cityRenderer->rooms()}); + {m_detail.objects, night, &m_cityRenderer->rooms(), + [this](std::size_t i, const Mat34& m) { + return m_drawnPhys.transform(game::drawnKey(game::Drawn::Prop, i), m); + }}); // The gizmos: the managers' bridges and ferries, and the train cars // and sailboats from their rooms; the cable cars from theirs. if (m_gizmos) @@ -600,20 +790,22 @@ class RaceScreen final : public Screen { &m_cityRenderer->rooms()); const bool lights = carLights(); if (m_vehicle && playerBody) { - m_pose.headlights = lights; - m_vehicle->draw(m_pose, camera.transform); + m_drawPose.headlights = lights; + m_vehicle->draw(m_drawPose, camera.transform); } if (m_trailer && (m_flyCamera || m_cams.display() == game::CarDisplay::Body || !playerBody)) { - m_trailerPose.headlights = lights; - m_trailer->draw(m_trailerPose, camera.transform); + m_drawTrailerPose.headlights = lights; + m_trailer->draw(m_drawTrailerPose, camera.transform); } - for (const auto& o : m_opponents) { - game::VehiclePose pose = o.sim->pose(); + for (std::size_t i = 0; i < m_opponents.size(); ++i) { + const auto& o = m_opponents[i]; + game::VehiclePose pose = drawnPose(game::Drawn::Opponent, i, *o.sim); pose.headlights = lights; o.renderer->draw(pose, camera.transform); } - for (const auto& c : m_cops) { - game::VehiclePose pose = c.sim->pose(); + for (std::size_t i = 0; i < m_cops.size(); ++i) { + const auto& c = m_cops[i]; + game::VehiclePose pose = drawnPose(game::Drawn::Police, i, *c.sim); pose.headlights = lights; pose.siren = c.driver->siren(); pose.sirenAngle = c.sirenAngle; @@ -627,6 +819,7 @@ class RaceScreen final : public Screen { for (const auto& c : m_cops) if (c.fx) c.fx->draw(dev, *m_textures, m_cards, m_skids, camera.transform); + drawNetFx(dev, camera); // OpenMM2: the network cars' damage effects if (m_weather && rainVisible(camera.position())) m_weather->draw(dev, *m_textures, m_cards, camera.transform); } @@ -662,17 +855,26 @@ class RaceScreen final : public Screen { } // A traffic car TrafficBodies holds: where it is, and its wheels while - // it has a body (aiVehicleInstance::Draw). + // 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) { + const auto it = m_netPhysical.find(id); + return it != m_netPhysical.end() ? std::optional(it->second) : std::nullopt; + } const Mat34* m = m_trafficBodies ? m_trafficBodies->transformOf(id) : nullptr; if (!m) return std::nullopt; game::AiRenderer::PhysicalCar car; car.transform = *m; - if (const auto wheels = m_trafficBodies->wheelsOf(id)) { + if (const auto pose = game::trafficBodyPose(*m_trafficBodies, id)) { + const auto key = game::drawnKey(game::Drawn::TrafficCar, static_cast(id)); + const game::VehiclePose drawn = m_drawnPhys.pose(key, *pose); car.active = true; - car.wheels = wheels->matrix; - car.wheelValid = wheels->valid; + car.transform = drawn.body; + car.wheels = drawn.wheelWorld; + car.wheelValid = drawn.wheelValid; } return car; } @@ -691,7 +893,7 @@ class RaceScreen final : public Screen { return; const auto& params = m_mirror.params(); game::Camera camera; - camera.transform = m_mirror.worldMatrix(m_pose.body); + camera.transform = m_mirror.worldMatrix(m_drawPose.body); camera.nearPlane = params.nearClip; camera.farPlane = params.farClip; render::FrameConstants frame = mainFrame; @@ -777,10 +979,30 @@ class RaceScreen final : public Screen { placeRespawnStart(ctx); m_loadPercent = 100; return; - default: loadFinish(ctx); return; + default: + if (debugLoadStalled()) { + --m_loadStep; // this step again next frame + return; + } + loadFinish(ctx); + return; } } + // Development aid: OPENMM2_DEBUG_LOAD_DELAY_MS= keeps the loading + // screen up until that long after the loading began (the frames go on, + // and a network race keeps its session serviced), to try a slow loader. + bool debugLoadStalled() const { + static const long long delayMs = [] { + const char* env = std::getenv("OPENMM2_DEBUG_LOAD_DELAY_MS"); + return env ? str::parseInt(env).value_or(0) : 0; + }(); + if (delayMs <= 0) + return false; + const auto elapsed = std::chrono::steady_clock::now() - m_loadStart; + return elapsed < std::chrono::milliseconds(delayMs); + } + void loadCityPart(Context& ctx) { m_loadStart = std::chrono::steady_clock::now(); std::string error; @@ -789,8 +1011,11 @@ class RaceScreen final : public Screen { log::error("race: cannot load city '{}': {}", m_result.config.city, error); // Back to the menus; the host of a network race takes everyone // back to the lobby, which could otherwise never start another. - if (multiplayer(ctx)) + if (multiplayer(ctx)) { ctx.netGame->addNotice(std::format("Cannot load the city '{}': {}", m_result.config.city, error)); + if (!ctx.netGame->isHost()) + ctx.netGame->sendLeftRace(); // the others stop waiting for this machine + } leaveRace(ctx, m_result); return; } @@ -880,6 +1105,10 @@ class RaceScreen final : public Screen { if (m_result.config.mode != game::GameMode::Cruise && !multiplayer(ctx)) m_cams.startPreRace(); } + // A network race's start begins with its first frame (state 0), with + // or without race rules: it reports this machine loaded. + if (multiplayer(ctx)) + m_netStart.emplace(game::NetRaceStart::kindOf(m_result.config.mode)); // -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) { @@ -933,6 +1162,7 @@ class RaceScreen final : public Screen { m_cityLevel = std::make_unique(*m_city, ctx.game->vfs, [this](std::string_view n) { return m_bangerData->has(n); }); m_world = std::make_unique(m_cityLevel->takeMaterials()); + m_world->setStepObserver([this] { recordStepPoses(); }); m_world->setStatic(m_cityLevel->takeProbeSoup()); m_world->setLevel(m_cityLevel.get()); @@ -948,6 +1178,8 @@ class RaceScreen final : public Screen { // seen by the others: back to the lobby (the host takes everyone). if (multiplayer(ctx)) { ctx.netGame->addNotice(std::format("Cannot load the car '{}': {}", m_result.config.vehicle, error)); + if (!ctx.netGame->isHost()) + ctx.netGame->sendLeftRace(); leaveRace(ctx, m_result); } return; @@ -1081,12 +1313,38 @@ class RaceScreen final : public Screen { return; log::info("race: start at intersection {} ({:.3f}, {:.3f}, {:.3f})", pick->intersection, pick->position.x, pick->position.y, pick->position.z); - m_player->setResetPos(pick->position, pick->angle); + Vec3 position = pick->position; + float angle = pick->angle; + // Development aid: OPENMM2_DEBUG_START_NEAR_POLICE=: + // starts the car that far in front of a police post's car, facing it + // (every machine knows the posts; a network cruise's damage tests). + if (const char* near = std::getenv("OPENMM2_DEBUG_START_NEAR_POLICE")) { + const auto parts = str::split(near, ':'); + const auto post = static_cast(str::parseInt(parts[0]).value_or(0)); + const float metres = + parts.size() > 1 ? static_cast(str::parseDouble(parts[1]).value_or(20.0)) : 20.0f; + if (post < m_session->police().size()) { + const Mat34& spawn = m_session->police()[post].spawn; + position = spawn.m3 - spawn.m2 * metres; + angle = phys::resetRotationOf(spawn) + 3.1415927f; + } + } + // ... and OPENMM2_DEBUG_START=,,, at that place. + if (const char* at = std::getenv("OPENMM2_DEBUG_START")) { + const auto v = str::split(at, ','); + auto f = [&](std::size_t i) { + return i < v.size() ? static_cast(str::parseDouble(v[i]).value_or(0.0)) : 0.0f; + }; + position = {f(0), f(1), f(2)}; + angle = f(3); + } + log::info("race: start angle {:.4f}", angle); + m_player->setResetPos(position, angle); m_player->reset(); m_pose = m_player->pose(); m_cams.reset(cameraTarget()); - m_position = pick->position + Mat34::rotationY(pick->angle).transformDir({0, 2.2f, 7.0f}); - m_yaw = pick->angle; + m_position = position + Mat34::rotationY(angle).transformDir({0, 2.2f, 7.0f}); + m_yaw = angle; } void createSession(Context& ctx) { @@ -1327,11 +1585,28 @@ class RaceScreen final : public Screen { setupVehicleRenderer(ctx, *cop.renderer); cop.audio = loadAiCarAudio(ctx, p.vehicle, true); cop.fx = loadVehicleFx(ctx, p.vehicle, cop.sim->model(), *cop.renderer); - cop.sim->sim().onImpactCallback = [fx = cop.fx.get(), sim = &cop.sim->sim(), - sounds = cop.impacts](const phys::CarImpact& impact) { + // Development aid: OPENMM2_DEBUG_POLICE_TOUGHNESS= scales + // the police cars' MedDamage and MaxDamage (a wreck sooner, for + // the shared traffic's damage tests). + if (const char* tough = std::getenv("OPENMM2_DEBUG_POLICE_TOUGHNESS")) { + const auto f = static_cast(str::parseDouble(tough).value_or(1.0)); + auto& d = cop.sim->sim().damage.params; + d.medDamage *= f; + d.maxDamage *= f; + } + // OpenMM2: a shared-traffic host shows its police's damage on the + // clients (the car's ambient id, as sendNetTraffic shares it). + const int netId = kNetPoliceId + static_cast(m_cops.size()); + if (netTrafficHost(ctx) && netId < static_cast(net::kMaxAmbientIds)) + cop.damage = &m_netDamage.police(static_cast(netId)); + cop.sim->sim().onImpactCallback = [fx = cop.fx.get(), sim = &cop.sim->sim(), sounds = cop.impacts, + damage = cop.damage, + time = &m_netStateTime](const phys::CarImpact& impact) { if (impact.sound) sounds->push_back({impact.soundStrength, impact.audioId, impact.position}); fx->impact(impact, *sim); + if (damage && impact.damaging) + damage->impact(*time, game::damageImpactOf(impact, *sim)); }; m_cops.push_back(std::move(cop)); } @@ -1539,6 +1814,7 @@ class RaceScreen final : public Screen { in.wrecked = (rc.flags & net::kVehicleWrecked) != 0; in.transform = rc.transform; in.velocity = rc.velocity; + in.impacts = std::exchange(it->second.impacts, {}); // OpenMM2: its owner's, replayed it->second.audio->update(in, dt, listener); } } @@ -1735,8 +2011,11 @@ class RaceScreen final : public Screen { 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 shares the start time instead. - m_session->setStartSignal(!multiplayer(ctx) || ctx.netGame->secondsToStart() <= 2.5); + // message; OpenMM2's follows the shared start time (updateNetStart). + if (multiplayer(ctx)) + m_session->setNetStart(m_netToGo); + else + m_session->setStartSignal(true); m_session->update(dt, m_playerState, opps, cops); // DisableRacers / EnableRacers: the player's vehCarDamage switch. m_player->sim().damage.enabled = m_session->playerDamageEnabled(); @@ -1825,8 +2104,7 @@ class RaceScreen final : public Screen { m_crWaterHandled = m_cr != nullptr; // mmMultiCR::HitWaterHandler drops the gold if (m_vehicleFx) m_vehicleFx->reset(); // vehCar::Reset - if (m_vehicle) - m_vehicle->resetDamage(); + clearVehicleDamage(); m_cams.reset(cameraTarget()); } else if (e.type == EventType::Restart) { // The race starts over (mmGame::Reset): every prop back in its @@ -1846,8 +2124,7 @@ class RaceScreen final : public Screen { m_player->reset(); if (m_vehicleFx) m_vehicleFx->reset(); - if (m_vehicle) - m_vehicle->resetDamage(); + clearVehicleDamage(); for (auto& o : m_opponents) { o.sim->reset(); if (o.fx) @@ -1870,6 +2147,9 @@ class RaceScreen final : public Screen { if (c.fx) c.fx->reset(); // vehCar::Reset (aiVehiclePhysics::Reset) c.renderer->resetDamage(); + ++c.resets; // a new car for the shared traffic's clients + if (c.damage) + c.damage->reset(m_netStateTime); if (c.audio) c.audio->reset(); // aiPoliceOfficer::Reset -> vehPoliceCarAudio::Reset } @@ -1877,6 +2157,8 @@ class RaceScreen final : public Screen { if (m_musicDirector) m_musicDirector->restart(); m_musicFinished = m_musicResults = false; + m_drawnPhys.clear(); // OpenMM2: drawn where everything now is + m_drawnAi.clear(); m_cams.reset(cameraTarget()); // The race modes' Reset: mmPlayer::SetPreRaceCam again. if (m_result.config.mode != game::GameMode::Cruise && !multiplayer(ctx)) @@ -1885,8 +2167,7 @@ class RaceScreen final : public Screen { m_ff.reset(); // mmPlayer::Reset: ResetFF, mmCarRoadFF::Reset } else if (e.type == EventType::DamageReset) { m_player->sim().damage.reset(); - if (m_vehicle) - m_vehicle->resetDamage(); // vehCar::ClearDamage + clearVehicleDamage(); // vehCar::ClearDamage } else if (e.type == EventType::PlayerDamageLimits) { auto& d = m_player->sim().damage.params; d.maxDamage = e.value; @@ -2093,6 +2374,35 @@ class RaceScreen final : public Screen { // mmMultiRoam / Race / Circuit / Blitz / CR::SystemMessage 0x2d: a // player who leaves while the game runs gets "" / "has left the // game" (38) for 5 s at the bottom with mmHUD::PlayNetAlert. + // The network race's start on this machine (game::NetRaceStart): the + // loaded report, "Waiting for N players" while others still load, the + // countdown on the shared start, and a cruise's " has joined". + void updateNetStart(Context& ctx, float dt) { + if (!m_netStart) + return; + const auto out = m_netStart->update(dt, *ctx.netGame); + m_netHeld = out.held; + m_netToGo = out.secondsToGo; + if (!m_session) + return; + // mmMulti*::UpdateGame state 0: mmHUD::SetMessage(text, 5.0, 0) + // every frame while the count is above 0. + if (out.waitingFor > 0) { + const auto& strings = ctx.game->strings; + m_session->showMessage(game::NetRaceStart::waitingText(strings, out.waitingFor), 5.0f, false); + } + // mmGameMulti::GameMessageCB 0x1fa: the net alert, the name for 5 s + // and "has joined" (string 42) under it. + for (const auto id : out.joined) { + const auto* p = ctx.netGame->player(id); + if (!p) + continue; + playGameSound(ctx, game::session::GameSound::NetAlert, 0.0f); + m_session->showMessage(p->name, 5.0f, false); + m_session->showMessage2(ctx.game->strings.get(42, "has joined")); + } + } + void updateNetPlayers(Context& ctx) { if (!multiplayer(ctx) || !m_session) return; @@ -2100,7 +2410,10 @@ class RaceScreen final : public Screen { for (const auto& p : ctx.netGame->players()) if (!m_netLeft.contains(p.id)) now[p.id] = p.name; - if (m_netPlayersKnown && m_session->phase() == game::session::Phase::Racing) { + // The race modes say it while racing (state 3); Cops and Robbers in + // any state (mmMultiCR::SystemMessage 0x2d). + if (m_netPlayersKnown && (m_session->phase() == game::session::Phase::Racing || + m_result.config.mode == game::GameMode::CopsAndRobbers)) { for (const auto& [id, name] : m_netPlayers) { if (now.contains(id)) continue; @@ -2145,9 +2458,10 @@ class RaceScreen final : public Screen { st.goldMass = ctx.netGame->goldMass(); st.timeLimitSeconds = m_result.config.timeLimitMinutes * 60.0f; st.pointLimit = m_result.config.pointLimit; - // Every machine starts with the same places (OpenMM2: the shared - // start time seeds them; the host's sets follow by message). - st.seed = std::max(1u, ctx.netGame->raceStartTime()); + // 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()); m_crRng = st.seed; // GetRandomPoints' picker: mmGame::RespawnXYZ(false, false, false) // less its 2 m: an intersection whose room (FindRoomId of its @@ -2252,8 +2566,8 @@ class RaceScreen final : public Screen { using Type = CopsAndRobbers::Message::Type; const bool host = ctx.netGame->isHost(); // mmPlayer::UpdateRegen while regeneration is on. - if (m_regen && m_player->sim().regenerate() && m_vehicle) - m_vehicle->resetDamage(); + 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 @@ -2270,8 +2584,9 @@ class RaceScreen final : public Screen { // and every machine runs its rules on the session clock from there, // so the time limit runs out on all of them together (each counting // frames from its own load did not agree by the loads' difference). - const double raceClock = - std::max(0.0, (ctx.netGame->frameTime() - static_cast(ctx.netGame->raceStartTime())) / 1000.0); + const double sinceStart = + ctx.netGame->frameTime() - static_cast(ctx.netGame->raceStartTime()); + 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)); @@ -2354,8 +2669,7 @@ class RaceScreen final : public Screen { fondleMass(-m_cr->carrierExtraMassKg()); m_regen = true; m_player->sim().damage.reset(); - if (m_vehicle) - m_vehicle->resetDamage(); + clearVehicleDamage(); m_session->showMessage(s.get(117, "Gold delivered!"), 5.0f, false); } else { // GameMessage 600: with mmHUD::PlayNetAlert. @@ -2598,6 +2912,9 @@ class RaceScreen final : public Screen { // Robbers rules; the players who quit the race are noted here. void takeNetEvents(Context& ctx) { m_netEvents = ctx.netGame->takeGameEvents(); + // OpenMM2: the network cars' damage (game/net/DamageSync). + m_netDamage.setVerbose(m_debugNetDamage); + m_netDamage.receive(m_netEvents, ctx.netGame->frameTime()); for (const auto& ev : m_netEvents) if (ev.type == net::GameEventType::LeftRace && ev.from != ctx.netGame->localId()) { log::info("race: player {} quit the race", ev.from); @@ -3025,7 +3342,9 @@ class RaceScreen final : public Screen { return; } m_ai->setLightsDeferred(true); // updated after the racers and police + m_ai->setStepObserver([this] { game::recordAiStep(m_drawnAi, *m_ai); }); m_aiRenderer = std::make_unique(ctx.device(), *m_textures, *m_models, ctx.game->vfs); + m_aiRenderer->setInterpolation(&m_drawnAi); if (m_cityRenderer) m_aiRenderer->setRooms(&m_cityRenderer->rooms()); // cityLevel::DrawRooms' room gates m_aiRenderer->setGroundProbe([this](const Vec3& from, const Vec3& to, Vec3& point, Vec3& normal) { @@ -3225,24 +3544,18 @@ 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. void breakParts(game::fx::VehicleEffects& fx, game::VehicleRenderer& r, const phys::CarSim& sim, - const std::string& vehicle) { + const std::string& vehicle, game::DamageRecorder* recorder = nullptr) { 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) { - const auto* data = m_bangerData->find(vehicle + "_" + str::lower(b.part)); - if (!data) + if (!ejectCarPart(r, vehicle, b, body, speed, sim.body.room)) return false; - // vehBreakableMgr::Reset (the car's damage cleared) takes the - // ejected part out of the world again (dgHitBangerInstance::Detach). - r.setEjectedPartReset([this](std::size_t i) { - if (m_bangers) - m_bangers->detachHit(i); - }); - r.detach(b.part, m_bangers->ejectPart(*data, vehicle, b.part, r.paintjob(), - Mat34::translation(b.pivot) * body, speed, sim.body.room)); + if (recorder) + recorder->part(m_netStateTime, b.part); return true; }; for (const auto& impact : impacts) @@ -3254,6 +3567,26 @@ class RaceScreen final : public Screen { eject(b, sim.speed() * 1.3f); } + // vehBreakableMgr::Eject: part `b` of a car (its model matrix `body`) + // 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 auto* data = m_bangers && m_bangerData ? m_bangerData->find(vehicle + "_" + str::lower(b.part)) + : nullptr; + if (!data) + return false; + // vehBreakableMgr::Reset (the car's damage cleared) takes the + // ejected part out of the world again (dgHitBangerInstance::Detach). + r.setEjectedPartReset([this](std::size_t i) { + if (m_bangers) + m_bangers->detachHit(i); + }); + r.detach(b.part, m_bangers->ejectPart(*data, vehicle, b.part, r.paintjob(), + Mat34::translation(b.pivot) * body, speed, room)); + return true; + } + // vehCar::UpdateTrack lays no tracks in the rooms gizBridge::Init flags // (level room flag 0x10: at the opening bridges). game::fx::VehicleFxContext vehicleFxContext(const phys::CarSim& sim) const { @@ -3265,16 +3598,21 @@ class RaceScreen final : public Screen { void updateEffects(float dt) { // vehCarDamage::Update paints the first impact since the last frame // 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). auto paint = [this](game::fx::VehicleEffects& fx, game::VehicleRenderer& r, const phys::CarSim& sim, - const std::string& vehicle) { - if (auto p = fx.takeDamagePoint()) - r.applyDamage(*p, sim.damage.params.textelDamageRadius); - breakParts(fx, r, sim, vehicle); + const std::string& vehicle, game::DamageRecorder* recorder = nullptr) { + 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); }; if (m_player && m_vehicleFx) { m_vehicleFx->update(dt, m_player->sim(), vehicleFxContext(m_player->sim())); if (m_vehicle) - paint(*m_vehicleFx, *m_vehicle, m_player->sim(), m_player->model().baseName); + paint(*m_vehicleFx, *m_vehicle, m_player->sim(), m_player->model().baseName, + m_result.config.multiplayer ? &m_netDamage.own() : nullptr); } for (auto& o : m_opponents) if (o.fx) { @@ -3284,11 +3622,12 @@ 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); + paint(*c.fx, *c.renderer, c.sim->sim(), c.sim->model().baseName, c.damage); } if (c.driver->siren()) c.sirenAngle = std::fmod(c.sirenAngle + dt * 2.5f * 3.1415927f, 6.2831855f); } + updateNetFx(dt); // OpenMM2: the network cars' damage smoke if (m_weather) m_weather->update(dt, m_camera.transform); } @@ -3353,6 +3692,8 @@ class RaceScreen final : public Screen { m_impacts.push_back({impact.soundStrength, impact.audioId, impact.position}); if (m_vehicleFx) m_vehicleFx->impact(impact, m_player->sim()); + if (m_result.config.multiplayer && impact.damaging) // OpenMM2: the others see it too + m_netDamage.own().impact(m_netStateTime, game::damageImpactOf(impact, m_player->sim())); if (impact.damaging) { ++(impact.otherIsBody ? m_vehicleImpacts : m_objectImpacts); m_ff.impact(impact.total, m_player->sim().speedMph()); // mmPlayer::FFImpactCallback @@ -3479,7 +3820,7 @@ class RaceScreen final : public Screen { } else { m_trafficClient.emplace(m_trafficCatalog.size(), m_trafficCatalog.checksum()); if (m_aiRenderer) - m_aiRenderer->setCars(&m_netCars); + m_aiRenderer->setCars(&m_netCarsDrawn); if (m_cityLevel) { m_trafficProxies = std::make_unique(m_cityLevel.get()); m_cityLevel->addSource(m_trafficProxies.get()); @@ -3599,6 +3940,9 @@ class RaceScreen final : public Screen { body.emplace(); body->transform = *m; m_trafficBodies->motionOf(c.id, 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)); @@ -3664,11 +4008,24 @@ class RaceScreen final : public Screen { st.bytes += bytes; st.messages += bytes > 0 ? 1 : 0; st.cars += msg.entities.size(); + // What protocol 4 added: a knocked car's wheel bit and wheels, + // a police car's 4 more bits of damage. + if (bytes > 0) + for (const auto& e : msg.entities) { + if (!e.hasState) + continue; + if (e.kind == net::AmbientKind::Police) + st.damageBits += 4; + else if (e.flags & net::kAmbientOffRail) + st.damageBits += e.wheels ? 81 : 1; + st.wheels += e.wheels ? 1 : 0; + } if (m_debugNetTraffic) // every message: the clients' logs are compared with these for (const auto& e : msg.entities) if (e.position.dist(rc.transform.m3) < 120.0f) - log::info("nettraffic: host t {} to {} id {} gen {} at {:.2f} {:.2f} {:.2f}", time, - rc.id, e.id, e.generation, e.position.x, e.position.y, e.position.z); + log::info("nettraffic: host t {} to {} id {} gen {} at {:.2f} {:.2f} {:.2f}{}", time, + rc.id, e.id, e.generation, e.position.x, e.position.y, e.position.z, + e.wheels ? " wheels" : ""); } for (auto it = m_trafficSent.begin(); it != m_trafficSent.end();) { if (viewers.contains(it->first)) { @@ -3686,9 +4043,11 @@ class RaceScreen final : public Screen { for (auto& [id, st] : m_trafficSent) { const double perMessage = st.messages ? static_cast(st.cars) / static_cast(st.messages) : 0.0; - log::info("nettraffic: to player {}: {:.1f} msg/s, {:.0f} B/s, {:.1f} cars a message", id, - static_cast(st.messages) / seconds, static_cast(st.bytes) / seconds, - perMessage); + 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)", + id, static_cast(st.messages) / seconds, + static_cast(st.bytes) / seconds, perMessage, + static_cast(st.damageBits) / 8.0 / seconds, st.wheels); st = {}; } m_trafficStatsAt = now; @@ -3750,10 +4109,13 @@ class RaceScreen final : public Screen { ctx.netGame->playoutDelay(net::kHostPlayerId)); const double renderTime = ctx.netGame->frameTime() - delay; m_trafficClient->update(renderTime); + m_trafficRenderTime = renderTime; 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 for (const auto& c : m_trafficClient->cars()) { const std::string* model = m_trafficCatalog.name(c.model); if (c.kind != net::AmbientKind::Traffic || !model) @@ -3768,10 +4130,30 @@ class RaceScreen final : public Screen { m_netCars.push_back(game::ambientCarOf(c, *model, netVehicleData(*model), m_aiRenderer ? m_aiRenderer->paintJobs(*model) : 1, turn)); seen.insert(c.id); + if (c.wheels && m_netCars.back().data) + wheeled.push_back({m_netCars.size() - 1, c.wheelOffsets}); } 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; + m_netCarsDrawn = m_netCars; + for (ai::AmbientCar& c : m_netCarsDrawn) + if (const auto m = m_trafficClient->transformAt(c.id, m_netDrawTime)) + c.transform = *m; + // A knocked car with a body on the host: drawn on the wheels its + // vehWheelCheaps put there (aiVehicleInstance::Draw), carried by the + // car as drawn. + for (const auto& [index, offsets] : wheeled) { + const ai::AmbientCar& c = m_netCarsDrawn[index]; + game::AiRenderer::PhysicalCar p; + p.transform = c.transform; + p.active = true; + game::trafficWheelMatrices(c.transform, offsets, *c.data, p.wheels, p.wheelValid); + m_netPhysical[c.id] = p; + } // The pedestrians' accidents: the components of the cars off their rails. m_netAccidentNodes.clear(); m_netAccidentPaths.clear(); @@ -3796,9 +4178,9 @@ class RaceScreen final : public Screen { if (c.transform.m3.dist(me) < 120.0f) log::info( "nettraffic: client t {:.0f} id {} gen {} at {:.2f} {:.2f} {:.2f} flags {:#x}" - "{}{}", + "{}{}{}", renderTime, c.id, c.generation, c.transform.m3.x, c.transform.m3.y, c.transform.m3.z, - c.flags, c.extrapolated ? " (extrapolated)" : "", + c.flags, c.wheels ? " wheels" : "", c.extrapolated ? " (extrapolated)" : "", c.kind == net::AmbientKind::Police ? std::format(" police target {}", c.target) : std::string()); log::info("nettraffic: client cops pursuing this player: {}", @@ -3811,9 +4193,9 @@ class RaceScreen final : public Screen { if (now - m_trafficStatsAt >= 10000) { const auto& st = m_trafficClient->stats(); log::info("nettraffic: received {} messages ({} cars), {} refused, {} late, {} moved; showing {} " - "cars, {} police", + "cars ({} on their bodies' wheels), {} police", st.messages, st.entities, st.refused, st.outdated, st.teleports, m_netCars.size(), - m_netCops.size()); + m_netPhysical.size(), m_netCops.size()); m_trafficStatsAt = now; } } @@ -3839,6 +4221,10 @@ class RaceScreen final : public Screen { game::TrafficClient::Car state; std::array spin{}; float sirenAngle = 0.0f; + // Its damage (as the network players' cars, RemoteVehicle). + std::unique_ptr fx; + std::vector impacts; + bool fresh = true; }; // A police car's parts out of the world, kept for another one of its @@ -3850,6 +4236,8 @@ class RaceScreen final : public Screen { cop.audio->stop(); if (cop.sim) { const std::string model = cop.model; + cop.fresh = true; // another car's damage when it is used again + cop.impacts.clear(); m_spareNetCops.emplace(model, std::move(cop)); } cop = {}; @@ -3890,6 +4278,7 @@ class RaceScreen final : public Screen { setupVehicleRenderer(ctx, *cop.renderer); cop.paint = c.paint; cop.audio = loadAiCarAudio(ctx, *model, true); + cop.fx = loadVehicleFx(ctx, *model, cop.sim->model(), *cop.renderer); } if (!cop.sim) continue; @@ -3901,6 +4290,7 @@ class RaceScreen final : public Screen { if (cop.paint != c.paint) { cop.renderer->setPaintjob(c.paint); cop.paint = c.paint; + cop.fresh = true; } if (cop.generation != c.generation) { // A new car in the slot (or the host's cop put back): it @@ -3909,8 +4299,11 @@ class RaceScreen final : public Screen { cop.sim->reset(c.transform); if (cop.audio) cop.audio->reset(); + if (cop.fx) + cop.fx->reset(); cop.spin = {}; cop.sirenAngle = 0.0f; + cop.fresh = true; // its damage drawn from its record again } auto& sim = cop.sim->sim(); Mat34 ics = c.transform; @@ -3923,6 +4316,10 @@ class RaceScreen final : public Screen { sim.body.kinematicSpin = c.angularVelocity; sim.body.declare(2, 0x1b); cop.state = c; + // OpenMM2: its damage as the host shows it. + updateNetCarDamage(ctx, game::DamageReplica::ambientKey(static_cast(c.id)), + *cop.renderer, cop.fx.get(), sim, cop.model, netCopDrawn(c.id).value_or(c.transform), + c.velocity, c.damage, m_trafficRenderTime, cop.fresh, cop.impacts); // vehSiren::Update: the beams turn 2.5 pi a second while on. if (c.flags & net::kAmbientSiren) cop.sirenAngle = std::fmod(cop.sirenAngle + dt * 2.5f * 3.1415927f, 6.2831855f); @@ -3944,6 +4341,8 @@ class RaceScreen final : public Screen { 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)); @@ -3981,6 +4380,7 @@ class RaceScreen final : public Screen { in.siren = (c.state.flags & net::kAmbientSiren) != 0; in.sirenPursuingPlayer = c.state.target == ctx.netGame->localId(); in.wrecked = (c.state.flags & net::kAmbientWrecked) != 0; + in.impacts = std::exchange(c.impacts, {}); // the host's, replayed c.audio->update(in, dt, m_camera.transform); } } @@ -3994,7 +4394,8 @@ class RaceScreen final : public Screen { controls.handbrake = m_lastPedals.handbrake; controls.gear = static_cast(sim.trans.getCurrentGear()); std::uint8_t flags = 0; - if (m_pose.headlights) + // The simulated pose carries no lights (the drawn poses get them). + if (carLights()) flags |= net::kVehicleHeadlights; if (m_pose.brakeLights) flags |= net::kVehicleBrakeLights; @@ -4031,6 +4432,17 @@ class RaceScreen final : public Screen { if (!multiplayer(ctx) || !m_world) return; m_remoteCars = ctx.netGame->remoteCars(static_cast(m_world->remainderAfter(dt)) * 1000.0); + // 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; + 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)) + if (rc.hasState) + m_remoteDrawn[rc.id] = rc.transform; // A player who quit the race takes its car out of it. std::erase_if(m_remoteCars, [this](const game::NetRemoteCar& c) { return m_netLeft.contains(c.id); }); const auto mode = m_result.config.mode; @@ -4079,6 +4491,9 @@ class RaceScreen final : public Screen { // 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.sim) continue; @@ -4107,6 +4522,9 @@ class RaceScreen final : public Screen { if (jumped) trailer->reset(); // respawned: hitched again behind it } + 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); } for (auto it = m_remotes.begin(); it != m_remotes.end();) { if (std::find(present.begin(), present.end(), it->first) == present.end()) { @@ -4114,6 +4532,7 @@ class RaceScreen final : public Screen { it->second.sim->removeFrom(*m_world); if (it->second.audio) it->second.audio->stop(); + m_netDamage.replica().forget(game::DamageReplica::playerKey(it->first)); it = m_remotes.erase(it); } else { ++it; @@ -4134,17 +4553,168 @@ class RaceScreen final : public Screen { // the ground under the placed body (suspension, roll, steering // from the snapshot's pedals), so they sit on the road instead of // hanging at their rest positions. - game::VehiclePose pose = rv.sim->pose(); - pose.body = rc.transform; + // OpenMM2: the body at its sample a physics step back, the + // simulated wheels and trailer between their last two samples. + const auto drawn = m_remoteDrawn.find(rc.id); + game::VehiclePose pose = + game::placePose(drawnPose(game::Drawn::RemoteCar, rc.id, *rv.sim), + drawn != m_remoteDrawn.end() ? drawn->second : rc.transform); pose.headlights = (rc.flags & net::kVehicleHeadlights) != 0; pose.brakeLights = (rc.flags & net::kVehicleBrakeLights) != 0; pose.reverseLights = rc.controls.gear < 0; rv.renderer->draw(pose, camera.transform); if (rv.trailer) - rv.trailer->draw(rv.sim->trailerPose(), camera.transform); + rv.trailer->draw(m_drawnPhys.pose(game::drawnKey(game::Drawn::RemoteTrailer, rc.id), + rv.sim->trailerPose(), rv.sim->sim().resets), + camera.transform); } } + // --- OpenMM2: the network cars' damage (game/net/DamageSync) ---------------------------- + + // vehCar::ClearDamage on the player's car's model; in a network race the + // other machines clear their copy of it too. + void clearVehicleDamage() { + if (m_vehicle) + m_vehicle->resetDamage(); + if (m_result.config.multiplayer) + m_netDamage.own().reset(m_netStateTime); + } + + // A car drawn from the network (another player's; the host's police car + // on a shared-traffic client) at session time `sampleTime`: its damage + // level as CurrentDamage (vehCarDamage::Update's smoke and the tyre + // wobble follow it, mmNetObject::PositionUpdate), and its owner's + // patches, parts and impacts as they become due (its whole record first + // when `fresh`). The car takes no damage of its own here (its vehCarDamage + // is off; MM2's simulated network car took its own collisions too). + void updateNetCarDamage(Context& ctx, std::uint32_t key, game::VehicleRenderer& renderer, + game::fx::VehicleEffects* fx, phys::CarSim& sim, const std::string& vehicle, + const Mat34& body, const Vec3& velocity, float level, double sampleTime, + bool& fresh, std::vector& sounds) { + sim.damage.enabled = false; + sim.damage.currentDamage = game::damageFromFraction(sim.damage.params, level); + game::DamageTarget t; + t.renderer = &renderer; + t.effects = fx; + t.body = body; + 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); + }; + t.sound = [&](const Vec3& position, float strength, int audioId) { + if (sounds.size() < 16) // taken by the car's audio each frame + sounds.push_back({strength, audioId, position}); + }; + m_netDamage.update(key, t, sampleTime, ctx.netGame->frameTime(), fresh); + fresh = false; + } + + // The network cars' vehCarDamage::Update (smoke, fire, exhaust) and the + // replayed impacts' sparks and shards; their wheels are not simulated + // (no tracks or wheel particles). + void updateNetFx(float dt) { + game::fx::VehicleFxContext context; + context.wheels = false; + const std::uint64_t now = net::monotonicMs(); + const bool log = m_debugNetDamage && now - m_netFxLoggedAt >= 2000; + if (log && m_player) { + m_netFxLoggedAt = now; + const auto& d = m_player->sim().damage; + log::info("netdamage: own car damage {:.0f} of {:.0f} (med {:.0f}), level {:.3f}", d.currentDamage, + d.maxDamage(), d.medDamage(), d.damage); + for (std::size_t i = 0; i < m_cops.size(); ++i) { + const auto& c = m_cops[i].sim->sim().damage; + const bool out = m_cops[i].driver->mode() == ai::PoliceCar::Mode::Disabled; + if (c.currentDamage > 0.0f) + log::info("netdamage: own police {} damage {:.0f} of {:.0f} (med {:.0f}){}", + kNetPoliceId + static_cast(i), c.currentDamage, c.maxDamage(), + c.medDamage(), out ? ", out of action" : ""); + } + } + // The smoke comes from the car as drawn, a step behind its body. + auto update = [&](const char* what, int id, game::fx::VehicleEffects& fx, const game::SimVehicle& car, + const std::optional& drawn) { + context.body.reset(); + if (drawn) + context.body = drawnIcs(*drawn, car); + const phys::CarSim& sim = car.sim(); + fx.update(dt, sim, context); + if (log) + log::info("netdamage: {} {} damage {:.0f} of {:.0f} (med {:.0f}), smoke {}, sparks {}", what, id, + sim.damage.currentDamage, sim.damage.maxDamage(), sim.damage.medDamage(), + fx.smoke().count(), fx.sparks().count()); + }; + for (auto& [id, rv] : m_remotes) + if (rv.fx && rv.sim) + update("player", id, *rv.fx, *rv.sim, netCarDrawn(id)); + for (auto& [id, cop] : m_netCops) + if (cop.fx && cop.sim) + update("police", id, *cop.fx, *cop.sim, netCopDrawn(id)); + } + + // Where a network player's car and a shared police car are drawn + // (model matrices), when known. + 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 { + return m_trafficClient ? m_trafficClient->transformAt(id, m_netDrawTime) : std::nullopt; + } + std::uint64_t m_netFxLoggedAt = 0; + + void drawNetFx(render::Device& dev, const game::Camera& camera) { + for (auto& [id, rv] : m_remotes) + if (rv.fx) + rv.fx->draw(dev, *m_textures, m_cards, m_skids, camera.transform); + for (auto& [id, cop] : m_netCops) + if (cop.fx) + cop.fx->draw(dev, *m_textures, m_cards, m_skids, camera.transform); + } + + // Development aid: OPENMM2_DEBUG_RESPAWN_MS=[,...] puts a + // network player's car back at its reset position at those session + // times ("+": from the race's order), as the water does in a cruise + // (mmPlayer::Reset: its damage cleared on every machine). + void debugRespawn(Context& ctx) { + static const char* times = std::getenv("OPENMM2_DEBUG_RESPAWN_MS"); + if (!times || !multiplayer(ctx) || !m_player || !ctx.netGame->raceStarted()) + return; + const auto list = str::split(times, ','); + const auto k = static_cast(m_debugRespawns); + if (k >= list.size()) + return; + // "+": from the race's order, as OPENMM2_DEBUG_NET_SHOT_MS. + const bool fromOrder = list[k].starts_with('+'); + const long long at = str::parseInt(fromOrder ? list[k].substr(1) : list[k]).value_or(0) + + (fromOrder ? static_cast(ctx.netGame->raceOrderTime()) : 0LL); + if (static_cast(ctx.netGame->sessionTime()) < at) + return; + ++m_debugRespawns; + log::info("race: debug respawn at session t {}", ctx.netGame->sessionTime()); + m_player->reset(); + if (m_vehicleFx) + m_vehicleFx->reset(); + clearVehicleDamage(); + m_cams.reset(cameraTarget()); + } + int m_debugRespawns = 0; + int m_netShotsTaken = 0; // OPENMM2_DEBUG_NET_SHOT_MS's times passed + + // What this machine's cars painted and broke goes to the others, after + // this frame's effects (this player's car; a shared-traffic host's police). + void sendNetDamage(Context& ctx) { + if (!multiplayer(ctx)) + return; + const std::uint64_t now = net::monotonicMs(); + m_netDamage.send(*ctx.netGame, now); + m_netDamage.logStats(now); + } + + double m_debugInputTime = 0.0; // OPENMM2_DEBUG_INPUT's phases + void updatePlayer(Context& ctx, float dt) { if (!m_player) return; @@ -4161,9 +4731,16 @@ class RaceScreen final : public Screen { pedals.steering = m_gameInput.steering(dt); } // Development aid: constant pedal input "accel,brake,steer,handbrake" - // (the steering through the game pad's filter). + // (the steering through the game pad's filter); several separated by + // '/' take turns, each for OPENMM2_DEBUG_INPUT_MS (2000) of game time. if (const char* dbg = std::getenv("OPENMM2_DEBUG_INPUT")) { - const auto parts = str::split(dbg, ','); + const auto phases = str::split(dbg, '/'); + const char* ms = std::getenv("OPENMM2_DEBUG_INPUT_MS"); + const auto phaseMs = static_cast(std::max(1LL, str::parseInt(ms ? ms : "").value_or(2000))); + if (!multiplayer(ctx) || ctx.netGame->raceStarted()) // a network race's from its start + m_debugInputTime += dt; + const auto phase = static_cast(m_debugInputTime * 1000.0 / phaseMs); + const auto parts = str::split(phases[phase % phases.size()], ','); auto f = [&](std::size_t i) { return i < parts.size() ? static_cast(str::parseDouble(parts[i]).value_or(0.0)) : 0.0f; }; @@ -4201,11 +4778,10 @@ class RaceScreen final : public Screen { if (over) { pedals = {}; m_player->drive(pedals); - } else if ((m_session && m_session->playerHeld()) || - (multiplayer(ctx) && m_result.config.mode != game::GameMode::Cruise && - ctx.netGame->secondsToStart() > 0.0)) { + } else if ((m_session && m_session->playerHeld()) || (multiplayer(ctx) && m_netHeld)) { // (mmMultiRoam says "Go!" and lets the car go two updates after - // its own load, with no shared start.) + // its own load, with no shared start: NetRaceStart never holds + // a cruise.) m_player->hold(pedals); // vehCar::SetDrivable(0, 1) pedals.brake = 1.0f; } else { @@ -4390,8 +4966,9 @@ class RaceScreen final : public Screen { game::CameraTarget cameraTarget() const { const auto& sim = m_player->sim(); game::CameraTarget t; - // camCarCS tracks vehCarSim's world matrix (the model origin). - t.matrix = sim.modelMatrix(); + // camCarCS tracks vehCarSim's world matrix (the model origin); OpenMM2 + // follows it as drawn, between the last two physics samples. + t.matrix = drawnPose(game::Drawn::Player, 0, *m_player).body; t.angularMomentum = sim.body.ics.angularMomentum; // camTrackCS::UpdateCar's spin test t.speed = sim.speed(); // vehCarSim: |velocity . Z| t.steering = sim.steering; @@ -4464,7 +5041,7 @@ class RaceScreen final : public Screen { const auto parts = str::split(orbit, ','); if (parts.size() == 3) { auto f = [&](int i) { return static_cast(str::parseDouble(parts[i]).value_or(0.0)); }; - const Mat34 car = m_pose.body; + const Mat34 car = m_drawPose.body; const Vec3 eye = car.m3 + Mat34::rotationY(f(2)).transformDir({0, f(1), f(0)}); m_camera.transform = game::Camera::lookAt(eye, car.m3 + Vec3{0, 1.0f, 0}); } @@ -4581,11 +5158,18 @@ class RaceScreen final : public Screen { game::Camera m_camera; std::unique_ptr m_cityLevel; std::unique_ptr m_world; + int m_frameSteps = 0; // the physics samples this frame ran + std::unique_ptr m_drawTrace = DrawTrace::fromEnvironment(); // OPENMM2_DEBUG_DRAW_TRACE std::unique_ptr m_player; std::unique_ptr m_vehicle; std::unique_ptr m_trailer; game::VehiclePose m_pose; game::VehiclePose m_trailerPose; + // OpenMM2 presentation (game/Interpolation.h): the physics' and the AI's + // objects as their last steps began, and the player's car and trailer as + // drawn this frame. + game::StepHistory m_drawnPhys, m_drawnAi; + game::VehiclePose m_drawPose, m_drawTrailerPose; bool m_flyCamera = std::getenv("OPENMM2_DEBUG_FLY") != nullptr; bool m_showDebugOnly = std::getenv("OPENMM2_DEBUG_NOHUD") != nullptr; bool m_showDebug = std::getenv("OPENMM2_DEBUG_HUD") != nullptr; @@ -4680,6 +5264,8 @@ class RaceScreen final : public Screen { std::shared_ptr> impacts = std::make_shared>(); 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; }; std::unique_ptr m_police; std::vector m_cops; @@ -4717,9 +5303,22 @@ class RaceScreen final : public Screen { std::unique_ptr sim; // kinematic body (and its trailer) std::unique_ptr renderer, trailer; std::unique_ptr audio; + // OpenMM2: its damage as its owner's machine shows it (smoke from + // the snapshots' level, sparks and shards of the replayed impacts). + std::unique_ptr fx; + std::vector impacts; // replayed impact sounds this frame + bool fresh = true; // its damage record not shown yet }; std::map m_remotes; + // OpenMM2: the network cars' damage (game/net/DamageSync): this player's + // car's and, on a shared-traffic host, the police's, sent; the other + // players' and the host's police, received. m_netStateTime is the + // session time the simulation's state belongs to this frame. + game::NetDamage m_netDamage; + std::uint32_t m_netStateTime = 0; std::vector m_remoteCars; // this frame's sample (updateRemoteCars) + std::map m_remoteDrawn; // and where they are drawn + std::map m_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 @@ -4727,6 +5326,9 @@ class RaceScreen final : public Screen { std::map m_netPlayers; // the players last frame (who left) bool m_netPlayersKnown = false; std::uint32_t m_netRace = 0; // NetGame::raceNumber() of this race + 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) bool multiplayer(Context& ctx) const { return m_result.config.multiplayer && ctx.netGame; } std::unique_ptr m_aiRenderer; std::unique_ptr m_trafficBodies; @@ -4741,9 +5343,12 @@ class RaceScreen final : public Screen { std::uint64_t m_trafficSentAt = 0, m_trafficStatsAt = 0, m_trafficLoggedAt = 0; 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::map m_trafficSent; // host: per client, since the last log 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; std::unordered_map m_netTireRotation; // client: their wheels' turn std::unique_ptr m_trafficProxies; // client: their physics std::unordered_set m_netAccidentNodes, m_netAccidentPaths; // client: off-rail cars' components @@ -4751,6 +5356,8 @@ class RaceScreen final : public Screen { 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 std::multimap m_spareNetCops; // client: police cars no longer shown, by model std::set m_badNetCopModels; // client: police models that failed to load static constexpr std::size_t kMaxNetCops = 32; @@ -4758,6 +5365,8 @@ class RaceScreen final : public Screen { // Development aid: OPENMM2_DEBUG_NETTRAFFIC logs the shared cars near // each client twice a second, on the host and on the client. bool m_debugNetTraffic = std::getenv("OPENMM2_DEBUG_NETTRAFFIC") != nullptr; + // OPENMM2_DEBUG_NETDAMAGE logs every damage event sent and received. + bool m_debugNetDamage = std::getenv("OPENMM2_DEBUG_NETDAMAGE") != nullptr; std::unordered_set m_debugKnocked; // Sound: the player's car, city ambience and rain (src/audio/game). diff --git a/src/game/AiRenderer.cpp b/src/game/AiRenderer.cpp index f5c9626..42fe8e5 100644 --- a/src/game/AiRenderer.cpp +++ b/src/game/AiRenderer.cpp @@ -60,7 +60,8 @@ const asset::PedType* AiRenderer::pedType(const std::string& name) { return it->second ? &*it->second : nullptr; } -void AiRenderer::drawPed(const ai::Pedestrian& ped, const asset::PedType& type, const Camera& camera) { +void AiRenderer::drawPed(const ai::Pedestrian& ped, const Mat34& transform, const asset::PedType& type, + const Camera& camera) { const asset::PedAnimation* anim = type.animation(ped.animFile); if (!anim) anim = type.animation(ped.state); @@ -71,15 +72,15 @@ void AiRenderer::drawPed(const ai::Pedestrian& ped, const asset::PedType& type, asset::posePed(type.skeleton, anim, ped.frame, m_bones); // aiPedestrianInstance::Draw: the posed model within 35 m of the camera, // the stick figure (pedAnimation::DrawSkeleton) beyond. - if (ped.transform.m3.dist2(camera.position()) >= 1225.0f) { - drawSkeleton(ped, type, camera); + if (transform.m3.dist2(camera.position()) >= 1225.0f) { + drawSkeleton(ped, transform, type, camera); return; } const auto& mesh = type.mesh; m_skinned.resize(mesh.vertices.size()); for (std::size_t i = 0; i < mesh.vertices.size(); ++i) { const auto& v = mesh.vertices[i]; - const Mat34 bone = v.bone < m_bones.size() ? m_bones[v.bone] * ped.transform : ped.transform; + const Mat34 bone = v.bone < m_bones.size() ? m_bones[v.bone] * transform : transform; const Vec3 p = bone.transform(v.position); const Vec3 n = bone.transformDir(v.normal).normalized(); auto& out = m_skinned[i]; @@ -119,7 +120,8 @@ void AiRenderer::drawPed(const ai::Pedestrian& ped, const asset::PedType& type, } } -void AiRenderer::drawSkeleton(const ai::Pedestrian& ped, const asset::PedType& type, const Camera& camera) { +void AiRenderer::drawSkeleton(const ai::Pedestrian& ped, const Mat34& transform, const asset::PedType& type, + const Camera& camera) { // pedAnimation::DrawSkeleton: for each bone with a width in the .rays // file, its position raised by the bone's offset (which its children then // use), a quad to its parent's position across skeletonWidthAxis (the @@ -133,7 +135,7 @@ void AiRenderer::drawSkeleton(const ai::Pedestrian& ped, const asset::PedType& t ped.variant >= 0 && static_cast(ped.variant) < rays.variants.size() ? &rays.variants[static_cast(ped.variant)] : nullptr; - const Vec3 right = skeletonWidthAxis(ped.transform, camera.transform); + const Vec3 right = skeletonWidthAxis(transform, camera.transform); std::vector vertices; std::vector indices; for (std::size_t j = 0; j < n; ++j) { @@ -154,8 +156,8 @@ void AiRenderer::drawSkeleton(const ai::Pedestrian& ped, const asset::PedType& t argb = 0xFF000000u | byte(shader->diffuse.x) << 16 | byte(shader->diffuse.y) << 8 | byte(shader->diffuse.z); } - const Vec3 a = ped.transform.transform(m_bones[j].m3); - const Vec3 b = ped.transform.transform(m_bones[parent].m3); + const Vec3 a = transform.transform(m_bones[j].m3); + const Vec3 b = transform.transform(m_bones[parent].m3); const Vec3 da = right * w0, db = right * w1; const auto base = static_cast(vertices.size()); for (const Vec3& p : {a - da, a + da, b - db, b + db}) { @@ -247,9 +249,16 @@ void AiRenderer::draw(const ai::World& world, const Camera& camera, const Frustu const Vec3 eye = camera.position(); const int blinkClock = world.blinkClock(); const bool rooms = m_rooms && m_rooms->active(); + // The world's own cars on their rails between its last two steps (a + // shared-traffic client's cars come already placed). + const StepHistory* history = m_carsOverride ? nullptr : m_history; for (const auto& car : m_carsOverride ? *m_carsOverride : world.cars()) { const std::optional physical = physicalCar ? physicalCar(car.id) : std::nullopt; - const Mat34& transform = physical ? physical->transform : car.transform; + const std::uint64_t key = drawnKey(Drawn::RailCar, static_cast(car.id)); + const auto generation = static_cast(car.spawns); + const Mat34 transform = physical ? physical->transform + : history ? history->transform(key, car.transform, generation) + : car.transform; // cityLevel::DrawRooms: from the car's room; the renderer's // lvlInstance::IsVisible ends the car itself at NoDraw. RoomVisibility::Passes passes; @@ -281,8 +290,12 @@ void AiRenderer::draw(const ai::World& world, const Camera& camera, const Frustu // aiVehicleInstance::Draw: the rail's tyre rotation about each axle, // no steering; with a body, the wheels where its vehWheelCheaps put // them. + const float tireRotation = + history && !physical ? history->turn(key, car.transform, car.tireRotation, ai::kTireRotationWrap, + generation) + : car.tireRotation; for (std::size_t i = 0; i < 6; ++i) - pose.wheelSpin[i] = -car.tireRotation; // rolling forward (-Z) spins about -X + pose.wheelSpin[i] = -tireRotation; // rolling forward (-Z) spins about -X if (physical && physical->active) { pose.physical = true; pose.hasWheelWorld = true; @@ -304,13 +317,18 @@ void AiRenderer::draw(const ai::World& world, const Camera& camera, const Frustu // Pedestrians are dynamic objects too: nothing beyond NoDraw (inferred: // MM2 draws them through lvlInstance::IsVisible like the cars). for (const auto& ped : world.peds()) { - if (rooms && !roomPasses(m_pedRooms, ped.id, ped.transform.m3).objects) + // Between the AI's last two steps; one put on a road in the last + // step (aiPedestrian::Reset) is drawn where it is. + const Mat34 transform = + m_history ? m_history->transform(drawnKey(Drawn::Pedestrian, static_cast(ped.id)), + ped.transform, ped.placed ? 1u : 0u) + : ped.transform; + if (rooms && !roomPasses(m_pedRooms, ped.id, transform.m3).objects) continue; - if (ped.transform.m3.dist2(eye) > sq(detail.noDraw) || - !frustum.intersectsSphere(ped.transform.m3, 2.0f)) + if (transform.m3.dist2(eye) > sq(detail.noDraw) || !frustum.intersectsSphere(transform.m3, 2.0f)) continue; if (const asset::PedType* type = pedType(ped.typeName)) { - drawPed(ped, *type, camera); + drawPed(ped, transform, *type, camera); ++m_stats.peds; } } diff --git a/src/game/AiRenderer.h b/src/game/AiRenderer.h index 3a20107..e646220 100644 --- a/src/game/AiRenderer.h +++ b/src/game/AiRenderer.h @@ -4,6 +4,7 @@ #include "asset/Ped.h" #include "asset/VehicleModel.h" #include "game/Camera.h" +#include "game/Interpolation.h" #include "game/RaceConfig.h" #include "game/ModelLibrary.h" #include "game/RoomVisibility.h" @@ -68,6 +69,10 @@ class AiRenderer { // OpenMM2 extra (a network client of the shared cruise traffic): draws // these cars instead of the world's traffic; null draws the world's. void setCars(const std::vector* cars) { m_carsOverride = cars; } + // OpenMM2 presentation: the world's cars on their rails and its + // pedestrians are drawn between its last two steps (the history its step + // observer keeps, recordAiStep); null draws them where they are. + void setInterpolation(const StepHistory* history) { m_history = history; } // The paint jobs of a traffic model (its package's), 1 without any. int paintJobs(const std::string& model); @@ -86,8 +91,11 @@ class AiRenderer { }; CarModel* carModel(const std::string& name); const asset::PedType* pedType(const std::string& name); - void drawPed(const ai::Pedestrian& ped, const asset::PedType& type, const Camera& camera); - void drawSkeleton(const ai::Pedestrian& ped, const asset::PedType& type, const Camera& camera); + // `transform`: where the pedestrian is drawn. + void drawPed(const ai::Pedestrian& ped, const Mat34& transform, const asset::PedType& type, + const Camera& camera); + void drawSkeleton(const ai::Pedestrian& ped, const Mat34& transform, const asset::PedType& type, + const Camera& camera); void drawSignal(const ai::Signal& signal, const Mat34& frame, const Camera& camera, bool nightGlows, const RoomVisibility::Passes* passes); // lvlLevel::MoveToRoom's room of an object, found from its last one @@ -112,6 +120,7 @@ class AiRenderer { // and the signals (aiTrafficLightSet::SetFourWay), by id or index. std::unordered_map m_carRooms, m_pedRooms, m_signalRooms; const std::vector* m_carsOverride = nullptr; + const StepHistory* m_history = nullptr; }; // aiTrafficLightInstance::DrawGlow's matrix for the glow and WALK meshes, diff --git a/src/game/Interpolation.cpp b/src/game/Interpolation.cpp new file mode 100644 index 0000000..aebf493 --- /dev/null +++ b/src/game/Interpolation.cpp @@ -0,0 +1,245 @@ +#include "game/Interpolation.h" + +#include "ai/World.h" +#include "game/TrafficBodies.h" +#include "game/bangers/BangerSet.h" +#include "phys/AgeMath.h" +#include "phys/World.h" + +#include +#include + +namespace mm2::game { + +namespace { + +float determinant(const Mat34& m) { return m.m0.dot(m.m1.cross(m.m2)); } + +// The rotation of a placement whose rows have lengths `scale`. +Mat34 unscaled(const Mat34& m, const Vec3& scale) { + Mat34 r; + r.m0 = m.m0 * (1.0f / scale.x); + r.m1 = m.m1 * (1.0f / scale.y); + r.m2 = m.m2 * (1.0f / scale.z); + return r; +} + +// vehWheel::Update spins a wheel's matrix about its own axle (its first +// row) by the accumulated turn (Matrix34::Rotate): undone and redone the +// same way. +Mat34 spun(Mat34 m, float turn) { + const Vec3 axle = m.m0; + phys::age::rotate(m, axle, turn); + return m; +} + +} // namespace + +Mat34 blendTransform(const Mat34& from, const Mat34& to, float t) { + if (!(t > 0.0f)) + return from; + if (t >= 1.0f) + return to; + const Vec3 sa{from.m0.mag(), from.m1.mag(), from.m2.mag()}; + const Vec3 sb{to.m0.mag(), to.m1.mag(), to.m2.mag()}; + const Vec3 position = lerp(from.m3, to.m3, t); + // A degenerate or mirrored basis has no rotation to slerp: the nearer + // of the two, moved. + const bool degenerate = !(sa.x > 0.0f && sa.y > 0.0f && sa.z > 0.0f && sb.x > 0.0f && sb.y > 0.0f && + sb.z > 0.0f) || + !(determinant(from) > 0.0f && determinant(to) > 0.0f); + if (degenerate) { + Mat34 r = t < 0.5f ? from : to; + r.m3 = position; + return r; + } + const Quat q = Quat::slerp(Quat::fromMatrix(unscaled(from, sa)), Quat::fromMatrix(unscaled(to, sb)), t); + Mat34 r = q.toMatrix(position); + r.m0 = r.m0 * lerp(sa.x, sb.x, t); + r.m1 = r.m1 * lerp(sa.y, sb.y, t); + r.m2 = r.m2 * lerp(sa.z, sb.z, t); + return r; +} + +float blendAngle(float from, float to, float t, float period) { + if (!(t > 0.0f)) + return from; + if (t >= 1.0f) + return to; + float d = std::fmod(to - from, period); + if (d > period * 0.5f) + d -= period; + else if (d < -period * 0.5f) + d += period; + return from + d * t; +} + +bool isJump(const Mat34& from, const Mat34& to, const JumpLimits& limits) { + if (!(from.m3.dist2(to.m3) <= limits.distance * limits.distance)) + return true; + // The turn from one to the other: cos = (trace(Ra Rb^T) - 1) / 2. + float trace = 0.0f; + for (int i = 0; i < 3; ++i) { + const float la = from.row(i).mag(), lb = to.row(i).mag(); + if (!(la > 0.0f && lb > 0.0f)) + return true; + trace += from.row(i).dot(to.row(i)) / (la * lb); + } + return !((trace - 1.0f) * 0.5f >= std::cos(limits.angle)); +} + +VehiclePose blendPose(const VehiclePose& from, const VehiclePose& to, float t) { + if (t >= 1.0f) + return to; + VehiclePose out = to; + const float u = std::max(t, 0.0f); + out.body = blendTransform(from.body, to.body, u); + constexpr float kTwoPi = 6.2831855f; + for (std::size_t i = 0; i < out.wheelSpin.size(); ++i) { + out.wheelSpin[i] = blendAngle(from.wheelSpin[i], to.wheelSpin[i], u, kTwoPi); + out.wheelSteer[i] = lerp(from.wheelSteer[i], to.wheelSteer[i], u); + out.wheelDrop[i] = lerp(from.wheelDrop[i], to.wheelDrop[i], u); + } + if (!from.hasWheelWorld || !to.hasWheelWorld) + return out; + // The wheels in the body's frame: steering, suspension and spin are + // what move them there. + const bool turns = from.hasWheelTurn && to.hasWheelTurn; + const Mat34 fromInverse = from.body.fastInverse(); + const Mat34 toInverse = to.body.fastInverse(); + for (std::size_t i = 0; i < out.wheelWorld.size(); ++i) { + if (!from.wheelValid[i] || !to.wheelValid[i]) + continue; + if (!turns) { + const Mat34 local = + blendTransform(from.wheelWorld[i] * fromInverse, to.wheelWorld[i] * toInverse, u); + out.wheelWorld[i] = local * out.body; + continue; + } + const Mat34 a = spun(from.wheelWorld[i], -from.wheelTurn[i]) * fromInverse; + const Mat34 b = spun(to.wheelWorld[i], -to.wheelTurn[i]) * toInverse; + out.wheelTurn[i] = lerp(from.wheelTurn[i], to.wheelTurn[i], u); + out.wheelWorld[i] = spun(blendTransform(a, b, u) * out.body, out.wheelTurn[i]); + } + return out; +} + +VehiclePose placePose(const VehiclePose& pose, const Mat34& body) { + VehiclePose out = pose; + out.body = body; + if (pose.hasWheelWorld) { + const Mat34 move = pose.body.fastInverse() * body; + for (std::size_t i = 0; i < out.wheelWorld.size(); ++i) + out.wheelWorld[i] = pose.wheelWorld[i] * move; + } + return out; +} + +// --- StepHistory --------------------------------------------------------------------------- + +void StepHistory::beginStep() { + ++m_step; + // Entries the step before did not record belong to objects that have + // gone or are no longer simulated. + const std::uint64_t last = m_step - 1; + std::erase_if(m_transforms, [last](const auto& e) { return e.second.step < last; }); + std::erase_if(m_poses, [last](const auto& e) { return e.second.step < last; }); +} + +void StepHistory::record(std::uint64_t key, const Mat34& transform, std::uint32_t generation, float turn) { + TransformEntry& e = m_transforms[key]; + e.step = m_step; + e.generation = generation; + e.transform = transform; + e.turn = turn; +} + +void StepHistory::record(std::uint64_t key, const VehiclePose& pose, std::uint32_t generation) { + PoseEntry& e = m_poses[key]; + e.step = m_step; + e.generation = generation; + e.pose = pose; +} + +void StepHistory::clear() { + m_transforms.clear(); + m_poses.clear(); +} + +void StepHistory::setAlpha(float alpha) { m_alpha = std::clamp(alpha, 0.0f, 1.0f); } + +const StepHistory::TransformEntry* StepHistory::previous(std::uint64_t key, const Mat34& live, + std::uint32_t generation) const { + const auto it = m_transforms.find(key); + if (it == m_transforms.end() || it->second.step != m_step || it->second.generation != generation || + isJump(it->second.transform, live, m_limits)) + return nullptr; + return &it->second; +} + +const StepHistory::PoseEntry* StepHistory::previousPose(std::uint64_t key, const Mat34& live, + std::uint32_t generation) const { + const auto it = m_poses.find(key); + if (it == m_poses.end() || it->second.step != m_step || it->second.generation != generation || + isJump(it->second.pose.body, live, m_limits)) + return nullptr; + return &it->second; +} + +Mat34 StepHistory::transform(std::uint64_t key, const Mat34& live, std::uint32_t generation) const { + const TransformEntry* e = previous(key, live, generation); + return e ? blendTransform(e->transform, live, m_alpha) : live; +} + +float StepHistory::turn(std::uint64_t key, const Mat34& live, float liveTurn, float period, + std::uint32_t generation) const { + const TransformEntry* e = previous(key, live, generation); + return e ? blendAngle(e->turn, liveTurn, m_alpha, period) : liveTurn; +} + +VehiclePose StepHistory::pose(std::uint64_t key, const VehiclePose& live, std::uint32_t generation) const { + const PoseEntry* e = previousPose(key, live.body, generation); + return e ? blendPose(e->pose, live, m_alpha) : live; +} + +bool StepHistory::blends(std::uint64_t key, const Mat34& live, std::uint32_t generation) const { + return previous(key, live, generation) || previousPose(key, live, generation); +} + +void recordAiStep(StepHistory& history, const ai::World& world) { + history.beginStep(); + for (const ai::AmbientCar& c : world.cars()) + if (!c.physical) + history.record(drawnKey(Drawn::RailCar, static_cast(c.id)), c.transform, + static_cast(c.spawns), c.tireRotation); + for (const ai::Pedestrian& p : world.peds()) + history.record(drawnKey(Drawn::Pedestrian, static_cast(p.id)), p.transform); +} + +void recordTrafficBodies(StepHistory& history, const ai::World& ai, const TrafficBodies& bodies) { + for (const ai::AmbientCar& c : ai.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) + if (const phys::Body* body = props.body(i); body && world.contains(body)) + history.record(drawnKey(Drawn::Prop, i), instances[i].matrix); +} + +std::optional trafficBodyPose(const TrafficBodies& bodies, int carId) { + const Mat34* m = bodies.transformOf(carId); + const auto wheels = m ? bodies.wheelsOf(carId) : std::nullopt; + if (!wheels) + return std::nullopt; + VehiclePose pose; + pose.body = *m; + pose.wheelWorld = wheels->matrix; + pose.wheelValid = wheels->valid; + pose.hasWheelWorld = true; + return pose; +} + +} // namespace mm2::game diff --git a/src/game/Interpolation.h b/src/game/Interpolation.h new file mode 100644 index 0000000..5c42fac --- /dev/null +++ b/src/game/Interpolation.h @@ -0,0 +1,158 @@ +#pragma once + +// OpenMM2 presentation: what is drawn between two simulation steps. +// +// MM2 ran its whole game once per rendered frame with that frame's measured +// time (datTimeManager::Update sets Seconds to it, clamped; mmGame::Update +// and the AI follow, and dgPhysManager::Update splits the frame into at most +// three samples of at most 1/35 s), so each frame showed the state its own +// update had reached. OpenMM2 instead steps the physics in fixed 1/60 s +// samples (phys::World::advanceFixed) and the AI in fixed 1/30 s steps +// (ai::World::update), so that the simulation is the same at any frame rate +// (docs/physics.md). A frame then runs no step or several, and drawing the +// last step's state made motion judder above 60 fps (and at 60 fps whenever +// a frame ran 0 or 2 samples). +// +// A StepHistory keeps each simulated object's state as it stood when the +// last step began; the drawing blends it towards the object's current state +// by alpha = the time the simulation has not stepped yet / the step. The +// scene is therefore drawn one step behind the frame: up to 16.7 ms more +// latency for what the physics moves, up to 33 ms for the AI's traffic and +// pedestrians. Nothing here feeds back into the simulation. + +#include "core/Math.h" +#include "game/VehicleRenderer.h" + +#include +#include +#include + +namespace mm2::ai { +class World; +} +namespace mm2::phys { +class World; +} +namespace mm2::game { +class TrafficBodies; +namespace bangers { +class BangerSet; +} +} // namespace mm2::game + +namespace mm2::game { + +// Two placements blended: the positions lerped, the orientations slerped +// (the short way), each basis row's length (an instance's scale) lerped. +// t <= 0 gives `from` and t >= 1 `to`, exactly. +Mat34 blendTransform(const Mat34& from, const Mat34& to, float t); +// An angle that wraps every `period` (a tyre's turn): the short way round. +float blendAngle(float from, float to, float t, float period); + +// How far an object can move in one step before the move counts as a jump +// (a respawn, a reset, a recycled slot): drawn where it is, not blended. +struct JumpLimits { + float distance = 5.0f; // m: 300 m/s at 60 Hz, 150 m/s at 30 Hz + float angle = 1.6f; // rad +}; +bool isJump(const Mat34& from, const Mat34& to, const JumpLimits& limits); + +// Two vehicle poses blended: the body as blendTransform, the simulated +// wheels in the body's frame (spun by the difference of their accumulated +// turns when both poses carry them, so a wheel turning more than half a +// revolution a step does not turn backwards), the rest-pose wheels' spin, +// steering and drop. The lights, siren and other flags are `to`'s. +VehiclePose blendPose(const VehiclePose& from, const VehiclePose& to, float t); +// The pose moved, wheels and all, so that its body is at `body`. +VehiclePose placePose(const VehiclePose& pose, const Mat34& body); + +// What a StepHistory entry is (the high bits of its key). +enum class Drawn : std::uint8_t { + Player, + PlayerTrailer, + Opponent, + Police, + RemoteCar, + RemoteTrailer, + TrafficCar, // an ambient car the physics has taken over + Prop, + RailCar, // an ambient car on its rail (the AI) + Pedestrian, +}; +constexpr std::uint64_t drawnKey(Drawn kind, std::uint64_t id) { + return (static_cast(kind) << 48) | (id & 0xFFFFFFFFFFFFull); +} + +// The states of one stepped simulation's objects as they stood when its +// last step began. The simulation's step observer calls beginStep() and +// record()s each object, at the start of every step; the drawing asks for +// each object's state at the frame's alpha. An object not recorded at the +// start of the last step (new since, or not stepped), recorded under another +// generation (reset, respawned, recycled) or whose move is a jump is drawn +// as it is now. +class StepHistory { +public: + explicit StepHistory(JumpLimits limits = {}) : m_limits(limits) {} + + // A step begins: what is recorded until the next one is the state + // before it. Forgets the objects the step before did not record. + void beginStep(); + void record(std::uint64_t key, const Mat34& transform, std::uint32_t generation = 0, float turn = 0.0f); + void record(std::uint64_t key, const VehiclePose& pose, std::uint32_t generation = 0); + // Everything is drawn where it is until the next step (a race reset). + void clear(); + + // The share of a step the simulation has not run yet (its remainder / + // its step), 0..1. + void setAlpha(float alpha); + float alpha() const { return m_alpha; } + + // The drawn state of an object now at `live`. + Mat34 transform(std::uint64_t key, const Mat34& live, std::uint32_t generation = 0) const; + // A turn recorded with the transform (a rail car's tyres), wrapping + // every `period`. + float turn(std::uint64_t key, const Mat34& live, float liveTurn, float period, + std::uint32_t generation = 0) const; + VehiclePose pose(std::uint64_t key, const VehiclePose& live, std::uint32_t generation = 0) const; + // Whether the object will be blended (it has a usable earlier state). + bool blends(std::uint64_t key, const Mat34& live, std::uint32_t generation = 0) const; + + std::size_t size() const { return m_transforms.size() + m_poses.size(); } + +private: + struct TransformEntry { + std::uint64_t step = 0; + std::uint32_t generation = 0; + Mat34 transform; + float turn = 0.0f; + }; + struct PoseEntry { + std::uint64_t step = 0; + std::uint32_t generation = 0; + VehiclePose pose; + }; + const TransformEntry* previous(std::uint64_t key, const Mat34& live, std::uint32_t generation) const; + const PoseEntry* previousPose(std::uint64_t key, const Mat34& live, std::uint32_t generation) const; + + std::unordered_map m_transforms; + std::unordered_map m_poses; + std::uint64_t m_step = 0; + float m_alpha = 1.0f; + JumpLimits m_limits; +}; + +// ai::World's step observer: its ambient cars on their rails (by id, the +// generation their slot's spawns) and its pedestrians (by id), as they stand +// before the step. The cars the physics has taken over are the physics'. +void recordAiStep(StepHistory& history, const ai::World& world); + +// For the physics' step observer: the traffic cars that have a body (by car +// 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); +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. +std::optional trafficBodyPose(const TrafficBodies& bodies, int carId); + +} // namespace mm2::game diff --git a/src/game/PlayerVehicle.cpp b/src/game/PlayerVehicle.cpp index 0bbcf37..9dca365 100644 --- a/src/game/PlayerVehicle.cpp +++ b/src/game/PlayerVehicle.cpp @@ -264,8 +264,10 @@ VehiclePose SimVehicle::trailerPose() const { for (std::size_t i = 0; i < 4; ++i) { pose.wheelWorld[i] = m_trailer->wheels[i].matrix; pose.wheelValid[i] = true; + pose.wheelTurn[i] = m_trailer->wheels[i].rotation; } pose.hasWheelWorld = true; + pose.hasWheelTurn = true; // vehTrailerInstance::Draw: TLIGHT while the tow car brakes over 0.1. pose.brakeLights = m_sim.brakes > 0.1f; return pose; @@ -302,6 +304,7 @@ VehiclePose SimVehicle::pose() const { for (int i = 0; i < 4; ++i) { pose.wheelWorld[static_cast(i)] = m_sim.wheelMatrix(i); pose.wheelValid[static_cast(i)] = m_model.wheel(i) != nullptr; + pose.wheelTurn[static_cast(i)] = m_sim.wheels[static_cast(i)].rotation; } // vehCarModel::Draw: a WHL4 / WHL5 mesh (a second back axle) is drawn // with the WHL2 / WHL3 matrix moved back along the car's Z axis by @@ -317,8 +320,10 @@ VehiclePose SimVehicle::pose() const { m.m3 = {m.m3.x + back.x * k, m.m3.y + back.y * k, m.m3.z + back.z * k}; pose.wheelWorld[static_cast(i)] = m; pose.wheelValid[static_cast(i)] = true; + pose.wheelTurn[static_cast(i)] = m_sim.wheels[static_cast(follow)].rotation; } pose.hasWheelWorld = true; + pose.hasWheelTurn = true; // vehCarModel::DrawGlow: brake input not zero; reverse gear. pose.brakeLights = m_sim.brakes != 0.0f; pose.reverseLights = reversing(); diff --git a/src/game/TexelDamage.cpp b/src/game/TexelDamage.cpp index 91c8784..511d98c 100644 --- a/src/game/TexelDamage.cpp +++ b/src/game/TexelDamage.cpp @@ -132,9 +132,11 @@ void TexelDamage::upload(Layer& layer) { m_device.updateTexture(layer.handle, 0, {0, 0, layer.width, layer.height}, layer.live.data()); } -void TexelDamage::apply(const Vec3& point, float radius) { +void TexelDamage::apply(const Vec3& point, float radius, std::optional seed) { if (!m_active) return; + if (seed) + m_rand.seed(*seed); const float r2 = radius * radius; std::vector dirty(m_layers.size(), false); for (const auto& t : m_triangles) { diff --git a/src/game/TexelDamage.h b/src/game/TexelDamage.h index 887deca..21923e2 100644 --- a/src/game/TexelDamage.h +++ b/src/game/TexelDamage.h @@ -5,6 +5,8 @@ #include "game/fx/Random.h" #include "render/Device.h" +#include +#include #include #include @@ -32,8 +34,12 @@ class TexelDamage { TexelDamage& operator=(const TexelDamage&) = delete; bool active() const { return m_active; } - // fxTexelDamage::ApplyDamage: `point` in the body's model space. - void apply(const Vec3& point, float radius); + // fxTexelDamage::ApplyDamage: `point` in the body's model space. With + // `seed` the patches' random numbers start from that state (OpenMM2: a + // network car's patches come out as its owner's did). + void apply(const Vec3& point, float radius, std::optional seed = {}); + // The state the next apply() draws its random numbers from. + std::uint32_t randomState() const { return m_rand.state(); } // fxTexelDamage::Reset: the copies back to clean. void reset(); diff --git a/src/game/VehicleRenderer.cpp b/src/game/VehicleRenderer.cpp index 07d78fc..2309a3b 100644 --- a/src/game/VehicleRenderer.cpp +++ b/src/game/VehicleRenderer.cpp @@ -131,9 +131,17 @@ void VehicleRenderer::setPaintjob(int paintjob) { } } -void VehicleRenderer::applyDamage(const Vec3& modelPoint, float radius) { +void VehicleRenderer::applyDamage(const Vec3& modelPoint, float radius, std::optional seed) { if (m_texelDamage) - m_texelDamage->apply(modelPoint, radius); + m_texelDamage->apply(modelPoint, radius, seed); +} + +std::optional VehicleRenderer::attachedPart(const std::string& part) const { + const std::string name = str::upper(part); + const auto* pivot = m_model.pivot(str::lower(part)); + if (!pivot || !m_gpu || !findFilledLod(*m_gpu, name, asset::Lod::High) || m_detached.contains(name)) + return std::nullopt; + return Breakable{name, pivot->origin}; } void VehicleRenderer::resetDamage() { diff --git a/src/game/VehicleRenderer.h b/src/game/VehicleRenderer.h index 860b229..11cfe39 100644 --- a/src/game/VehicleRenderer.h +++ b/src/game/VehicleRenderer.h @@ -28,6 +28,11 @@ struct VehiclePose { std::array wheelWorld{}; std::array wheelValid{}; bool hasWheelWorld = false; + // How far each wheelWorld wheel has turned about its axle in all + // (vehWheel's accumulated rotation), when known: what is drawn between + // two simulation steps spins the wheels by the difference (blendPose). + std::array wheelTurn{}; + bool hasWheelTurn = false; // A traffic car the physics simulation has taken over (aiVehicleInstance // with an aiVehicleActive): its wheels are wheelWorld and its shadow is // laid on the ground first (aiVehicleInstance::Draw / DrawShadow). @@ -76,9 +81,12 @@ class VehicleRenderer { // fxTexelDamage::ApplyDamage at a point in the car's model space with // TextelDamageRadius; resetDamage() repaints the car clean. - void applyDamage(const Vec3& modelPoint, float radius); + // With `seed`, the patches start from that random state (a network + // car's, as its owner painted them; texelDamageState() is the owner's). + void applyDamage(const Vec3& modelPoint, float radius, std::optional seed = {}); void resetDamage(); bool hasTexelDamage() const { return m_texelDamage && m_texelDamage->active(); } + std::uint32_t texelDamageState() const { return m_texelDamage ? m_texelDamage->randomState() : 0u; } // The paint job's material textures in order (fxShardManager's shards). std::vector materialTextures() const; int paintjob() const { return m_paintjob; } @@ -100,6 +108,11 @@ class VehicleRenderer { // (vehBreakableMgr::Get by id bit, vehBreakableMgr::EjectAll above // 100 mph). std::vector wreckParts(float mph, fx::Rand& rng); + // A part either manager can eject (a BREAK part, the paint job's + // VARIANT, a wheel, hub, fender or the engine) that the car has and that + // is still on, with its pivot (OpenMM2: a network car loses the parts its + // owner's car lost). + std::optional attachedPart(const std::string& part) const; // vehBreakableMgr::Eject: the part stops being drawn; `banger` is the // hit banger instance it became (vehBreakable +0x44), if any. void detach(const std::string& part, std::optional banger = {}); diff --git a/src/game/bangers/BangerSet.cpp b/src/game/bangers/BangerSet.cpp index 94ec8cc..b012cf3 100644 --- a/src/game/bangers/BangerSet.cpp +++ b/src/game/bangers/BangerSet.cpp @@ -1085,7 +1085,12 @@ void BangerSet::draw(render::Device& device, ModelLibrary& models, TextureLibrar const Frustum& frustum, const Camera& camera, const DrawParams& params) { const Mat34& cam = camera.transform; std::vector glows; - std::vector> trees; + struct Tree { + const Instance* instance; + const GpuMesh* mesh; + Mat34 matrix; + }; + std::vector trees; // cityLevel::DrawRooms draws the props from their rooms (lvlLevel:: // MoveToRoom's, which BangerSet keeps) when the city listed the view's. const bool rooms = params.rooms && params.rooms->active() && roomsTracked(); @@ -1108,9 +1113,11 @@ 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; // lvlInstance::IsVisible with the dynamic objects' NoDraw limit. - const auto lod = objectLod(viewDepth(cam, inst.matrix.m3), radius, params.detail, params.detail.noDraw); - const bool visible = lod && frustum.intersectsSphere(inst.matrix.m3, radius); + const auto lod = objectLod(viewDepth(cam, matrix.m3), radius, params.detail, params.detail.noDraw); + const bool visible = lod && frustum.intersectsSphere(matrix.m3, radius); // Lamp glows of props still standing (dgBangerInstance::DrawGlow: // lvlInstance flag 1, which stays set while an active holds the prop // and only goes when it breaks loose). cityLevel_drawLights draws @@ -1118,7 +1125,7 @@ void BangerSet::draw(render::Device& device, ModelLibrary& models, TextureLibrar // city's room list, with the prop. if (params.glows && standing(i) && (rooms ? passes.shadowsAndGlows : visible)) for (const Vec3& g : inst.data->glowOffsets) - glows.push_back(inst.matrix.transform(g)); + glows.push_back(matrix.transform(g)); if (!visible || !passes.objects) continue; const std::string part = !inst.mesh.empty() ? inst.mesh @@ -1130,7 +1137,7 @@ void BangerSet::draw(render::Device& device, ModelLibrary& models, TextureLibrar if (!mesh) continue; if (tree) { - trees.emplace_back(&inst, mesh); + trees.push_back({&inst, mesh, matrix}); continue; } // dgBangerInstance::Draw. No local lights @@ -1144,17 +1151,17 @@ void BangerSet::draw(render::Device& device, ModelLibrary& models, TextureLibrar options.alphaRef = kUnlitAlphaRef; } drawGpuMesh(device, textures, *mesh, model->materials(variantOf(*model, inst.paint)), - Mat44::fromMat34(inst.matrix), options); + Mat44::fromMat34(matrix), options); } // dgTreeRenderer::RenderTrees (dgBangerInstance::DrawTree): unlit, alpha // reference 120, with the prop's variant. - for (const auto& [inst, mesh] : trees) { + for (const Tree& t : trees) { MeshDrawOptions options; options.lighting = false; options.alphaRef = kTreeAlphaRef; - const GpuModel& model = *models.get(inst->model); - drawGpuMesh(device, textures, *mesh, model.materials(variantOf(model, inst->paint)), - Mat44::fromMat34(inst->matrix), options); + const GpuModel& model = *models.get(t.instance->model); + drawGpuMesh(device, textures, *t.mesh, model.materials(variantOf(model, t.instance->paint)), + Mat44::fromMat34(t.matrix), options); } // Lamp glows: s_yel_glow cards of half size 1.5 m with a 1% flicker, @@ -1177,7 +1184,8 @@ void BangerSet::draw(render::Device& device, ModelLibrary& models, TextureLibrar if (!a.particles.count() || a.texNumber <= 0) continue; const auto sheet = fx::EffectLibrary::bangerSheet(a.texNumber); - cards.draw(device, cam, a.particles, textures.get(sheet.texture)); + // OpenMM2: between the last two 60 Hz updates (FixedTicker::behind). + cards.draw(device, cam, a.particles, textures.get(sheet.texture), {}, m_ticker.behind()); } } diff --git a/src/game/bangers/BangerSet.h b/src/game/bangers/BangerSet.h index f59e012..749f728 100644 --- a/src/game/bangers/BangerSet.h +++ b/src/game/bangers/BangerSet.h @@ -24,6 +24,7 @@ #include #include +#include #include #include #include @@ -87,6 +88,10 @@ class BangerSet final : public InstanceSource { // props are then drawn from their rooms (cityLevel_drawObjects) and // their lamp glows by the room alone (cityLevel_drawLights). const RoomVisibility* rooms = nullptr; + // OpenMM2 presentation: where a prop an active simulates is drawn, + // from its index and current matrix (between the physics' last two + // samples, game::StepHistory); without it, at its matrix. + std::function drawnMatrix; }; // Inside a scene pass after setFrameConstants(). void draw(render::Device& device, ModelLibrary& models, TextureLibrary& textures, fx::ParticleRenderer& cards, diff --git a/src/game/fx/ParticleRenderer.cpp b/src/game/fx/ParticleRenderer.cpp index 595b605..f828f35 100644 --- a/src/game/fx/ParticleRenderer.cpp +++ b/src/game/fx/ParticleRenderer.cpp @@ -116,10 +116,19 @@ void ParticleRenderer::flush(render::Device& device, const WorldTexture* texture } void ParticleRenderer::draw(render::Device& device, const Mat34& cameraBasis, const ParticleSystem& system, - const WorldTexture* texture, const CardStyle& style) { + const WorldTexture* texture, const CardStyle& style, float behind) { begin(); - for (const auto& p : system.positions()) - add(p, system.framesWide(), system.framesHigh(), cameraBasis); + const auto positions = system.positions(); + const auto info = system.info(); + for (std::size_t i = 0; i < positions.size(); ++i) { + if (behind > 0.0f) { + SparkPos p = positions[i]; + p.position = p.position - info[i].velocity * behind; + add(p, system.framesWide(), system.framesHigh(), cameraBasis); + } else { + add(positions[i], system.framesWide(), system.framesHigh(), cameraBasis); + } + } flush(device, texture, style); } diff --git a/src/game/fx/ParticleRenderer.h b/src/game/fx/ParticleRenderer.h index 364503c..e837efa 100644 --- a/src/game/fx/ParticleRenderer.h +++ b/src/game/fx/ParticleRenderer.h @@ -39,9 +39,11 @@ class ParticleRenderer { // texture is a sheet of system.framesWide() x framesHigh() frames, frame // 0 at the start of the texture's first stored row. Particles flagged // BirthRule::kShadow also get their flat shadow, drawn first - // (asParticles::Cull). + // (asParticles::Cull). `behind` (s): each particle is drawn that far back + // along its velocity (its update moves it by its new velocity, so this is + // where it was between its last two updates; FixedTicker::behind). void draw(render::Device& device, const Mat34& cameraBasis, const ParticleSystem& system, - const WorldTexture* texture, const CardStyle& style = {}); + const WorldTexture* texture, const CardStyle& style = {}, float behind = 0.0f); // Lower-level: append cards for arbitrary particles and flush once. void begin(); diff --git a/src/game/fx/Particles.h b/src/game/fx/Particles.h index b7bdfaa..5e03ab3 100644 --- a/src/game/fx/Particles.h +++ b/src/game/fx/Particles.h @@ -107,6 +107,11 @@ class FixedTicker { // At most `maxSteps` per call (a long stall does not replay seconds of effects). int advance(float dt, int maxSteps = 8); void reset() { m_accumulator = 0.0f; } + // OpenMM2 presentation: how long ago the last update would have been + // due, kStep less the time not run yet. The particles are drawn that far + // back along their velocities (ParticleRenderer::draw), between their + // last two updates as the rest of the scene is (game::StepHistory). + float behind() const { return kStep - m_accumulator; } private: float m_accumulator = 0.0f; diff --git a/src/game/fx/VehicleEffects.cpp b/src/game/fx/VehicleEffects.cpp index 05fd5eb..bba0add 100644 --- a/src/game/fx/VehicleEffects.cpp +++ b/src/game/fx/VehicleEffects.cpp @@ -72,20 +72,25 @@ void VehicleEffects::impact(const phys::CarImpact& impact, const phys::CarSim& c // body). if (!impact.damaging) return; - const float mph = car.speedMph(); - // Sparks: 16 x the impact's running total x frame seconds of them (at - // most the pool's 64). The frame time is the fixed 1/60 s step. - if (15.0f < mph) - m_sparks.radialBlast(static_cast(16.0f * impact.total * FixedTicker::kStep), impact.position, - impact.normal); - // fxShardManager::EmitShards gets the impact's running total. - m_shards.emit(impact.position, impact.total, car.speed(), car.body.ics.matrix); + impactEffects(impact.position, impact.normal, impact.total, car.speed(), car.speedMph(), + car.body.ics.matrix); if (!m_damagePoint) m_damagePoint = impact.localPosition; if (m_impacts.size() < 64) m_impacts.push_back({impact.localPosition, impact.value}); } +void VehicleEffects::impactEffects(const Vec3& position, const Vec3& normal, float total, float speed, + float mph, const Mat34& body) { + // Sparks: 16 x the impact's running total x frame seconds of them (at + // most the pool's 64), above 15 mph. The frame time is the fixed 1/60 s + // step. + if (15.0f < mph) + m_sparks.radialBlast(static_cast(16.0f * total * FixedTicker::kStep), position, normal); + // fxShardManager::EmitShards gets the impact's running total. + m_shards.emit(position, total, speed, body); +} + std::vector VehicleEffects::takeImpacts() { std::vector out; out.swap(m_impacts); @@ -157,7 +162,7 @@ void VehicleEffects::spewSmoke(const Mat34& car, const Vec3& offset, float amoun void VehicleEffects::step(float dt, const phys::CarSim& car, const VehicleFxContext& context) { // vehCar::Update: the four tracks (vehCar::UpdateTrack), then vehWheelPtx. - for (std::size_t i = 0; i < 4; ++i) { + for (std::size_t i = 0; i < 4 && context.wheels; ++i) { const phys::Wheel& w = car.wheels[i]; auto& track = m_tracks[i]; const bool water = w.material && w.material->name == "water"; @@ -165,7 +170,7 @@ void VehicleEffects::step(float dt, const phys::CarSim& car, const VehicleFxCont track.setWidth(w.width); track.update(w.intersection.position, w.matrix.m0, w.skidding && !water && context.tracksAllowed); } for (const phys::Wheel& w : car.wheels) { - if (!w.material) + if (!w.material || !context.wheels) continue; for (int slot = 0; slot < 2; ++slot) { const int index = w.material->ptxIndex[slot]; @@ -177,7 +182,7 @@ void VehicleEffects::step(float dt, const phys::CarSim& car, const VehicleFxCont // vehCarDamage::Update: smoke above MedDamage, four steps to MaxDamage, // each with its own frame of fxpt8; one puff per update. - const Mat34& body = car.body.ics.matrix; + const Mat34& body = context.body ? *context.body : car.body.ics.matrix; const auto& d = car.damage.params; const float f = std::clamp((car.damage.currentDamage - d.medDamage) / (d.maxDamage - d.medDamage), 0.0f, 1.0f); const int level = static_cast(std::ceil(static_cast(f * 4.0f))); @@ -214,10 +219,12 @@ void VehicleEffects::step(float dt, const phys::CarSim& car, const VehicleFxCont void VehicleEffects::draw(render::Device& device, TextureLibrary& textures, ParticleRenderer& cards, SkidRenderer& skids, const Mat34& cameraBasis) { skids.draw(device, textures, {&m_tracks[0], &m_tracks[1], &m_tracks[2], &m_tracks[3]}); + // OpenMM2: between their last two 60 Hz updates (FixedTicker::behind). if (m_wheelPtx.count()) - cards.draw(device, cameraBasis, m_wheelPtx, textures.get(EffectLibrary::wheelSheet().texture)); + cards.draw(device, cameraBasis, m_wheelPtx, textures.get(EffectLibrary::wheelSheet().texture), {}, + m_ticker.behind()); if (m_smoke.count()) - cards.draw(device, cameraBasis, m_smoke, textures.get("fxpt8")); + cards.draw(device, cameraBasis, m_smoke, textures.get("fxpt8"), {}, m_ticker.behind()); m_sparks.draw(device); m_shards.draw(device, textures, m_setup.shardTextures); } diff --git a/src/game/fx/VehicleEffects.h b/src/game/fx/VehicleEffects.h index 82edc57..9f6b5d8 100644 --- a/src/game/fx/VehicleEffects.h +++ b/src/game/fx/VehicleEffects.h @@ -39,6 +39,15 @@ struct VehicleFxContext { // vehCar::UpdateTrack lays no tracks while the car's room has the // runtime flag 0x10, which gizBridge sets on the opening bridges. bool tracksAllowed = true; + // OpenMM2: false for a car drawn from the network (another player's, a + // shared police car on a client), whose wheels are not simulated: no + // tracks and no wheel particles, only vehCarDamage's smoke and the + // replayed impacts' sparks and shards. + bool wheels = true; + // OpenMM2: where such a car is drawn (its phInertialCS matrix, a + // physics step behind its kinematic body, game::StepHistory): its smoke + // comes from there. Without it, the car's own body. + std::optional body; }; class VehicleEffects { @@ -59,6 +68,12 @@ class VehicleEffects { // shards, and texel damage at the first such point since the last // takeDamagePoint(). void impact(const phys::CarImpact& impact, const phys::CarSim& car); + // The sparks and shards of a damaging impact (as impact() makes them): + // world `position` and `normal`, the impact's running total, the car's + // speed (m/s and mph) and matrix. OpenMM2: a network car's replayed + // impacts. + void impactEffects(const Vec3& position, const Vec3& normal, float total, float speed, float mph, + const Mat34& body); // The model-space point texel damage should be painted at, once. std::optional takeDamagePoint(); // The impacts that counted since the last call (model-space point and diff --git a/src/game/fx/Weather.cpp b/src/game/fx/Weather.cpp index dda5033..5f27ef3 100644 --- a/src/game/fx/Weather.cpp +++ b/src/game/fx/Weather.cpp @@ -32,7 +32,9 @@ void Weather::draw(render::Device& device, TextureLibrary& textures, ParticleRen if (!m_ok || !m_system.count()) return; // asParticles::SetTexture by name asks gfxGetTexture for no mipmaps. - cards.draw(device, camera, m_system, textures.get(EffectLibrary::rainSheet().texture, false)); + // OpenMM2: between the last two 60 Hz updates (FixedTicker::behind). + cards.draw(device, camera, m_system, textures.get(EffectLibrary::rainSheet().texture, false), {}, + m_ticker.behind()); } } // namespace mm2::game::fx diff --git a/src/game/net/DamageSync.cpp b/src/game/net/DamageSync.cpp new file mode 100644 index 0000000..8aaa687 --- /dev/null +++ b/src/game/net/DamageSync.cpp @@ -0,0 +1,489 @@ +// The damage of the cars drawn from the network (OpenMM2). See DamageSync.h +// and docs/multiplayer.md, "Damage". +#include "game/net/DamageSync.h" + +#include "core/Log.h" +#include "core/StringUtil.h" +#include "game/net/NetGame.h" +#include "phys/Constants.h" + +#include +#include +#include +#include +#include + +namespace mm2::game { +namespace { + +// vehCarModel::Init's breakables in the order the managers hold them: +// manager A (vehBreakableMgr::Impact) the BREAK parts and the paint job's +// VARIANT, manager B (EjectOneshot) the wheels, hubs, fenders and engine. +constexpr std::array kParts = { + "BREAK0", "BREAK1", "BREAK2", "BREAK3", "BREAK01", "BREAK12", "BREAK23", "BREAK03", "VARIANT", "WHL0", + "WHL1", "WHL2", "WHL3", "HUB0", "HUB1", "HUB2", "HUB3", "FNDR0", "FNDR1", "ENGINE"}; +constexpr int kVariant = 8; +constexpr int kFirstWreckPart = 9; + +bool finite(const Vec3& v) { return std::isfinite(v.x) && std::isfinite(v.y) && std::isfinite(v.z); } + +bool within(const Vec3& v, float range) { + return finite(v) && std::abs(v.x) <= range && std::abs(v.y) <= range && std::abs(v.z) <= range; +} + +std::uint8_t delayOf(std::uint32_t time, std::uint32_t base) { + const auto d = static_cast(time - base); + return static_cast(std::clamp(d, 0, 255)); +} + +} // namespace + +int damagePartIndex(std::string_view part) { + const std::string name = str::upper(part); + for (std::size_t i = 0; i < kParts.size(); ++i) + if (i != kVariant && name == kParts[i]) + return static_cast(i); + if (name.size() > 7 && name.starts_with("VARIANT") && + std::all_of(name.begin() + 7, name.end(), [](char c) { return c >= '0' && c <= '9'; })) + return kVariant; + return -1; +} + +std::string damagePartName(int index, int paintjob) { + if (index < 0 || index >= net::kDamagePartCount) + return {}; + if (index == kVariant) + return std::format("VARIANT{}", paintjob); + return std::string(kParts[static_cast(index)]); +} + +bool damagePartIsWreckPart(int index) { return index >= kFirstWreckPart; } + +net::DamageImpact damageImpactOf(const phys::CarImpact& impact, const phys::CarSim& car) { + const Mat34 m = car.modelMatrix(); + const Vec3& n = impact.normal; + net::DamageImpact d; + d.point = impact.localPosition; + d.normal = {n.dot(m.m0), n.dot(m.m1), n.dot(m.m2)}; + d.total = impact.total; + d.speed = car.speed(); + d.sound = impact.sound ? impact.soundStrength : 0.0f; + d.audioId = static_cast(std::clamp(impact.audioId, 0, 1000)); + return d; +} + +float damageFromFraction(const phys::CarDamageParams& params, float fraction) { + if (!(fraction > 0.0f)) + return 0.0f; + if (fraction >= 1.0f) + return params.maxDamage; + return params.medDamage + fraction * (params.maxDamage - params.medDamage); +} + +// --- DamageRecorder ---------------------------------------------------------------------- + +DamageRecorder::DamageRecorder(std::uint16_t subject, const Options& options) + : m_subject(subject), m_options(options) { + m_batches.emplace_back(); +} + +void DamageRecorder::reset(std::uint32_t time) { + ++m_epoch; + m_count = 0; + m_parts = 0; + // A batch that only said "reset" (or nothing) is superseded: the newer + // reset clears the car as well. + const Batch& last = open(); + if (last.patches.empty() && !last.partsTime && last.impacts.empty()) + m_batches.pop_back(); + Batch b; + b.epoch = m_epoch; + b.resetTime = time; + m_batches.push_back(std::move(b)); +} + +Vec3 DamageRecorder::patch(std::uint32_t time, const Vec3& point, std::uint32_t seed) { + const Vec3 q = net::quantizeDamagePoint(point); + if (m_count >= net::kMaxDamageRecord) + return q; + Batch& b = open(); + if (b.patches.empty()) + b.first = m_count; + b.patches.push_back({time, {0, q, seed}}); + ++m_count; + return q; +} + +void DamageRecorder::part(std::uint32_t time, std::string_view name) { + const int index = damagePartIndex(name); + if (index < 0 || (m_parts & (1u << index)) != 0) + return; + m_parts |= 1u << index; + Batch& b = open(); + b.parts = m_parts; + b.partsTime = time; +} + +void DamageRecorder::impact(std::uint32_t time, const net::DamageImpact& impact) { + Batch& b = open(); + if (b.impacts.size() < net::kMaxDamageImpacts) + b.impacts.push_back({time, impact}); +} + +std::vector DamageRecorder::take(std::uint64_t nowMs) { + std::vector out; + const bool due = !m_sentAny || nowMs - m_lastSent >= m_options.intervalMs; + if (!due && m_batches.size() < 2) + return out; + // The sender's token bucket. + if (m_tokens < 0.0) + m_tokens = m_options.burst; + else + m_tokens = std::min(m_options.burst, + m_tokens + static_cast(nowMs - m_tokensAt) * 0.001 * m_options.perSecond); + m_tokensAt = nowMs; + bool starved = false; // out of tokens: the rest waits for the next ones + while (!m_batches.empty() && !starved) { + Batch& b = m_batches.front(); + const bool isOpen = m_batches.size() == 1; + if (b.empty()) { + if (isOpen) + break; + m_batches.pop_front(); + continue; + } + if (isOpen && !due) + break; + // One event per kMaxDamagePatches patches; the reset, the impacts and + // the newly broken parts go with the first. + while (!b.empty()) { + if (m_tokens < 1.0) { + starved = true; + break; + } + m_tokens -= 1.0; + net::VehicleDamageEvent e; + e.subject = m_subject; + e.epoch = b.epoch; + std::uint32_t base = 0; + if (b.resetTime) + base = *b.resetTime; + else if (!b.patches.empty()) + base = b.patches.front().time; + else if (!b.impacts.empty()) + base = b.impacts.front().time; + else if (b.partsTime) + base = *b.partsTime; + e.time = base; + e.first = static_cast(b.first); + const std::size_t n = std::min(b.patches.size(), net::kMaxDamagePatches); + for (std::size_t i = 0; i < n; ++i) { + net::DamagePatch p = b.patches[i].patch; + p.delay = delayOf(b.patches[i].time, base); + e.patches.push_back(p); + } + b.patches.erase(b.patches.begin(), b.patches.begin() + static_cast(n)); + b.first += static_cast(n); + for (const Hit& h : b.impacts) { + net::DamageImpact m = h.impact; + m.delay = delayOf(h.time, base); + e.impacts.push_back(m); + } + b.impacts.clear(); + e.parts = b.parts; + if (b.partsTime) + e.partsDelay = delayOf(*b.partsTime, base); + b.partsTime.reset(); + b.resetTime.reset(); + out.push_back(std::move(e)); + } + if (isOpen || starved) + break; + m_batches.pop_front(); + } + if (m_batches.empty()) { + Batch b; + b.epoch = m_epoch; + b.first = m_count; + b.parts = m_parts; + m_batches.push_back(std::move(b)); + } + // The open batch carries on from what was sent. + Batch& b = open(); + if (b.patches.empty()) + b.first = m_count; + b.parts = m_parts; + if (!out.empty()) { + m_lastSent = nowMs; + m_sentAny = true; + } + return out; +} + +// --- DamageReplica ----------------------------------------------------------------------- + +bool DamageReplica::receive(std::uint8_t from, const net::VehicleDamageEvent& e, double arrival) { + ++m_stats.events; + std::uint32_t key = 0; + if (e.subject == net::kDamageOwnCar) { + if (from >= net::kMaxPlayers) { + ++m_stats.refused; + return false; + } + key = playerKey(from); + } else { + // Only the host runs the shared police. + if (from != net::kHostPlayerId || e.subject >= net::kMaxAmbientIds) { + ++m_stats.refused; + return false; + } + key = ambientKey(e.subject); + } + bool ok = e.patches.size() <= net::kMaxDamagePatches && e.impacts.size() <= net::kMaxDamageImpacts && + e.first + e.patches.size() <= net::kMaxDamageRecord && + (e.parts >> net::kDamagePartCount) == 0 && std::isfinite(arrival); + for (const auto& p : e.patches) + ok = ok && within(p.point, net::kDamagePointRange); + for (const auto& m : e.impacts) + ok = ok && within(m.point, net::kDamagePointRange) && within(m.normal, 1.0f) && + std::isfinite(m.total) && m.total >= 0.0f && std::isfinite(m.speed) && m.speed >= 0.0f && + m.speed <= net::kDamageMaxSpeed && std::isfinite(m.sound) && m.sound >= 0.0f && + m.audioId <= 1000; + if (!ok) { + ++m_stats.refused; + return false; + } + auto it = m_records.find(key); + if (it == m_records.end()) { + if (m_records.size() >= kMaxRecords) { + ++m_stats.refused; + return false; + } + it = m_records.emplace(key, Record{}).first; + } + Record& r = it->second; + // Events piling up (a car not drawn, or a flood): the oldest go into the + // record at once, and the car shows its whole record when next drawn. + while (r.pending.size() >= kMaxPending) { + r.pending.front().overdue = true; + const std::size_t before = r.pending.size(); + process(r, -std::numeric_limits::infinity(), arrival, nullptr); + if (r.pending.size() >= before) + r.pending.pop_front(); + r.replay = true; + } + Pending p; + p.event = e; + p.arrival = arrival; + r.pending.push_back(std::move(p)); + return true; +} + +void DamageReplica::process(Record& r, double sampleTime, double now, Actions* out) { + while (!r.pending.empty()) { + Pending& p = r.pending.front(); + const net::VehicleDamageEvent& e = p.event; + const bool overdue = p.overdue || now - p.arrival >= kMaxHoldMs; + const double base = static_cast(e.time); + auto due = [&](std::uint8_t delay) { return overdue || sampleTime >= base + delay; }; + auto fresh = [&](std::uint8_t delay) { return !overdue && sampleTime - (base + delay) < kFreshMs; }; + if (!p.started) { + // A new epoch: the car's damage was cleared at the event's time. + if (!r.any || e.epoch != r.epoch) { + if (!due(0)) + return; + r.any = true; + r.epoch = e.epoch; + r.patches.clear(); + r.next = 0; + r.parts = 0; + if (out) { + *out = Actions{}; + out->clear = true; + } + } + p.started = true; + } + while (p.nextPatch < e.patches.size() && due(e.patches[p.nextPatch].delay)) { + const net::DamagePatch& d = e.patches[p.nextPatch]; + const std::uint32_t index = e.first + static_cast(p.nextPatch); + if (index >= r.next) { + m_stats.missed += index - r.next; + r.patches.push_back({d.point, d.seed}); + r.next = index + 1; + ++m_stats.patches; + if (out) + out->patches.push_back({d.point, d.seed}); + } + ++p.nextPatch; + } + while (p.nextImpact < e.impacts.size() && due(e.impacts[p.nextImpact].delay)) { + const net::DamageImpact& m = e.impacts[p.nextImpact]; + if (out && fresh(m.delay)) + out->impacts.push_back(m); + ++p.nextImpact; + } + if (!p.partsDone && due(e.partsDelay)) { + const std::uint32_t added = e.parts & ~r.parts; + r.parts |= added; + if (out && added) { + out->partsOff |= added; + out->partsFresh = out->partsFresh || fresh(e.partsDelay); + } + p.partsDone = true; + } + if (p.nextPatch < e.patches.size() || p.nextImpact < e.impacts.size() || !p.partsDone) + return; + r.pending.pop_front(); + } +} + +DamageReplica::Actions DamageReplica::advance(std::uint32_t key, double sampleTime, double now) { + Actions out; + const auto it = m_records.find(key); + if (it == m_records.end()) + return out; + Record& r = it->second; + r.touched = true; + if (r.replay) { + out = replay(key); + r.replay = false; + } + process(r, sampleTime, now, &out); + return out; +} + +void DamageReplica::settle(double now) { + for (auto& [key, r] : m_records) { + if (!r.touched) { + const std::size_t before = r.pending.size(); + process(r, -std::numeric_limits::infinity(), now, nullptr); + // Should the car be drawn again without being shown afresh, it + // shows its whole record then. + r.replay = r.replay || r.pending.size() != before; + } + r.touched = false; + } +} + +DamageReplica::Actions DamageReplica::replay(std::uint32_t key) const { + Actions out; + const auto it = m_records.find(key); + if (it == m_records.end()) + return out; + out.clear = true; + out.patches = it->second.patches; + out.partsOff = it->second.parts; + return out; +} + +std::size_t DamageReplica::recordSize(std::uint32_t key) const { + const auto it = m_records.find(key); + return it == m_records.end() ? 0 : it->second.patches.size(); +} + +// --- applyDamage ------------------------------------------------------------------------- + +void applyDamage(const DamageReplica::Actions& a, DamageTarget& t) { + if (!t.renderer) + return; + VehicleRenderer& r = *t.renderer; + // vehCarModel::ClearDamage: the texel damage and every part back on. + if (a.clear) + r.resetDamage(); + // fxTexelDamage::ApplyDamage as the owner ran it. + for (const auto& p : a.patches) + r.applyDamage(p.point, t.texelRadius, p.seed); + // vehBreakableMgr::Eject: thrown off as it breaks, or just gone. + for (int i = 0; i < net::kDamagePartCount; ++i) { + if ((a.partsOff & (1u << i)) == 0) + continue; + const auto part = r.attachedPart(damagePartName(i, r.paintjob())); + if (!part) + continue; + const float speed = damagePartIsWreckPart(i) ? t.speed * 1.3f : 4.0f; + if (!(a.partsFresh && t.eject && t.eject(*part, speed))) + r.detach(part->part); + } + // vehCarDamage::ApplyImpact's sparks, shards and AudImpact. + for (const auto& m : a.impacts) { + const Vec3 position = t.body.transform(m.point); + Vec3 normal = t.body.transformDir(m.normal); + if (const float len = normal.mag(); len > 1e-6f) + normal = normal * (1.0f / len); + if (t.effects) + t.effects->impactEffects(position, normal, m.total, m.speed, + m.speed * phys::kMetersPerSecondToMph, t.body); + if (t.sound && m.sound > 0.001f) + t.sound(position, m.sound, m.audioId); + } +} + +// --- NetDamage --------------------------------------------------------------------------- + +DamageRecorder& NetDamage::police(std::uint16_t id) { + return m_police.try_emplace(id, id).first->second; +} + +void NetDamage::send(NetGame& net, std::uint64_t nowMs) { + auto send = [&](DamageRecorder& recorder) { + for (auto& e : recorder.take(nowMs)) { + if (m_verbose) + log::info("netdamage: send car {} epoch {} t {} patches {}+{} parts {:#x} impacts {}", + e.subject, e.epoch, e.time, e.first, e.patches.size(), e.parts, e.impacts.size()); + auto payload = net::encodePayload(std::move(e)); + ++m_stats.sentEvents; + m_stats.sentBytes += payload.size(); + net.sendEvent(net::kVehicleDamageEvent, std::move(payload)); + } + }; + send(m_own); + for (auto& [id, recorder] : m_police) + send(recorder); +} + +void NetDamage::receive(std::span events, double now) { + for (const NetGameEvent& ev : events) { + if (static_cast(ev.type) != net::kVehicleDamageEvent) + continue; + ++m_stats.receivedEvents; + m_stats.receivedBytes += ev.payload.size(); + const auto e = ev.as(); + const bool taken = e && m_replica.receive(ev.from, *e, now); + if (m_verbose && e) + log::info("netdamage: from {} car {} epoch {} t {} patches {}+{} parts {:#x} impacts {}{}", + ev.from, e->subject, e->epoch, e->time, e->first, e->patches.size(), e->parts, + e->impacts.size(), taken ? "" : " (refused)"); + } +} + +void NetDamage::update(std::uint32_t key, DamageTarget& target, double sampleTime, double now, bool fresh) { + if (fresh) { + // A car shown afresh starts clean, then shows its record so far. + if (target.renderer) + target.renderer->resetDamage(); + applyDamage(m_replica.replay(key), target); + } + const auto actions = m_replica.advance(key, sampleTime, now); + if (!actions.empty()) + applyDamage(actions, target); +} + +void NetDamage::logStats(std::uint64_t nowMs) { + if (m_statsAt == 0) + m_statsAt = nowMs; + if (nowMs - m_statsAt < 10000) + return; + const double seconds = static_cast(nowMs - m_statsAt) / 1000.0; + if (m_stats.sentEvents || m_stats.receivedEvents) + log::info("netdamage: sent {} events ({:.0f} B/s payload), received {} ({:.0f} B/s); {} records, {} " + "patches, {} missed, {} refused", + m_stats.sentEvents, static_cast(m_stats.sentBytes) / seconds, + m_stats.receivedEvents, static_cast(m_stats.receivedBytes) / seconds, + m_replica.records(), m_replica.stats().patches, m_replica.stats().missed, + m_replica.stats().refused); + m_stats = {}; + m_statsAt = nowMs; +} + +} // namespace mm2::game diff --git a/src/game/net/DamageSync.h b/src/game/net/DamageSync.h new file mode 100644 index 0000000..8374e95 --- /dev/null +++ b/src/game/net/DamageSync.h @@ -0,0 +1,278 @@ +#pragma once + +// The damage of the cars drawn from the network: the other players' cars +// and, on a shared-traffic client, the host's police (OpenMM2; see +// docs/multiplayer.md, "Damage", and net/VehicleDamage.h). +// +// MM2 (mmNetObject::SetPositionData / PositionUpdate) sends a car's +// vehCarDamage::CurrentDamage with its position, and every machine simulates +// the network car, so its own collisions paint the dents and break the parts +// there. OpenMM2's network cars are kinematic, drawn from the owner's +// snapshots: their damage level still comes with the snapshot (the smoke, +// the fire and the tyre wobble follow it as vehCarDamage::Update makes +// them), and what the owner's vehCarDamage::ApplyImpact painted and broke +// comes as reliable events, replayed here through the same code: +// +// DamageRecorder owner side: the texel damage patches the car painted +// (the point and the random state each started from), +// the parts it lost, its damaging impacts (sparks, +// shards, impact sound) and its resets, sent in batches +// DamageReplica receiver side: one record per car since its last +// reset, each entry applied when the car is drawn at the +// time it happened on its owner's machine +// applyDamage the record's actions on a car's renderer and effects +// +// Nothing here touches the network: the race screen sends the recorder's +// events and feeds the received ones in. + +#include "game/VehicleRenderer.h" +#include "game/fx/VehicleEffects.h" +#include "net/VehicleDamage.h" +#include "phys/vehicle/CarSim.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace mm2::game { + +// The parts a car can lose, as bits of net::VehicleDamageEvent::parts: +// vehBreakableMgr::Impact's BREAK0-3, BREAK01, BREAK12, BREAK23, BREAK03 and +// the paint job's VARIANT (0-8), vehCarModel::EjectOneshot's WHL0-3, HUB0-3, +// FNDR0-1 and ENGINE (9-19). -1 for any other name. +int damagePartIndex(std::string_view part); +// The part's mesh name ("VARIANT" for 8); empty out of range. +std::string damagePartName(int index, int paintjob); +// EjectOneshot's parts fly at 1.3 times the car's speed, Impact's at 4 m/s. +bool damagePartIsWreckPart(int index); + +// What a damaging impact (CarSim::onImpactCallback) shows on another +// machine, in the car's model space. +net::DamageImpact damageImpactOf(const phys::CarImpact& impact, const phys::CarSim& car); + +// Damage level <-> the replicated 0..1 fraction (vehCarDamage::Update's +// (CurrentDamage - MedDamage) / (MaxDamage - MedDamage)): a network car's +// CurrentDamage is set from it, which gives the same smoke level, fire and +// tyre wobble (nothing shows below MedDamage). +float damageFromFraction(const phys::CarDamageParams& params, float fraction); + +class DamageRecorder { +public: + struct Options { + // At most this often a batch of what happened; a reset sends the + // batch before it at once. + std::uint32_t intervalMs = 100; + // The sender's own budget (under the host's 15 a second, burst 30): + // what does not fit waits for the next tokens, nothing is dropped. + double perSecond = 10.0; + double burst = 20.0; + }; + + explicit DamageRecorder(std::uint16_t subject = net::kDamageOwnCar) + : DamageRecorder(subject, Options{}) {} + DamageRecorder(std::uint16_t subject, const Options& options); + + // vehCar::ClearDamage on the car (a reset, a respawn, a repair): its + // damage starts again, everywhere, at session time `time`. + void reset(std::uint32_t time); + // fxTexelDamage::ApplyDamage is about to paint the car at `point` (model + // space) from random state `seed`. Returns the point to paint at: the one + // the receivers get (net::quantizeDamagePoint), so every machine runs + // the texel damage on the same numbers. + Vec3 patch(std::uint32_t time, const Vec3& point, std::uint32_t seed); + // A part broke off (vehBreakableMgr::Eject), by mesh name. + void part(std::uint32_t time, std::string_view name); + // A damaging impact (for its sparks, shards and sound; the first few of + // each batch travel). + void impact(std::uint32_t time, const net::DamageImpact& impact); + + // The events to send at monotonic time `nowMs` (often empty). + std::vector take(std::uint64_t nowMs); + + std::uint8_t epoch() const { return m_epoch; } + // Patches recorded since the last reset (the ones beyond + // net::kMaxDamageRecord are painted but not sent). + std::uint32_t recorded() const { return m_count; } + +private: + struct Entry { + std::uint32_t time = 0; + net::DamagePatch patch; + }; + struct Hit { + std::uint32_t time = 0; + net::DamageImpact impact; + }; + // What happened in one epoch since the last event. + struct Batch { + std::uint8_t epoch = 0; + std::optional resetTime; // a new epoch's first batch: when it began + std::uint32_t first = 0; // record index of patches[0] + std::vector patches; + std::uint32_t parts = 0; // every part since the reset + std::optional partsTime; + std::vector impacts; + bool empty() const { return !resetTime && patches.empty() && !partsTime && impacts.empty(); } + }; + Batch& open() { return m_batches.back(); } + + std::uint16_t m_subject; + Options m_options; + std::uint8_t m_epoch = 0; + std::uint32_t m_count = 0; // patches this epoch + std::uint32_t m_parts = 0; + std::deque m_batches; // the last one is open + std::uint64_t m_lastSent = 0; + bool m_sentAny = false; + double m_tokens = -1.0; + std::uint64_t m_tokensAt = 0; +}; + +class DamageReplica { +public: + // A car's key: a player's own car, or a shared police car (ambient id). + static std::uint32_t playerKey(std::uint8_t player) { return 0x10000u | player; } + static std::uint32_t ambientKey(std::uint16_t id) { return id; } + + // An event from player `from` (untrusted: checked here; the payload was + // decoded within the protocol's bounds), received at session time + // `arrival`. A police car's damage is taken from the host only. Returns + // false when refused. + bool receive(std::uint8_t from, const net::VehicleDamageEvent& event, double arrival); + + struct Patch { + Vec3 point; + std::uint32_t seed = 0; + }; + struct Actions { + bool clear = false; // the car's damage was cleared: start from a clean car + std::vector patches; + std::uint32_t partsOff = 0; // net::VehicleDamageEvent::parts bits + bool partsFresh = false; // thrown off as they break (else just gone) + std::vector impacts; + bool empty() const { return !clear && patches.empty() && partsOff == 0 && impacts.empty(); } + }; + // What became due for a car drawn at session time `sampleTime` (the time + // its owner's machine was at in what is drawn), at session time `now`. + // An entry is due once the car is drawn at its time, or after + // kMaxHoldMs whatever the time says; its sparks, shards, sound and + // flying parts only while it is fresh (shown within kFreshMs of its + // time). + Actions advance(std::uint32_t key, double sampleTime, double now); + // The records of cars not drawn this frame (not advanced since the last + // call) take their entries after kMaxHoldMs, so a car that comes into + // view later shows them (replay()). + void settle(double now); + // A car shown afresh (created, or reset by its drawing): clear, then + // every patch and part of its record so far. + Actions replay(std::uint32_t key) const; + void forget(std::uint32_t key) { m_records.erase(key); } + void clear() { m_records.clear(); } + + static constexpr double kMaxHoldMs = 2000.0; + static constexpr double kFreshMs = 500.0; + static constexpr std::size_t kMaxRecords = 96; + // Events waiting for their time, per car: beyond it the oldest go into + // the record at once (and the car replays its record when next drawn). + static constexpr std::size_t kMaxPending = 64; + + struct Stats { + std::uint64_t events = 0; + std::uint64_t refused = 0; + std::uint64_t patches = 0; + std::uint64_t missed = 0; // patches a gap in the record indices skipped + }; + const Stats& stats() const { return m_stats; } + std::size_t records() const { return m_records.size(); } + // The patches a car's record holds (tests, diagnostics). + std::size_t recordSize(std::uint32_t key) const; + +private: + struct Pending { + net::VehicleDamageEvent event; + double arrival = 0.0; + std::size_t nextPatch = 0, nextImpact = 0; + bool started = false, partsDone = false, overdue = false; + }; + struct Record { + bool any = false; + std::uint8_t epoch = 0; + std::vector patches; // applied, in record order + std::uint32_t next = 0; // record index after the newest applied patch + std::uint32_t parts = 0; + std::deque pending; + bool touched = false; // advanced since the last settle() + bool replay = false; // entries went in without actions: the next advance() replays all + }; + void process(Record& record, double sampleTime, double now, Actions* out); + + std::map m_records; + Stats m_stats; +}; + +// What applyDamage acts on: a network car as this frame draws it. +struct DamageTarget { + VehicleRenderer* renderer = nullptr; + fx::VehicleEffects* effects = nullptr; // sparks and shards (may be null) + Mat34 body; // the car's model matrix as drawn + float speed = 0.0f; // m/s (the parts' throw) + float texelRadius = 0.4f; // vehCarDamage's TextelDamageRadius + // Takes a part off thrown as a banger at `speed` (vehBreakableMgr::Eject, + // VehicleRenderer::detach with the banger); false when it cannot (no + // banger data): applyDamage then just takes it off, as the owner's was. + std::function eject; + // AudImpact::Play for a replayed impact: world position, strength, id. + std::function sound; +}; +void applyDamage(const DamageReplica::Actions& actions, DamageTarget& target); + +class NetGame; +struct NetGameEvent; + +// A network race's damage replication as the race screen runs it: this +// player's car's recorder, the host's police cars' recorders, the received +// records. +class NetDamage { +public: + // This player's car. + 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); + DamageReplica& replica() { return m_replica; } + + // Sends what the recorders have (reliable game events to everyone). + void send(NetGame& net, std::uint64_t nowMs); + // The frame's game events: the damage ones go into the records. + void receive(std::span events, double now); + // A drawn car's damage this frame: its record replayed first when it is + // shown afresh, then what became due by `sampleTime`. + void update(std::uint32_t key, DamageTarget& target, double sampleTime, double now, bool fresh); + // After the drawn cars: the records of the others (DamageReplica::settle). + void settle(double now) { m_replica.settle(now); } + + struct Stats { + std::uint64_t sentEvents = 0, sentBytes = 0; + std::uint64_t receivedEvents = 0, receivedBytes = 0; + }; + const Stats& stats() const { return m_stats; } + // Logs and clears the counts (every 10 s while anything was sent or + // received; with `verbose` every event too). + void logStats(std::uint64_t nowMs); + void setVerbose(bool verbose) { m_verbose = verbose; } + +private: + DamageRecorder m_own; + std::map m_police; + DamageReplica m_replica; + Stats m_stats; + std::uint64_t m_statsAt = 0; + bool m_verbose = false; +}; + +} // namespace mm2::game diff --git a/src/game/net/NetGame.cpp b/src/game/net/NetGame.cpp index cfa0de8..4f580d9 100644 --- a/src/game/net/NetGame.cpp +++ b/src/game/net/NetGame.cpp @@ -3,6 +3,7 @@ #include "core/Log.h" #include "game/Catalog.h" #include "core/StringUtil.h" +#include "game/net/RaceStart.h" #include "net/PortMapper.h" #include "net/Session.h" @@ -10,6 +11,7 @@ #include #include #include +#include #include namespace mm2::game { @@ -168,8 +170,10 @@ struct NetGame::Impl { std::unique_ptr trace{nullptr, &std::fclose}; // OPENMM2_NET_TRACE // A new session: the trace (when asked for) follows its snapshots. - void newSession() { - session = std::make_unique(); + void newSession(const NetOptions& options) { + net::SessionConfig config; + config.loadWaitMs = options.loadWaitMs; + session = std::make_unique(config); const char* path = std::getenv("OPENMM2_NET_TRACE"); if (!path || !*path) return; @@ -220,7 +224,7 @@ bool NetGame::host(const RaceConfig& config, const NetHostOptions& hostOptions, params.advertiseOnLan = hostOptions.advertiseOnLan; params.discoveryPort = m_options.discoveryPort; - impl.newSession(); + impl.newSession(m_options); if (!impl.session->host(params, error)) { impl.session.reset(); return false; @@ -255,7 +259,7 @@ bool NetGame::join(const net::Address& address, const std::string& password, con params.password = password; params.player = {m_options.playerName, car.vehicle, static_cast(car.color), static_cast(car.team)}; - m_impl->newSession(); + m_impl->newSession(m_options); if (!m_impl->session->join(params, error)) { m_impl->session.reset(); return false; @@ -276,7 +280,7 @@ void NetGame::leave() { } m_impl->joining = false; m_raceStartPending = m_raceStarted = false; - m_raceNumber = m_lobbyAfterRace = 0; + m_lobbyAfterRace = 0; m_gameEvents.clear(); } @@ -322,19 +326,22 @@ void NetGame::handleEvents() { : "has left")); // string 69 } else if constexpr (std::is_same_v) { addChatLine({ev.from, playerName(ev.from), ev.text, false}); - } else if constexpr (std::is_same_v) { - ++m_raceNumber; - log::info("netgame: race {} starts at session time {}", m_raceNumber, ev.startTime); + } else if constexpr (std::is_same_v) { + log::info("netgame: race {} ordered at session time {}: loading", ev.race, ev.orderTime); m_raceStartPending = true; m_raceStarted = false; // Whatever is still queued belongs to an earlier race // (late events, or a cruise that reads none). m_gameEvents.clear(); + } else if constexpr (std::is_same_v) { + log::info("netgame: {} has loaded race {}", playerName(ev.id), ev.race); + } else if constexpr (std::is_same_v) { + log::info("netgame: race {} starts at session time {}", ev.race, ev.startTime); } else if constexpr (std::is_same_v) { m_raceStarted = true; } else if constexpr (std::is_same_v) { - log::info("netgame: back to the lobby after race {}", m_raceNumber); - m_lobbyAfterRace = m_raceNumber; + log::info("netgame: back to the lobby after race {}", ev.race); + m_lobbyAfterRace = ev.race; m_raceStartPending = false; m_raceStarted = false; addSystemLine("**Session returning to Lobby**"); // string 73 @@ -570,9 +577,15 @@ bool NetGame::everyoneReady(bool allowAlone) const { return others > 0 || allowAlone; } -void NetGame::startRace(std::uint32_t delayMs) { - if (isHost() && phase() == Phase::Lobby) - m_impl->session->startCountdown(delayMs); +void NetGame::startRace() { + if (!isHost() || phase() != Phase::Lobby) + return; + // The start the host sends ends the mode's countdown (Ready... Set... + // in the races, none in Cops and Robbers and cruise). + const auto kind = NetRaceStart::kindOf(fromSessionSettings(settings()).mode); + const auto countdownMs = + static_cast(NetRaceStart::countdownSeconds(kind) * 1000.0f + 0.5f); + m_impl->session->startRace(countdownMs); } void NetGame::returnToLobby() { @@ -621,12 +634,39 @@ bool NetGame::takeRaceStart() { return std::exchange(m_raceStartPending, false); std::uint32_t NetGame::sessionTime() const { return m_impl->session ? m_impl->session->time() : 0; } -std::uint32_t NetGame::raceStartTime() const { return m_impl->session ? m_impl->session->countdownEnd() : 0; } +std::uint32_t NetGame::raceNumber() const { return m_impl->session ? m_impl->session->raceNumber() : 0; } + +std::uint32_t NetGame::raceOrderTime() const { + return m_impl->session ? m_impl->session->raceOrderTime() : 0; +} + +void NetGame::reportLoaded() { + if (m_impl->session) + m_impl->session->reportLoaded(); +} + +bool NetGame::playerLoaded(std::uint8_t playerId) const { + return m_impl->session && m_impl->session->playerLoaded(playerId); +} + +int NetGame::playersLoading() const { + int n = 0; + for (const auto& p : players()) + if (p.id != localId() && !playerLoaded(p.id)) + ++n; + return n; +} + +bool NetGame::raceStartKnown() const { return m_impl->session && m_impl->session->raceStartKnown(); } + +std::uint32_t NetGame::raceStartTime() const { + return m_impl->session ? m_impl->session->raceStartTime() : 0; +} double NetGame::secondsToStart() const { - if (!m_impl->session) - return 0.0; - return (static_cast(raceStartTime()) - static_cast(sessionTime())) / 1000.0; + if (!raceStartKnown()) + return std::numeric_limits::infinity(); + return (static_cast(raceStartTime()) - m_frameTime) / 1000.0; } bool NetGame::raceStarted() const { return m_raceStarted; } @@ -660,6 +700,10 @@ std::vector NetGame::remoteCars(double stateAgeMs) const { car.id = p.id; car.name = p.name; car.car = raceCar(race, {p.car, p.color, p.team}); + if (!playerLoaded(p.id)) { + out.push_back(std::move(car)); + 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); @@ -734,7 +778,13 @@ void NetGame::sendDamage(float damage, std::uint8_t source) { net::encodePayload(net::DamageEvent{damage, source})); } -void NetGame::sendLeftRace() { sendEvent(static_cast(net::GameEventType::LeftRace), {}); } +void NetGame::sendLeftRace() { + // A player who leaves the race before reporting it loaded no longer + // holds the others' start (MM2: SystemMessage 0x2d takes one off the + // count in state 0). + reportLoaded(); + sendEvent(static_cast(net::GameEventType::LeftRace), {}); +} std::vector NetGame::takeGameEvents() { return std::exchange(m_gameEvents, {}); } diff --git a/src/game/net/NetGame.h b/src/game/net/NetGame.h index 1372483..37af7af 100644 --- a/src/game/net/NetGame.h +++ b/src/game/net/NetGame.h @@ -67,6 +67,9 @@ struct NetOptions { bool portMapping = true; // forward the port on the router (UPnP, PCP, NAT-PMP) std::filesystem::path portMapState; // crash-safe cleanup record of the mapping std::uint16_t discoveryPort = net::kDefaultDiscoveryPort; // LAN browser port (tests use others) + // Hosting: how long a race's start waits for players still loading + // (net::SessionConfig::loadWaitMs, the same default; tests use less). + std::uint32_t loadWaitMs = 60000; }; struct NetHostOptions { @@ -142,7 +145,7 @@ class NetGame { Idle, // no session Connecting, // join in progress Lobby, - Countdown, // race starting: load the race now; it starts at raceStartTime() + Countdown, // race starting: load it, report it loaded; it starts at raceStartTime() Racing, Closed, // ended (see takeNotice()) }; @@ -219,9 +222,10 @@ class NetGame { // Everyone except the host has pressed READY (and there is at least one // other player, unless `allowAlone`). bool everyoneReady(bool allowAlone = true) const; - // Starts the countdown: every machine loads the race and starts it at - // raceStartTime(). `delayMs` must cover loading the city. - void startRace(std::uint32_t delayMs = 6000); + // GO DRIVE: every machine loads the race and reports it loaded + // (reportLoaded); the host then sends its start (raceStartTime()), with + // the mode's countdown (NetRaceStart) before it. + void startRace(); // Ends the race for everyone and returns to the lobby. void returnToLobby(); @@ -229,11 +233,24 @@ class NetGame { std::string portMappingStatus() const; // --- Race ------------------------------------------------------------------------- - // True once when a countdown starts (the frontend switches to the race). + // True once when a race is ordered (the frontend switches to the race). bool takeRaceStart(); - // The races this NetGame has seen start, counted from 1 (0 before the - // first). The race screen keeps the number of the race it runs. - std::uint32_t raceNumber() const { return m_raceNumber; } + // The host's number of the current (or last) race, from 1 (0 before the + // first): the same on every machine. The race screen keeps the number + // of the race it runs. + std::uint32_t raceNumber() const; + // Session time at which the host ordered the race (GO DRIVE): every + // machine knows it from the moment it starts loading. + std::uint32_t raceOrderTime() const; + // This machine has loaded the current race (MM2's RaceReady, + // NetRaceStart): the host starts it once every player still in the + // session has, or after net::SessionConfig::loadWaitMs without the ones + // still loading. + void reportLoaded(); + // Whether a player (this one included) has reported the current race + // loaded; the other players still in the session who have not. + bool playerLoaded(std::uint8_t playerId) const; + int playersLoading() const; // Whether the host has taken everyone back to the lobby since race // `number` started. A return that arrives while the player is in the // menus (the host's own return, or one after the player quit the race @@ -241,8 +258,12 @@ class NetGame { bool backToLobby(std::uint32_t number) const { return number != 0 && m_lobbyAfterRace >= number; } // Session clock (ms; the host's clock, estimated on clients). std::uint32_t sessionTime() const; + // Whether the host has sent the race's start, and its session time (the + // end of its countdown, when the cars go; 0 until known). + bool raceStartKnown() const; std::uint32_t raceStartTime() const; - // Seconds until the race starts (negative once it has started). + // Seconds from this frame (frameTime()) until the race starts: negative + // once it has started, infinite until the start is known. double secondsToStart() const; bool raceStarted() const; @@ -256,7 +277,10 @@ class NetGame { double stateAgeMs = 0.0); // Every other player in the session, sampled at this frame's session // time less `stateAgeMs` (so they move with a simulation that is behind - // the frame) and less each one's playout delay. + // the frame) and less each one's playout delay. A player who has not + // reported the race loaded has no state yet (MM2 activates a network + // car when its RaceReady or SendGameSet arrives: mmGameMulti:: + // GameMessageCB 0x1f6, 0x1fa). std::vector remoteCars(double stateAgeMs = 0.0) const; // The session time update() last saw (ms). double frameTime() const { return m_frameTime; } @@ -288,7 +312,8 @@ class NetGame { void sendCollision(std::uint8_t otherPlayer, const Vec3& position, float impulse); void sendDamage(float damage, std::uint8_t source); // The local player quits the race the others go on driving (MM2's - // player left the session: mmGameMulti::QuitNetwork). + // player left the session: mmGameMulti::QuitNetwork). It no longer holds + // the others' start either (SystemMessage 0x2d in state 0). void sendLeftRace(); void sendEvent(std::uint16_t type, std::vector payload, std::uint8_t target = net::kBroadcastTarget); @@ -311,7 +336,6 @@ class NetGame { std::deque m_notices; std::vector m_gameEvents; bool m_raceStartPending = false; - std::uint32_t m_raceNumber = 0; // countdowns seen std::uint32_t m_lobbyAfterRace = 0; // the race the last return to the lobby ended bool m_raceStarted = false; bool m_closed = false; diff --git a/src/game/net/RaceStart.cpp b/src/game/net/RaceStart.cpp new file mode 100644 index 0000000..1df258f --- /dev/null +++ b/src/game/net/RaceStart.cpp @@ -0,0 +1,108 @@ +#include "game/net/RaceStart.h" + +#include "core/Log.h" +#include "game/Strings.h" +#include "game/net/NetGame.h" + +#include +#include + +namespace mm2::game { + +NetRaceStart::Kind NetRaceStart::kindOf(GameMode mode) { + switch (mode) { + case GameMode::Cruise: return Kind::Cruise; + case GameMode::CopsAndRobbers: return Kind::CopsAndRobbers; + default: return Kind::Race; + } +} + +float NetRaceStart::countdownSeconds(Kind kind) { return kind == Kind::Race ? 2.5f : 0.0f; } + +NetRaceStart::Output NetRaceStart::update(float dt, const Input& in) { + Output out; + if (m_kind == Kind::Cruise) { + // mmMultiRoam::Init reports at the end of the load; UpdateGame says + // "Go!" and lets the car go at once (Session::start). + out.report = !std::exchange(m_reported, true); + out.held = false; + return out; + } + if (!m_reported) { + // State 0 counts 5 s of frames, then sends RaceReady on the next one. + // A start that comes meanwhile goes first: 0x20f in state 0 starts + // the countdown at once (and MM2 never sends that RaceReady; OpenMM2 + // still reports, so the others show the car). + if (m_kind == Kind::Race && !in.startKnown && m_settle <= kSettleSeconds) { + m_settle += dt; + return out; + } + m_reported = true; + out.report = true; + } + if (!in.startKnown) { + out.waitingFor = in.othersLoading; + return out; + } + const float countdown = countdownSeconds(m_kind); + if (!m_startSeen) { + m_startSeen = true; + // Loaded after the start (the host stopped waiting for this + // machine): the whole countdown from now, on its own. + if (in.secondsToStart <= 0.0) { + m_own = true; + m_ownLeft = countdown; + } + } + float toGo = static_cast(in.secondsToStart); + if (m_own) { + toGo = m_ownLeft; + m_ownLeft -= dt; + } + out.secondsToGo = toGo; + if (!m_began && toGo <= countdown) { + m_began = true; + out.countdownBegan = true; + } + if (toGo <= 0.0f) { + out.held = false; + out.went = !std::exchange(m_gone, true); + } + return out; +} + +NetRaceStart::Output NetRaceStart::update(float dt, NetGame& net) { + Input in; + in.startKnown = net.raceStartKnown(); + in.secondsToStart = in.startKnown ? net.secondsToStart() : 0.0; + in.othersLoading = net.playersLoading(); + Output out = update(dt, in); + const std::uint32_t race = net.raceNumber(); + if (out.report) { + net.reportLoaded(); + log::info("race {}: loaded, reported at session time {:.0f}", race, net.frameTime()); + } + if (out.countdownBegan) + log::info("race {}: countdown from session time {:.0f}{}", race, net.frameTime(), + m_own ? " (on its own: loaded after the start)" : ""); + if (out.went) + log::info("race {}: GO at session time {:.0f} (the start: {})", race, net.frameTime(), + net.raceStartTime()); + // mmGameMulti::GameMessageCB 0x1fa: "" / "has joined" as each other + // machine's cruise begins (mmMultiRoam::Init's SendGameSet). + if (m_kind == Kind::Cruise) + for (const auto& p : net.players()) + if (p.id != net.localId() && net.playerLoaded(p.id) && m_joined.insert(p.id).second) + out.joined.push_back(p.id); + return out; +} + +std::string NetRaceStart::waitingText(const Strings& strings, int players) { + const std::string fallback = + players == 1 ? std::string("Waiting for 1 player") : std::format("Waiting for {} players", players); + if (players >= 1 && players <= 7) + return strings.get(30u + static_cast(players), fallback); + return fallback; +} + +} // namespace mm2::game diff --git a/src/game/net/RaceStart.h b/src/game/net/RaceStart.h new file mode 100644 index 0000000..96a5fd0 --- /dev/null +++ b/src/game/net/RaceStart.h @@ -0,0 +1,106 @@ +#pragma once + +// The start of a network race on one machine, from the moment its race has +// loaded until its cars go: MM2's mmMultiRace / mmMultiCircuit / +// mmMultiBlitz::UpdateGame state 0 (and mmMultiRoam::Init, mmMultiCR::Init) +// on OpenMM2's shared start time (net::Session; docs/multiplayer.md, "Race +// start"). +// +// MM2's race modes sit in state 0 after loading with every car held +// (InitNetworkPlayers: vehCar::SetDrivable(0, 1)) and nothing shown. After 5 s +// of frames the machine sends RaceReady (0x1f6, SendRaceReady) to everyone; +// from then on, while some other player's RaceReady has not arrived, it shows +// "Waiting for N players" (strings 31-37). The host sends the start (0x20f) +// once every player has; each machine then counts Ready... (1.25 s), Set... +// (1.25 s) and Go!, when the cars go and the race timers start. OpenMM2's +// host sends the session time of the Go instead, so every countdown ends +// together, and stops waiting for a player still loading after +// net::SessionConfig::loadWaitMs: that machine, loaded after the start, +// counts down on its own and joins the running race, as an MM2 machine does +// with a start message that waited in its queue. + +#include "game/RaceConfig.h" + +#include +#include +#include +#include +#include + +namespace mm2::game { + +class NetGame; +class Strings; + +class NetRaceStart { +public: + enum class Kind { + // Blitz, circuit and checkpoint races (mmMultiBlitz / mmMultiCircuit / + // mmMultiRace): RaceReady after 5 s, "Waiting for N players", then + // Ready... / Set... / Go! up to the start. + Race, + // Cops and Robbers: mmMultiCR::Init reports at once (0x25c) and the + // game has no countdown ("Go!"). MM2's host starts without waiting + // and each joiner when the host's game state reaches it; OpenMM2 waits + // for everyone as in the races, since its rules and limits run on + // the shared start's clock (deviation). + CopsAndRobbers, + // Cruise: mmMultiRoam::Init reports at once (PlayerFinishedLoading, + // SendGameSet 0x1fa) and lets the car go: nobody waits. + Cruise, + }; + static Kind kindOf(GameMode mode); + // The seconds of frames state 0 counts before RaceReady (+0x408 against + // 5.0). + static constexpr float kSettleSeconds = 5.0f; + // From the start message to "Go!": states 1 and 2, 1.25 s each. Cops and + // Robbers and cruise have none. + static float countdownSeconds(Kind kind); + + struct Input { + bool startKnown = false; // the host's start has arrived + double secondsToStart = 0.0; // until the shared start (the Go), when known + int othersLoading = 0; // other players in the session who have not reported + }; + struct Output { + bool report = false; // report the race loaded now + std::optional secondsToGo; // for Session::setNetStart: none until the start + int waitingFor = 0; // > 0: "Waiting for N players" + bool held = true; // the car may not move yet + bool countdownBegan = false; // this frame: Ready... (for the log) + bool went = false; // this frame: Go! + std::vector joined; // cruise: other players whose report has just come + }; + + explicit NetRaceStart(Kind kind) : m_kind(kind) {} + + // One frame of `dt` seconds. + Output update(float dt, const Input& in); + // The same with the network's state; reports the race loaded through it + // and logs the countdown's start and the Go in session time. + Output update(float dt, NetGame& net); + + Kind kind() const { return m_kind; } + bool reported() const { return m_reported; } + // Loaded after the start: counting down on its own. + bool ownCountdown() const { return m_own; } + bool gone() const { return m_gone; } + + // "Waiting for 1 player" ... "Waiting for 7 players": strings 31-37 + // (mmGameMulti::InitGameStrings joins them with '|', UpdateGame shows the + // N-th with string::SubString). OpenMM2's sessions can hold more. + static std::string waitingText(const Strings& strings, int players); + +private: + Kind m_kind; + float m_settle = 0.0f; + bool m_reported = false; + bool m_startSeen = false; + bool m_own = false; + float m_ownLeft = 0.0f; + bool m_began = false; + bool m_gone = false; + std::set m_joined; +}; + +} // namespace mm2::game diff --git a/src/game/net/TrafficSync.cpp b/src/game/net/TrafficSync.cpp index e8f076e..ec2aef2 100644 --- a/src/game/net/TrafficSync.cpp +++ b/src/game/net/TrafficSync.cpp @@ -33,6 +33,9 @@ net::AmbientEntity toEntity(const SharedCar& c) { e.velocity = c.velocity; e.angularVelocity = c.angularVelocity; e.flags = c.flags; + e.wheels = c.wheels && c.kind == net::AmbientKind::Traffic && (c.flags & net::kAmbientOffRail) != 0; + if (e.wheels) + e.wheelOffsets = c.wheelOffsets; e.target = c.target; e.damage = c.damage; e.rpm = c.rpm; @@ -168,6 +171,7 @@ void TrafficClient::restart(Entry& entry, const net::AmbientEntity& e, std::uint entry.model = e.model; entry.paint = e.paint; entry.buffer.clear(); + entry.wheels.clear(); entry.firstTime = time; entry.lastTime = time; entry.goneAt.reset(); @@ -242,6 +246,19 @@ void TrafficClient::receive(const net::AmbientStateMsg& msg) { s.damage = e.damage; s.flags = e.flags; entry.buffer.push(s); + // A knocked car's wheels, kept in time order (an older message's + // are not inserted: the newer ones say more). + if (entry.wheels.empty() || later(msg.time, entry.wheels.back().time) > 0) { + WheelSample w; + w.time = msg.time; + w.wheels = e.wheels && + std::ranges::all_of(e.wheelOffsets, [](const Vec3& v) { return finite(v); }); + if (w.wheels) + w.offsets = e.wheelOffsets; + entry.wheels.push_back(w); + if (entry.wheels.size() > 16) + entry.wheels.pop_front(); + } if (later(msg.time, entry.lastTime) >= 0) { entry.lastTime = msg.time; entry.target = e.target; @@ -299,6 +316,29 @@ void TrafficClient::update(double renderTime) { c.rpm = entry.rpm; c.throttle = s.controls.throttle; c.gear = s.controls.gear; + // The wheels at renderTime: from the newest message at or before it, + // blended toward the next one when that has them too. + if (!entry.wheels.empty()) { + std::size_t i = 0; + while (i + 1 < entry.wheels.size() && static_cast(entry.wheels[i + 1].time) <= renderTime) + ++i; + const WheelSample& a = entry.wheels[i]; + if (a.wheels) { + c.wheels = true; + c.wheelOffsets = a.offsets; + if (i + 1 < entry.wheels.size() && entry.wheels[i + 1].wheels && + static_cast(a.time) <= renderTime) { + const WheelSample& b = entry.wheels[i + 1]; + const auto span = static_cast(later(b.time, a.time)); + const double into = renderTime - static_cast(a.time); + const float t = span > 0.0 ? static_cast(std::clamp(into / span, 0.0, 1.0)) : 0.0f; + for (std::size_t k = 0; k < c.wheelOffsets.size(); ++k) + c.wheelOffsets[k] = a.offsets[k] + (b.offsets[k] - a.offsets[k]) * t; + } + } + for (; i > 0; --i) + entry.wheels.pop_front(); // older than the one in use + } c.extrapolated = result != net::SnapshotBuffer::Result::Interpolated; const bool horn = (s.flags & net::kAmbientHorn) != 0; c.hornStarted = horn && !entry.horn; @@ -306,7 +346,7 @@ void TrafficClient::update(double renderTime) { c.fresh = !entry.shown; entry.shown = true; m_cars.push_back(c); - entry.buffer.prune(renderTime); + entry.buffer.prune(renderTime - kDrawBehindMs); } } @@ -332,6 +372,10 @@ 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{}; + if (body && body->wheels) { + s.wheels = true; + s.wheelOffsets = *body->wheels; + } std::uint8_t f = 0; if (car.braking) f |= net::kAmbientBrake; @@ -386,6 +430,42 @@ ai::AmbientCar ambientCarOf(const TrafficClient::Car& car, const std::string& mo return a; } +std::array trafficWheelOffsets(const Mat34& transform, const std::array& wheels, + const ai::VehicleData& data) { + std::array out{}; + for (std::size_t i = 0; i < out.size(); ++i) + out[i] = transform.untransform(wheels[i].m3) - data.wheels[i]; + return out; +} + +void trafficWheelMatrices(const Mat34& transform, const std::array& offsets, + const ai::VehicleData& data, std::array& matrices, + std::array& valid) { + valid = {}; + for (std::size_t i = 0; i < offsets.size(); ++i) { + matrices[i] = Mat34::mul(Mat34::translation(data.wheels[i] + offsets[i]), transform); + valid[i] = true; + } + for (std::size_t i = 4; i < 6; ++i) { + if (data.wheelCount <= static_cast(i)) + continue; + const Vec3& p = data.wheels[i]; + const float drawn = data.wheels[i - 2].y + offsets[i - 2].y; + matrices[i] = Mat34::mul(Mat34::translation({p.x, (drawn - data.wheelRadius) + p.y, p.z}), transform); + valid[i] = true; + } +} + +std::optional TrafficClient::transformAt(int id, double time) const { + const auto it = m_entries.find(id); + if (it == m_entries.end()) + return std::nullopt; + net::VehicleSnapshot s; + if (it->second.buffer.sample(time, s, m_options.maxExtrapolationMs) == net::SnapshotBuffer::Result::Empty) + return std::nullopt; + return s.orientation.normalized().toMatrix(s.position); +} + std::optional TrafficClient::lightSteps(double renderTime) const { if (!m_any) return std::nullopt; diff --git a/src/game/net/TrafficSync.h b/src/game/net/TrafficSync.h index a1b7bf4..4973352 100644 --- a/src/game/net/TrafficSync.h +++ b/src/game/net/TrafficSync.h @@ -22,8 +22,10 @@ #include "net/AmbientState.h" #include "net/Snapshot.h" +#include #include #include +#include #include #include #include @@ -65,6 +67,10 @@ struct SharedCar { Vec3 velocity; Vec3 angularVelocity; std::uint8_t flags = 0; // net::AmbientFlags + // Traffic with a physics body: WHL0-3's drawing offsets from their + // pivots (trafficWheelOffsets). + bool wheels = false; + std::array wheelOffsets{}; // Police. std::uint8_t target = net::kAmbientNoTarget; float damage = 0.0f; @@ -156,6 +162,10 @@ class TrafficClient { Vec3 angularVelocity; float speed = 0.0f; // along -m2 std::uint8_t flags = 0; // net::AmbientFlags + // A knocked car with a body on the host: its wheels' drawing offsets + // (interpolated between the messages, trafficWheelMatrices). + bool wheels = false; + std::array wheelOffsets{}; std::uint8_t target = net::kAmbientNoTarget; float damage = 0.0f; float rpm = 0.0f; @@ -168,6 +178,12 @@ class TrafficClient { const std::vector& cars() const { return m_cars; } // The host's traffic light steps at `renderTime`, once a message came. std::optional lightSteps(double renderTime) const; + // 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. + std::optional transformAt(int id, double time) const; + static constexpr double kDrawBehindMs = 100.0; // The host's catalog differs from this client's. bool catalogMismatch() const { return m_mismatch; } std::size_t known() const { return m_entries.size(); } @@ -184,12 +200,19 @@ class TrafficClient { const Options& options() const { return m_options; } private: + // A knocked car's wheels as one message gave them. + struct WheelSample { + std::uint32_t time = 0; + bool wheels = false; + std::array offsets{}; + }; struct Entry { int generation = 0; net::AmbientKind kind = net::AmbientKind::Traffic; int model = 0; int paint = 0; net::SnapshotBuffer buffer{32}; + std::deque wheels; // time order, newest last std::uint32_t firstTime = 0; // its first snapshot (hidden before it) std::uint32_t lastTime = 0; // its newest snapshot std::optional goneAt; // missing from a newer message: gone from this time on @@ -225,7 +248,24 @@ struct TrafficBodyState { Mat34 transform; Vec3 velocity; Vec3 angularVelocity; + // While the body is simulated (aiVehicleActive): WHL0-3's drawing + // offsets (trafficWheelOffsets). + std::optional> wheels; }; + +// Host: a knocked car's wheels as aiVehicleInstance::Draw draws them while it +// has a body (TrafficBodies::wheelsOf, world matrices), as offsets of WHL0-3 +// from their pivots (`data`'s) in the model space of `transform`, the matrix +// the car is shared with. +std::array trafficWheelOffsets(const Mat34& transform, const std::array& wheels, + const ai::VehicleData& data); +// Client: the wheels' world matrices from the offsets, for AiRenderer's +// physical cars: WHL0-3 unturned at their pivots plus the offsets, WHL4 and +// WHL5 at their pivots raised by WHL2's and WHL3's drawn height less the +// wheel radius (as TrafficBodies::wheelsOf places them). +void trafficWheelMatrices(const Mat34& transform, const std::array& offsets, + const ai::VehicleData& data, std::array& matrices, + std::array& valid); SharedCar shareTrafficCar(const ai::AmbientCar& car, int model, int paint, const TrafficBodyState* body, bool horn); diff --git a/src/game/session/Session.cpp b/src/game/session/Session.cpp index 5508166..4b95a85 100644 --- a/src/game/session/Session.cpp +++ b/src/game/session/Session.cpp @@ -278,7 +278,10 @@ void Session::beginEvent(int index) { m_wp.singleVisible = lesson && lesson->singleCheckpoint; resetWaypoints(); - m_phase = mode() == GameMode::Cruise || mode() == GameMode::CopsAndRobbers ? Phase::Racing : Phase::Countdown; + // A network Cops and Robbers game waits for its start (copsAndRobbersGo). + const bool noCountdown = + mode() == GameMode::Cruise || (mode() == GameMode::CopsAndRobbers && !multiplayer()); + m_phase = noCountdown ? Phase::Racing : Phase::Countdown; m_stage = Stage::Intro; m_wait = 0.0f; // A later exam event goes straight to "Go"; UpdateCollide and @@ -346,13 +349,9 @@ void Session::start() { return; } if (mode() == GameMode::CopsAndRobbers) { - // mmMultiCR::UpdateGame state 2: no countdown, "Go!" for 2 s at the - // top with its first sound ("Startracehigh"); the rules are - // CopsAndRobbers'. - m_phase = Phase::Racing; - setMessage(modeText(mode(), multiplayer()).go, "Go!", 2.0f, true); - sound(GameSound::StartRaceHigh); - m_released = true; + // A network game says "Go!" when it starts (updateCountdown). + if (!multiplayer()) + copsAndRobbersGo(); return; } if (m_options.skipCountdown) @@ -405,12 +404,21 @@ void Session::updateCountdown(float dt) { if (m_stage == Stage::Intro && m_preRaceCamera && !multiplayer() && (mode() == GameMode::Circuit || mode() == GameMode::Checkpoint)) return; - // mmMultiBlitz / mmMultiCircuit / mmMultiRace::UpdateGame state 0 waits - // for the host's start message. - if (m_stage == Stage::Intro && !m_startSignal) + // mmMultiCR::UpdateGame: the host goes to state 1 at once, a joiner when + // the host's game state arrives (0x25d), and state 2 lets the cars go. + // OpenMM2's machines go together at the shared start (NetRaceStart). + if (mode() == GameMode::CopsAndRobbers) { + if (m_startSignal && (!m_netToGo || *m_netToGo <= 0.0f)) + copsAndRobbersGo(); return; + } + // mmMultiBlitz / mmMultiCircuit / mmMultiRace::UpdateGame state 0 waits + // for the host's start message; on OpenMM2's shared start the countdown + // begins when Ready... and Set... are what is left of it. const LessonEvent* lesson = currentLesson(); ModeText mt = modeText(mode(), multiplayer()); + if (m_stage == Stage::Intro && (!m_startSignal || (m_netToGo && *m_netToGo > mt.total))) + return; LessonText lt; if (lesson) { lt = lessonText(lesson->type); @@ -442,7 +450,7 @@ void Session::updateCountdown(float dt) { sound(GameSound::StartRaceLow); return; case Stage::Ready: - m_wait -= dt; + m_wait = m_netToGo ? *m_netToGo : m_wait - dt; if (m_wait > kStep) { if (lesson && lesson->type == LessonType::MinimumSpeed) { std::string text = str(mt.ready, "Maintain %.0f through the checkpoints"); @@ -463,7 +471,7 @@ void Session::updateCountdown(float dt) { push(EventType::PlayerDamageLimits, -1, kCleanMaxDamage); return; case Stage::Set: { - m_wait -= dt; + m_wait = m_netToGo ? *m_netToGo : m_wait - dt; const bool corner = lesson && lesson->type == LessonType::MinimumSpeed; if (corner ? m_wait >= 0.0f : m_wait > 0.0f) { if (mt.set) @@ -476,6 +484,15 @@ void Session::updateCountdown(float dt) { } } +void Session::copsAndRobbersGo() { + // mmMultiCR::UpdateGame state 2: no countdown, "Go!" for 2 s at the top + // with its first sound ("Startracehigh"); the rules are CopsAndRobbers'. + m_phase = Phase::Racing; + setMessage(modeText(mode(), multiplayer()).go, "Go!", 2.0f, true); + sound(GameSound::StartRaceHigh); + m_released = true; +} + void Session::go() { m_phase = Phase::Racing; m_skipToGo = false; diff --git a/src/game/session/Session.h b/src/game/session/Session.h index 138d798..2367bcd 100644 --- a/src/game/session/Session.h +++ b/src/game/session/Session.h @@ -108,9 +108,20 @@ class Session { void setPreRaceCamera(bool active) { m_preRaceCamera = active; } // Multiplayer races: whether the host's start message has arrived (the // countdown of mmMultiBlitz / mmMultiCircuit / mmMultiRace::UpdateGame - // waits for it in state 0; OpenMM2 sends a shared start time instead and - // signals 2.5 s before it). Single player ignores it. - void setStartSignal(bool received) { m_startSignal = received || !multiplayer(); } + // waits for it in state 0 and then runs on its own clock; Cops and + // Robbers says "Go!" with it). Single player ignores it. + void setStartSignal(bool received) { + m_startSignal = received || !multiplayer(); + m_netToGo.reset(); + } + // The same on OpenMM2's shared start (game::NetRaceStart): none while + // the start has not come, then the seconds left until the start, which + // the countdown follows (Ready... while more than 1.25 s are left, Set... + // while any are, Go! at 0) so that every machine ends it together. + void setNetStart(std::optional secondsToGo) { + m_startSignal = secondsToGo.has_value() || !multiplayer(); + m_netToGo = secondsToGo; + } // `opponents` in the order of opponents(); `police` in the order of // police() (crash course chasers, cop chase lessons). // @@ -295,6 +306,7 @@ class Session { void updateCountdown(float dt); void enableLessonOpponents(); void go(); + void copsAndRobbersGo(); void updateRules(float dt, const PlayerState& player, std::span opponents, std::span police); void tickMessage(float dt); @@ -348,6 +360,7 @@ class Session { bool m_skipToGo = false; // later crash course events start at once bool m_preRaceCamera = false; bool m_startSignal = true; + std::optional m_netToGo; // setNetStart float m_wait = 0.0f; bool m_started = false; bool m_released = false; diff --git a/src/net/AmbientState.h b/src/net/AmbientState.h index 63bbd61..21c61fe 100644 --- a/src/net/AmbientState.h +++ b/src/net/AmbientState.h @@ -24,6 +24,7 @@ #include "net/Protocol.h" +#include #include #include @@ -41,6 +42,10 @@ inline constexpr float kAmbientSpeedRange = 64.0f; // rail cars, m/s inline constexpr float kAmbientVelocityRange = 96.0f; // off-rail cars and police, m/s inline constexpr float kAmbientSpinRange = 32.0f; // rad/s inline constexpr float kAmbientMaxRpm = 10000.0f; +// A knocked car's wheels: each drawing position's offset from its pivot, +// +-0.25 m across and along (the tyre's deflection), +-1 m up (the spring). +inline constexpr float kAmbientWheelAcross = 0.25f; +inline constexpr float kAmbientWheelTravel = 1.0f; // Police target: a player id, or none. inline constexpr std::uint8_t kAmbientNoTarget = kInvalidPlayerId; @@ -72,6 +77,12 @@ struct AmbientEntity { Vec3 velocity; // off-rail cars and police Vec3 angularVelocity; // off-rail cars and police std::uint8_t flags = 0; // AmbientFlags + // Traffic off its rail with a physics body (aiVehicleActive): WHL0-3 as + // aiVehicleInstance::Draw draws them, each its drawing position's offset + // from its pivot in the car's model space (vehWheelCheap: the spring's + // travel up, the tyre's deflection across and along). + bool wheels = false; + std::array wheelOffsets{}; // Police only. std::uint8_t target = kAmbientNoTarget; // the player it chases float damage = 0.0f; // 0..1 @@ -134,13 +145,29 @@ bool serializeAmbientEntity(S& s, AmbientEntity& e, const Vec3& origin) { e.angularVelocity = {}; } } + if (e.kind == AmbientKind::Traffic && (e.flags & kAmbientOffRail) != 0) { + s.boolean(e.wheels); + if (e.wheels) + for (Vec3& w : e.wheelOffsets) { + s.quantized(w.x, -kAmbientWheelAcross, kAmbientWheelAcross, 6); + s.quantized(w.y, -kAmbientWheelTravel, kAmbientWheelTravel, 8); + s.quantized(w.z, -kAmbientWheelAcross, kAmbientWheelAcross, 6); + } + } else if constexpr (S::kReading) { + e.wheels = false; + } + if constexpr (S::kReading) + if (!e.wheels) + e.wheelOffsets = {}; if (e.kind == AmbientKind::Police) { std::int32_t target = e.target == kAmbientNoTarget ? static_cast(kMaxPlayers) : std::min(e.target, kMaxPlayers); s.ranged(target, 0, static_cast(kMaxPlayers)); e.target = target == static_cast(kMaxPlayers) ? kAmbientNoTarget : static_cast(target); - s.quantized(e.damage, 0.0f, 1.0f, 6); + // As a player's car (VehicleSnapshot): the smoke's four levels and + // the tyre wobble come from it (vehCarDamage::Update). + s.quantized(e.damage, 0.0f, 1.0f, 10); s.quantized(e.rpm, 0.0f, kAmbientMaxRpm, 8); s.quantized(e.throttle, 0.0f, 1.0f, 4); std::int32_t gear = e.gear; diff --git a/src/net/Protocol.h b/src/net/Protocol.h index d9e93d6..8d0fbed 100644 --- a/src/net/Protocol.h +++ b/src/net/Protocol.h @@ -26,7 +26,11 @@ namespace mm2::net { inline constexpr std::uint16_t kProtocolMagic = 0x4D32; // "M2" // 2: the shared ambient traffic of multiplayer cruise (AmbientState, the // Ambient channel, SessionSettings::sharedTraffic). -inline constexpr std::uint16_t kProtocolVersion = 2; +// 3: the race start handshake (RaceLoad in place of Countdown, RaceLoaded, +// 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; inline constexpr std::uint32_t kConnectData = (std::uint32_t{kProtocolMagic} << 16) | kProtocolVersion; // ENet channels. @@ -77,7 +81,7 @@ enum class MsgType : std::uint8_t { PlayerRequest, Chat, Settings, - Countdown, + RaceLoad, // was Countdown (protocol 2): the start time now follows the loading ReturnToLobby, Kick, TimeRequest, @@ -87,7 +91,9 @@ enum class MsgType : std::uint8_t { GameEvent, PlayerPings, AmbientState, // net/AmbientState.h - Last = AmbientState, + RaceLoaded, + RaceStart, + Last = RaceStart, }; // Sent as ENet disconnect data and in Reject/PlayerLeft messages. @@ -112,6 +118,8 @@ const char* describe(DisconnectReason reason); enum class GameMode : std::uint8_t { Cruise, Checkpoint, Circuit, Blitz, CopsAndRobbers, CrashCourse, Last = CrashCourse }; enum class TimeOfDay : std::uint8_t { Morning, Noon, Evening, Night, Last = Night }; enum class Weather : std::uint8_t { Clear, Cloudy, Rain, Snow, Last = Snow }; +// Countdown: a race is loading and starting (from GO DRIVE until the start +// time the host sends once everyone has loaded). enum class SessionPhase : std::uint8_t { Lobby, Countdown, InGame, Last = InGame }; struct SessionSettings { @@ -177,7 +185,12 @@ struct WelcomeMsg { std::vector players; SessionPhase phase = SessionPhase::Lobby; std::uint32_t hostTime = 0; - std::uint32_t countdownEnd = 0; // valid when phase == Countdown + // The current race (RaceLoad's number and time; in the lobby the last). + std::uint32_t race = 0; + std::uint32_t raceOrderTime = 0; + bool startKnown = false; // the host has sent the race's start (RaceStart) + std::uint32_t startTime = 0; // valid when startKnown + std::vector loaded; // the players who have reported the race loaded }; struct RejectMsg { @@ -222,9 +235,30 @@ struct SettingsMsg { SessionSettings settings; }; -struct CountdownMsg { - static constexpr MsgType kType = MsgType::Countdown; - std::uint32_t startTime = 0; // session time at which the race starts +// Host -> clients: GO DRIVE. Every machine loads race `race` (the host +// numbers its races from 1) and reports it loaded (RaceLoaded); the start +// time follows (RaceStart). `orderTime` is the session time of the order. +struct RaceLoadMsg { + static constexpr MsgType kType = MsgType::RaceLoad; + std::uint32_t race = 0; + std::uint32_t orderTime = 0; +}; + +// Client -> host: this machine has loaded race `race` (mmGameMulti:: +// SendRaceReady, 0x1f6). Host -> clients: player `player` has (the host sets +// it when relaying). +struct RaceLoadedMsg { + static constexpr MsgType kType = MsgType::RaceLoaded; + std::uint32_t race = 0; + std::uint8_t player = kInvalidPlayerId; +}; + +// Host -> clients: race `race` starts (its countdown ends, the cars go) at +// session time `startTime` (MM2's start message, 0x20f, as a shared time). +struct RaceStartMsg { + static constexpr MsgType kType = MsgType::RaceStart; + std::uint32_t race = 0; + std::uint32_t startTime = 0; }; struct ReturnToLobbyMsg { @@ -360,7 +394,18 @@ bool serialize(S& s, WelcomeMsg& m) { serialize(s, p); s.enumeration(m.phase, SessionPhase::Last); s.u32(m.hostTime); - s.u32(m.countdownEnd); + s.u32(m.race); + s.u32(m.raceOrderTime); + s.boolean(m.startKnown); + s.u32(m.startTime); + auto loaded = static_cast(m.loaded.size()); + s.varU32(loaded); + if (loaded > kMaxPlayers) + return s.fail(); + if constexpr (S::kReading) + m.loaded.resize(loaded); + for (auto& id : m.loaded) + s.u8(id); return s.ok(); } @@ -410,7 +455,22 @@ bool serialize(S& s, SettingsMsg& m) { } template -bool serialize(S& s, CountdownMsg& m) { +bool serialize(S& s, RaceLoadMsg& m) { + s.u32(m.race); + s.u32(m.orderTime); + return s.ok(); +} + +template +bool serialize(S& s, RaceLoadedMsg& m) { + s.u32(m.race); + s.u8(m.player); + return s.ok(); +} + +template +bool serialize(S& s, RaceStartMsg& m) { + s.u32(m.race); s.u32(m.startTime); return s.ok(); } diff --git a/src/net/Session.cpp b/src/net/Session.cpp index ae69ebc..0a320d0 100644 --- a/src/net/Session.cpp +++ b/src/net/Session.cpp @@ -3,6 +3,7 @@ #include "core/Log.h" #include "core/StringUtil.h" #include "net/Sha256.h" +#include "net/VehicleDamage.h" #include #include @@ -67,6 +68,10 @@ bool upsertPlayer(std::vector& players, const PlayerInfo& p) { constexpr double kChatRate = 2.0, kChatBurst = 8.0; constexpr double kUpdateRate = 10.0, kUpdateBurst = 20.0; constexpr double kEventRate = 30.0, kEventBurst = 60.0; +// A car's damage events (net/VehicleDamage.h) have a budget of their own, so +// 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; // 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 @@ -210,7 +215,9 @@ void Session::resetState() { m_players.clear(); m_localId = kInvalidPlayerId; m_password.clear(); - m_countdownEnd = 0; + m_race = 0; + m_raceOrderTime = 0; + clearRace(); m_hostEpoch = 0; m_lastPingBroadcast = 0; m_passwordFailures.clear(); @@ -394,6 +401,8 @@ void Session::update() { if (snapshotDue() && replicating()) hostSendWorldState(); + if (m_phase == SessionPhase::Countdown && !m_startKnown) + hostCheckStart(); if (m_beacon) { hostAdvertise(); @@ -485,14 +494,75 @@ bool Session::receiveState(std::uint8_t id, const VehicleSnapshot& state) { } void Session::tickCountdown() { - if (m_phase != SessionPhase::Countdown || !clockSynced()) + if (m_phase != SessionPhase::Countdown || !m_startKnown || !clockSynced()) return; - if (static_cast(time()) - static_cast(m_countdownEnd) >= 0) { + if (static_cast(time()) - static_cast(m_startTime) >= 0) { m_phase = SessionPhase::InGame; emit(ev::GameStarted{}); } } +// --- The race start ------------------------------------------------------------- +// +// MM2's network races (mmMultiRace / mmMultiCircuit / mmMultiBlitz:: +// UpdateGame state 0): every machine sends RaceReady (0x1f6) once its race +// has loaded, every machine counts the others' (GameMessage 0x1f6 and 0x213, +// less one for each player who leaves meanwhile: SystemMessage 0x2d), and +// the host sends the start (0x20f) when its count reaches 0, at which every +// machine begins Ready / Set / Go. OpenMM2's host sends a shared start time +// instead, a lead ahead, so every countdown ends at the same moment. + +void Session::clearRace() { + m_startKnown = false; + m_startTime = 0; + m_countdownMs = 0; + m_loaded.clear(); +} + +bool Session::playerLoaded(std::uint8_t id) const { + return std::ranges::find(m_loaded, id) != m_loaded.end(); +} + +void Session::markLoaded(std::uint8_t id) { + m_loaded.push_back(id); + emit(ev::PlayerLoaded{id, m_race}); +} + +void Session::hostCheckStart() { + if (m_role != Role::Host || m_phase != SessionPhase::Countdown || m_startKnown || !m_transport) + return; + const bool everyone = + std::ranges::all_of(m_players, [&](const PlayerInfo& p) { return playerLoaded(p.id); }); + const std::uint32_t now = time(); + const bool waitedEnough = now - m_raceOrderTime >= m_config.loadWaitMs; + if (!everyone && !waitedEnough) + return; + if (!everyone) { + // OpenMM2's limit (MM2 has none): the race goes on without the + // players still loading, who join it when they have loaded. + std::string missing; + for (const auto& p : m_players) + if (!playerLoaded(p.id)) + missing += (missing.empty() ? "" : ", ") + p.name; + log::warn("net: race {} starts without {}: still loading {} ms after the order", m_race, missing, + now - m_raceOrderTime); + } + // The start message must reach every machine before its countdown: twice + // the slowest round trip (a loss and its resend) plus 100 ms. + std::uint32_t rtt = 0; + for (const auto& [peer, r] : m_remotes) + if (r.playerId != kInvalidPlayerId) + rtt = std::max(rtt, m_transport->stats(peer).rttMs); + const std::uint32_t lead = std::clamp(2 * std::min(rtt, 60000u) + 100, m_config.startLeadMinMs, + std::max(m_config.startLeadMinMs, m_config.startLeadMaxMs)); + m_startKnown = true; + m_startTime = now + lead + m_countdownMs; + log::info("net: race {} starts at session time {} (countdown from {}, lead {} ms)", m_race, m_startTime, + m_startTime - m_countdownMs, lead); + sendToPlayers(Channel::Control, RaceStartMsg{m_race, m_startTime}); + emit(ev::RaceStartSet{m_race, m_startTime}); +} + void Session::hostSendWorldState() { // The host's own car, on its snapshot cadence. if (!m_localState || m_localState->time == m_lastSentStateTime) @@ -688,11 +758,30 @@ void Session::hostHandle(Remote& r, MsgType type, std::span dat hostRelayState(id, msg.state); 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 + // player), a repeat, or one in the lobby is dropped. A report that + // comes after the start (a player the host stopped waiting for) no + // longer changes it, but the others still learn that the player is + // in the race. + RaceLoadedMsg msg; + if (!decodeMessage(data, msg) || m_phase == SessionPhase::Lobby || msg.race != m_race || + playerLoaded(id)) + return; + markLoaded(id); + sendToPlayers(Channel::Control, RaceLoadedMsg{m_race, id}, id); + hostCheckStart(); + return; + } case MsgType::GameEvent: { GameEventMsg msg; if (!decodeMessage(data, msg)) return; - if (!r.events.take(monotonicMs(), kEventRate, kEventBurst)) { + const bool damage = msg.type == kVehicleDamageEvent; + RateLimit& budget = damage ? r.damage : r.events; + if (!budget.take(monotonicMs(), damage ? kDamageEventRate : kEventRate, + damage ? kDamageEventBurst : kEventBurst)) { log::debug("net: dropped game event {} from player {}: too many events", msg.type, id); return; } @@ -729,7 +818,13 @@ void Session::hostAcceptHello(Remote& r, HelloMsg& hello) { welcome.players = m_players; welcome.phase = m_phase; welcome.hostTime = time(); - welcome.countdownEnd = m_countdownEnd; + welcome.race = m_race; // the last race in the lobby: the numbers go on from it + welcome.raceOrderTime = m_raceOrderTime; + if (m_phase != SessionPhase::Lobby) { + welcome.startKnown = m_startKnown; + welcome.startTime = m_startTime; + welcome.loaded = m_loaded; + } sendTo(r.peer, Channel::Control, std::move(welcome)); sendToPlayers(Channel::Control, PlayerJoinedMsg{p}, id); emit(ev::PlayerJoined{p}); @@ -757,6 +852,7 @@ void Session::hostRemovePlayer(std::uint8_t id, DisconnectReason reason) { PlayerInfo p = *it; m_players.erase(it); m_remoteStates.erase(id); + std::erase(m_loaded, id); // no longer waited for (update() checks the start) log::info("net: {} left ({})", p.name, describe(reason)); sendToPlayers(Channel::Control, PlayerLeftMsg{id, reason}); emit(ev::PlayerLeft{p, reason}); @@ -816,7 +912,17 @@ void Session::clientHandle(MsgType type, std::span data) { m_settings = std::move(w.settings); m_players = std::move(w.players); m_phase = w.phase; - m_countdownEnd = w.countdownEnd; + clearRace(); + m_race = w.race; + m_raceOrderTime = w.raceOrderTime; + if (m_phase != SessionPhase::Lobby) { + m_startKnown = w.startKnown; + m_startTime = w.startTime; + // The others who have loaded (this machine reports itself). + for (const std::uint8_t id : w.loaded) + if (id != m_localId && player(id) && !playerLoaded(id)) + m_loaded.push_back(id); + } m_state = State::Active; if (const PlayerInfo* self = player(m_localId)) m_request = PlayerRequestMsg{self->car, self->color, self->team, self->ready}; @@ -827,10 +933,13 @@ void Session::clientHandle(MsgType type, std::span data) { updateShownClock(); m_nextTimeRequest = now; emit(ev::JoinAccepted{m_localId}); - if (m_phase == SessionPhase::Countdown) - emit(ev::CountdownStarted{m_countdownEnd}); - else if (m_phase == SessionPhase::InGame) + if (m_phase == SessionPhase::Countdown) { + emit(ev::RaceLoading{m_race, m_raceOrderTime}); + if (m_startKnown) + emit(ev::RaceStartSet{m_race, m_startTime}); + } else if (m_phase == SessionPhase::InGame) { emit(ev::GameStarted{}); + } return; } case MsgType::Reject: { @@ -865,6 +974,7 @@ void Session::clientHandle(MsgType type, std::span data) { PlayerInfo p = *it; m_players.erase(it); m_remoteStates.erase(m.id); + std::erase(m_loaded, m.id); emit(ev::PlayerLeft{p, m.reason}); return; } @@ -895,14 +1005,36 @@ void Session::clientHandle(MsgType type, std::span data) { emit(ev::SettingsChanged{std::move(m.settings)}); return; } - case MsgType::Countdown: { - CountdownMsg m; - if (!decodeMessage(data, m)) + case MsgType::RaceLoad: { + // The host numbers its races upwards; anything else is not a new race. + RaceLoadMsg m; + if (!decodeMessage(data, m) || m.race <= m_race) return; m_phase = SessionPhase::Countdown; - m_countdownEnd = m.startTime; + m_race = m.race; + m_raceOrderTime = m.orderTime; + clearRace(); resetReplication(); - emit(ev::CountdownStarted{m.startTime}); + emit(ev::RaceLoading{m.race, m.orderTime}); + return; + } + case MsgType::RaceLoaded: { + RaceLoadedMsg m; + // The host tells of the others (its own report it sends itself). + if (!decodeMessage(data, m) || m_phase == SessionPhase::Lobby || m.race != m_race || + !player(m.player) || m.player == m_localId || playerLoaded(m.player)) + return; + markLoaded(m.player); + return; + } + case MsgType::RaceStart: { + // Once per race, while it is starting. + RaceStartMsg m; + if (!decodeMessage(data, m) || m_phase != SessionPhase::Countdown || m.race != m_race || m_startKnown) + return; + m_startKnown = true; + m_startTime = m.startTime; + emit(ev::RaceStartSet{m.race, m.startTime}); return; } case MsgType::ReturnToLobby: { @@ -913,8 +1045,9 @@ void Session::clientHandle(MsgType type, std::span data) { for (auto& p : m_players) p.ready = false; m_request.ready = false; + clearRace(); resetReplication(); - emit(ev::ReturnedToLobby{}); + emit(ev::ReturnedToLobby{m_race}); return; } case MsgType::Kick: { @@ -1063,14 +1196,30 @@ void Session::kick(std::uint8_t playerId, const std::string& reason) { hostRemovePlayer(playerId, DisconnectReason::Kicked); } -void Session::startCountdown(std::uint32_t delayMs) { +void Session::startRace(std::uint32_t countdownMs) { if (m_role != Role::Host || m_state != State::Active) return; m_phase = SessionPhase::Countdown; - m_countdownEnd = time() + delayMs; + ++m_race; + m_raceOrderTime = time(); + clearRace(); + m_countdownMs = countdownMs; resetReplication(); - sendToPlayers(Channel::Control, CountdownMsg{m_countdownEnd}); - emit(ev::CountdownStarted{m_countdownEnd}); + log::info("net: race {} ordered at session time {}", m_race, m_raceOrderTime); + sendToPlayers(Channel::Control, RaceLoadMsg{m_race, m_raceOrderTime}); + emit(ev::RaceLoading{m_race, m_raceOrderTime}); +} + +void Session::reportLoaded() { + if (m_state != State::Active || m_phase == SessionPhase::Lobby || m_race == 0 || playerLoaded(m_localId)) + return; + markLoaded(m_localId); + if (m_role == Role::Host) { + sendToPlayers(Channel::Control, RaceLoadedMsg{m_race, m_localId}); + hostCheckStart(); + } else { + sendTo(m_hostPeer, Channel::Control, RaceLoadedMsg{m_race, m_localId}); + } } void Session::returnToLobby() { @@ -1079,9 +1228,10 @@ void Session::returnToLobby() { m_phase = SessionPhase::Lobby; for (auto& p : m_players) p.ready = false; + clearRace(); resetReplication(); sendToPlayers(Channel::Control, ReturnToLobbyMsg{}); - emit(ev::ReturnedToLobby{}); + emit(ev::ReturnedToLobby{m_race}); } void Session::submitLocalState(const VehicleSnapshot& state) { submitLocalState(state, timeMs()); } diff --git a/src/net/Session.h b/src/net/Session.h index b3fec07..45b2e6a 100644 --- a/src/net/Session.h +++ b/src/net/Session.h @@ -66,6 +66,14 @@ struct SessionConfig { std::uint32_t joinTimeoutMs = 10000; // handshake must finish within this std::uint32_t connectTimeoutMs = 8000; // ENet connect attempt std::uint32_t pingBroadcastIntervalMs = 2000; + // The race start (host): how long after GO DRIVE the host waits for + // players still loading before it starts without them (OpenMM2's own + // limit: MM2 waits for every player still in the session), and the + // bounds of the lead it gives its start message to reach everyone + // (twice the slowest round trip plus 100 ms). + std::uint32_t loadWaitMs = 60000; + std::uint32_t startLeadMinMs = 200; + std::uint32_t startLeadMaxMs = 1000; std::string build = "OpenMM2"; // reported in Hello, informational }; @@ -100,11 +108,27 @@ struct Chat { struct SettingsChanged { SessionSettings settings; }; -struct CountdownStarted { +// GO DRIVE: load race `race` now (RaceLoad). +struct RaceLoading { + std::uint32_t race; + std::uint32_t orderTime; // session time of the order +}; +// A player (this one included) has loaded the current race. +struct PlayerLoaded { + std::uint8_t id; + std::uint32_t race; +}; +// The host has set the current race's start (its countdown's end). +struct RaceStartSet { + std::uint32_t race; std::uint32_t startTime; // session time }; +// The session clock has reached the start time. struct GameStarted {}; -struct ReturnedToLobby {}; +// The host took everyone back to the lobby, ending race `race`. +struct ReturnedToLobby { + std::uint32_t race; +}; struct GameEvent { std::uint8_t from; std::uint16_t type; @@ -113,9 +137,10 @@ struct GameEvent { }; } // namespace ev -using SessionEvent = std::variant; +using SessionEvent = + std::variant; class Session { public: @@ -151,7 +176,23 @@ class Session { std::uint32_t time() const; double timeMs() const; // the same with sub-millisecond precision bool clockSynced() const { return m_role == Role::Host || m_clock.synced(); } - std::uint32_t countdownEnd() const { return m_countdownEnd; } + + // --- The race (docs/multiplayer.md, "Race start") --- + // The host's number of the current (or last) race, from 1; 0 before the + // first. + std::uint32_t raceNumber() const { return m_race; } + // Session time at which the host ordered it (GO DRIVE). + std::uint32_t raceOrderTime() const { return m_raceOrderTime; } + // Whether the host has set its start yet, and the session time it + // starts at (its countdown ends: the cars go). + bool raceStartKnown() const { return m_startKnown; } + std::uint32_t raceStartTime() const { return m_startKnown ? m_startTime : 0; } + // Whether a player has reported the current race loaded. + bool playerLoaded(std::uint8_t id) const; + // This machine has loaded the current race (MM2's RaceReady): the host + // starts the race once every player still in the session has. + // Repeated calls, and calls outside a race, do nothing. + void reportLoaded(); // --- Lobby (both roles) --- void setLocalPlayer(const std::string& car, std::uint8_t color, std::uint8_t team); @@ -162,9 +203,13 @@ class Session { void updateSettings(const SessionSettings& settings); void setPassword(const std::string& password); void kick(std::uint8_t playerId, const std::string& reason); - // Starts the race `delayMs` from now; GameStarted fires on every machine - // when the session clock reaches the start time. - void startCountdown(std::uint32_t delayMs); + // GO DRIVE: every machine loads a new race and reports it loaded + // (reportLoaded). When every player still in the session has (or + // SessionConfig::loadWaitMs after the order, without the ones still + // loading), the host sets the start: a lead for the message to reach + // everyone plus `countdownMs` from then. GameStarted fires on every + // machine when the session clock reaches it. + void startRace(std::uint32_t countdownMs); void returnToLobby(); // Called for every game event before the host relays it (including the // host's own). Return false to drop it. @@ -222,7 +267,7 @@ class Session { std::uint8_t playerId = kInvalidPlayerId; // assigned after Hello std::array nonce{}; std::uint64_t connectedAt = 0; - RateLimit chat, updates, events; + RateLimit chat, updates, events, damage; bool updatePending = false; // a PlayerRequest applied but not yet relayed }; @@ -239,6 +284,9 @@ class Session { void hostAdvertise(); void close(DisconnectReason reason, std::string message, bool failedJoin); void tickCountdown(); + void hostCheckStart(); + void markLoaded(std::uint8_t id); + void clearRace(); // no race loading: the loaded flags and the start void updateShownClock(); // Takes in a remote vehicle's snapshot; false when it is ignored (in the // lobby, late packets of the last race; a time stamp far from this @@ -269,7 +317,13 @@ class Session { std::vector m_players; std::uint8_t m_localId = kInvalidPlayerId; std::string m_password; - std::uint32_t m_countdownEnd = 0; + // The race: its number, order time, start, and who has loaded it. + std::uint32_t m_race = 0; + std::uint32_t m_raceOrderTime = 0; + bool m_startKnown = false; + std::uint32_t m_startTime = 0; + std::uint32_t m_countdownMs = 0; // host: the start's countdown + std::vector m_loaded; // Host std::map m_remotes; diff --git a/src/net/VehicleDamage.h b/src/net/VehicleDamage.h new file mode 100644 index 0000000..9dad166 --- /dev/null +++ b/src/net/VehicleDamage.h @@ -0,0 +1,155 @@ +#pragma once + +// A car's visible damage in a network race (OpenMM2's own protocol; see +// docs/multiplayer.md, "Damage"). +// +// MM2's position packet (mmNetObject::SetPositionData) carries the car's +// vehCarDamage::CurrentDamage, and the receiving machine sets its copy of +// the car to it (mmNetObject::PositionUpdate), clearing the car's damage +// when it drops from above 1 to 0 (the owner's reset). Every machine then +// simulates the network car itself, so its own collisions paint the dents +// and break the parts there. OpenMM2 draws the other players' cars and the +// shared police kinematically from their snapshots, so the damage level +// still travels in the snapshot (VehicleSnapshot::damage, the police's +// AmbientEntity::damage), and what the owner's vehCarDamage::ApplyImpact +// painted and broke travels as this reliable game event: the receivers +// replay it through the same texel damage code (with the same random state +// and the same point, so the dents come out the same) and take the same +// parts off. +// +// Each car's damage since its last reset is one record: the texel damage +// patches in order (independent of each other: a patch copies the damaged +// texture's texels, so the order they land in does not matter), and the +// parts broken off. An event adds the patches from `first` on and carries +// every part broken since the reset, so a receiver that missed an event +// 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 "net/AmbientState.h" +#include "net/Protocol.h" + +#include +#include +#include +#include + +namespace mm2::net { + +// The game event type (reliable, ordered; the host relays a joiner's with +// its own budget, Session). +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. +inline constexpr std::uint16_t kDamageOwnCar = static_cast(kMaxAmbientIds); + +inline constexpr std::size_t kMaxDamagePatches = 16; // per event +inline constexpr std::size_t kMaxDamageImpacts = 8; // per event +// Patches in one record (between two resets); later ones are not sent. +inline constexpr std::uint32_t kMaxDamageRecord = 1024; +// The parts a car can lose (game::damagePartIndex): bits of `parts`. +inline constexpr int kDamagePartCount = 20; +// Model-space points: +-8 m (patches at 0.24 mm, impacts at 1.6 cm). +inline constexpr float kDamagePointRange = 8.0f; +inline constexpr int kDamagePatchBits = 16; +inline constexpr int kDamageImpactBits = 12; +// Impact strengths travel as log2(1 + x) over 0..20 (up to about 10^6). +inline constexpr float kDamageLogRange = 20.0f; +inline constexpr float kDamageMaxSpeed = 128.0f; // m/s + +// fxTexelDamage::ApplyDamage on the owner's car. +struct DamagePatch { + std::uint8_t delay = 0; // ms after the event's time + Vec3 point; // the impact point, model space + std::uint32_t seed = 0; // the texel damage random state it started from +}; + +// vehCarDamage::ApplyImpact's sparks, shards and impact sound on the +// owner's car (damaging impacts only). +struct DamageImpact { + std::uint8_t delay = 0; + Vec3 point; // model space + Vec3 normal; // model space, unit (from the other side towards this car) + float total = 0.0f; // the impact's running total (sparks, shards) + float speed = 0.0f; // the car's speed then, m/s + float sound = 0.0f; // AudImpact::Play's strength, 0 for none + std::uint16_t audioId = 0; +}; + +struct VehicleDamageEvent { + std::uint16_t subject = kDamageOwnCar; + std::uint8_t epoch = 0; // the car's damage resets so far (wraps) + std::uint32_t time = 0; // session ms the entries' delays count from + std::uint16_t first = 0; // record index of patches[0] + std::vector patches; + std::uint32_t parts = 0; // every part broken off since the reset + std::uint8_t partsDelay = 0; // ms after `time` the newest of them broke + 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; } + +template +bool serialize(S& s, VehicleDamageEvent& e) { + std::int32_t subject = e.subject, first = e.first; + s.ranged(subject, 0, static_cast(kDamageOwnCar)); + e.subject = static_cast(subject); + s.u8(e.epoch); + s.u32(e.time); + s.ranged(first, 0, static_cast(kMaxDamageRecord) - 1); + e.first = static_cast(first); + auto count = static_cast(std::min(e.patches.size(), kMaxDamagePatches)); + s.ranged(count, 0, static_cast(kMaxDamagePatches)); + if (static_cast(first + count) > kMaxDamageRecord) + return s.fail(); + if constexpr (S::kReading) + e.patches.resize(static_cast(count)); + for (std::int32_t i = 0; i < count; ++i) { + DamagePatch& p = e.patches[static_cast(i)]; + s.u8(p.delay); + s.vec3Quantized(p.point, kDamagePointRange, kDamagePatchBits); + s.u32(p.seed); + } + std::uint32_t parts = e.parts & ((1u << kDamagePartCount) - 1u); + s.bits(parts, kDamagePartCount); + e.parts = parts; + s.u8(e.partsDelay); + count = static_cast(std::min(e.impacts.size(), kMaxDamageImpacts)); + s.ranged(count, 0, static_cast(kMaxDamageImpacts)); + if constexpr (S::kReading) + e.impacts.resize(static_cast(count)); + for (std::int32_t i = 0; i < count; ++i) { + DamageImpact& m = e.impacts[static_cast(i)]; + s.u8(m.delay); + s.vec3Quantized(m.point, kDamagePointRange, kDamageImpactBits); + s.vec3Quantized(m.normal, 1.0f, 8); + float total = damageLog(m.total), sound = damageLog(m.sound); + s.quantized(total, 0.0f, kDamageLogRange, 10); + s.quantized(m.speed, 0.0f, kDamageMaxSpeed, 9); + s.quantized(sound, 0.0f, kDamageLogRange, 10); + std::int32_t audio = m.audioId; + s.ranged(audio, 0, 1000); + if constexpr (S::kReading) { + m.total = damageExp(total); + m.sound = damageExp(sound); + m.audioId = static_cast(audio); + } + } + return s.ok(); +} + +// The model-space point a patch travels as: the owner paints with it too, +// so both machines run the texel damage on the same numbers. +inline Vec3 quantizeDamagePoint(const Vec3& point) { + WriteStream w; + Vec3 p = point; + w.vec3Quantized(p, kDamagePointRange, kDamagePatchBits); + const std::vector bytes = w.writer().take(); + ReadStream r(bytes); + Vec3 out; + r.vec3Quantized(out, kDamagePointRange, kDamagePatchBits); + return out; +} + +} // namespace mm2::net diff --git a/src/phys/World.cpp b/src/phys/World.cpp index 07c7774..a131e13 100644 --- a/src/phys/World.cpp +++ b/src/phys/World.cpp @@ -520,6 +520,8 @@ int World::advanceOversampled(float frameDelta, float sampleStep, int maxSamples void World::step(float dt) { if (dt <= 0) return; + if (m_stepObserver) + m_stepObserver(); const float invDt = 1.0f / dt; // datTimeManager::SetTempOverSampling: Seconds is the sample's length. sampleTime() = {dt, invDt}; diff --git a/src/phys/World.h b/src/phys/World.h index 575a1c4..b9123dd 100644 --- a/src/phys/World.h +++ b/src/phys/World.h @@ -16,6 +16,7 @@ #include "phys/PolygonSoup.h" #include +#include #include #include @@ -222,6 +223,9 @@ class World final : public GroundQuery { // samples of delta / n each (frame-rate dependent). int advanceOversampled(float frameDelta, float sampleStep = kOversampleStep, int maxSamples = kOversampleMaxSamples); + // The share of a sample advanceFixed has not run yet: what is drawn + // between two samples blends the last two states by it + // (game::StepHistory). float interpolationAlpha(float sampleStep = kFixedSampleStep) const { return m_accumulator / sampleStep; } // The time (s) advanceFixed(frameDelta) will leave unstepped: how far the // simulation will be behind the frame once it has run. @@ -230,6 +234,11 @@ class World final : public GroundQuery { double time() const { return m_time; } + // OpenMM2 presentation: called at the start of every sample, before + // 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); } + bool probe(const Vec3& a, const Vec3& b, RayHit& hit) const override; // dgPhysManager::Collide(segment, mask 0x20) as vehWheel::ComputeDwtdw // calls it, with lvlSDL::CollideProbe for the city: the cached polygon @@ -313,6 +322,7 @@ class World final : public GroundQuery { bool m_stepping = false; float m_accumulator = 0; double m_time = 0; + std::function m_stepObserver; Stats m_stats; // dgPhysManager's static buffers and helper colliders: the level's diff --git a/src/phys/vehicle/CarSim.cpp b/src/phys/vehicle/CarSim.cpp index 7568c53..67661e9 100644 --- a/src/phys/vehicle/CarSim.cpp +++ b/src/phys/vehicle/CarSim.cpp @@ -210,6 +210,7 @@ void CarSim::resetAt(const Vec3& position, float rotation) { } void CarSim::resetBody(const Mat34& bodyMatrix) { + ++resets; InertialCS& ics = body.ics; ics.zero(); ics.matrix = bodyMatrix; diff --git a/src/phys/vehicle/CarSim.h b/src/phys/vehicle/CarSim.h index 6638889..db8e5ef 100644 --- a/src/phys/vehicle/CarSim.h +++ b/src/phys/vehicle/CarSim.h @@ -257,6 +257,11 @@ class CarSim final : public BodyController, public ImpactHandler { // mmPlayer +0x2258: the player has finished the race (brakes on, wheel // turned full left from then on). Set by the game. bool raceFinished = false; + // OpenMM2 presentation: how many times the car has been reset (put + // somewhere at rest). What is drawn between two simulation steps is not + // blended across a reset (game::StepHistory); the simulation never + // reads it. + std::uint32_t resets = 0; // 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/tests/game/test_damage_sync.cpp b/tests/game/test_damage_sync.cpp new file mode 100644 index 0000000..6cd1c33 --- /dev/null +++ b/tests/game/test_damage_sync.cpp @@ -0,0 +1,796 @@ +// The damage of the cars drawn from the network (game/net/DamageSync): the +// owner's recorder, the receivers' records (timing, resets, loss, hostile +// input), the same dents on both machines, and its trip through two +// NetGames. Also the knocked traffic cars' wheels (game/net/TrafficSync). +#include "TestData.h" +#include "asset/VehicleModel.h" +#include "core/StringUtil.h" +#include "game/ModelLibrary.h" +#include "game/TextureLibrary.h" +#include "game/VehicleRenderer.h" +#include "game/net/DamageSync.h" +#include "game/net/NetGame.h" +#include "game/net/TrafficSync.h" + +#include + +#include +#include +#include +#include +#include +#include +#include + +using namespace mm2; +using game::DamageRecorder; +using game::DamageReplica; + +namespace { + +net::DamageImpact sampleImpact() { + net::DamageImpact m; + m.point = {0.5f, 0.6f, -1.9f}; + m.normal = {0.0f, 0.0f, 1.0f}; + m.total = 900.0f; + m.speed = 20.0f; + m.sound = 5000.0f; + m.audioId = 3; + return m; +} + +// Encoded and decoded, as the network would hand it over. +net::VehicleDamageEvent overTheWire(const net::VehicleDamageEvent& e) { + net::VehicleDamageEvent out; + EXPECT_TRUE(net::decodePayload(net::encodePayload(e), out)); + return out; +} + +bool samePoint(const Vec3& a, const Vec3& b) { return a.x == b.x && a.y == b.y && a.z == b.z; } + +} // namespace + +// --- Parts ------------------------------------------------------------------------------- + +TEST(DamageSync, PartsHaveFixedBits) { + EXPECT_EQ(game::damagePartIndex("BREAK0"), 0); + EXPECT_EQ(game::damagePartIndex("break03"), 7); + EXPECT_EQ(game::damagePartIndex("VARIANT0"), 8); + EXPECT_EQ(game::damagePartIndex("VARIANT3"), 8); + EXPECT_EQ(game::damagePartIndex("WHL0"), 9); + EXPECT_EQ(game::damagePartIndex("HUB3"), 16); + EXPECT_EQ(game::damagePartIndex("ENGINE"), 19); + EXPECT_EQ(game::damagePartIndex("VARIANT"), -1); + EXPECT_EQ(game::damagePartIndex("BODY_H"), -1); + for (int i = 0; i < net::kDamagePartCount; ++i) + EXPECT_EQ(game::damagePartIndex(game::damagePartName(i, 2)), i) << i; + EXPECT_EQ(game::damagePartName(8, 2), "VARIANT2"); + EXPECT_EQ(game::damagePartName(20, 0), ""); + EXPECT_FALSE(game::damagePartIsWreckPart(8)); + EXPECT_TRUE(game::damagePartIsWreckPart(9)); +} + +// vehCarDamage::Update's level from the replicated fraction: nothing below +// MedDamage, MaxDamage at 1 (the fourth smoke level, a wreck). +TEST(DamageSync, DamageLevelFromTheFraction) { + phys::CarDamageParams p; + p.medDamage = 500.0f; + p.maxDamage = 1500.0f; + EXPECT_EQ(game::damageFromFraction(p, 0.0f), 0.0f); + EXPECT_EQ(game::damageFromFraction(p, std::numeric_limits::quiet_NaN()), 0.0f); + EXPECT_FLOAT_EQ(game::damageFromFraction(p, 0.5f), 1000.0f); + EXPECT_EQ(game::damageFromFraction(p, 1.0f), 1500.0f); + EXPECT_EQ(game::damageFromFraction(p, 2.0f), 1500.0f); +} + +// --- DamageRecorder ------------------------------------------------------------------------ + +TEST(DamageSync, RecorderSendsBatchesAtItsInterval) { + DamageRecorder rec; + EXPECT_TRUE(rec.take(1000).empty()); + const Vec3 p = rec.patch(5000, {0.3333f, 0.5555f, -1.1111f}, 77); + EXPECT_TRUE(samePoint(p, net::quantizeDamagePoint({0.3333f, 0.5555f, -1.1111f}))); + rec.patch(5010, {0.1f, 0.2f, 0.3f}, 78); + rec.impact(5005, sampleImpact()); + rec.part(5012, "BREAK0"); + rec.part(5013, "BREAK0"); // already off + auto events = rec.take(1000); + ASSERT_EQ(events.size(), 1u); + const auto& e = events[0]; + EXPECT_EQ(e.subject, net::kDamageOwnCar); + EXPECT_EQ(e.epoch, 0); + EXPECT_EQ(e.time, 5000u); + EXPECT_EQ(e.first, 0); + ASSERT_EQ(e.patches.size(), 2u); + EXPECT_EQ(e.patches[0].delay, 0); + EXPECT_EQ(e.patches[0].seed, 77u); + EXPECT_EQ(e.patches[1].delay, 10); + EXPECT_EQ(e.parts, 1u); + EXPECT_EQ(e.partsDelay, 12); + ASSERT_EQ(e.impacts.size(), 1u); + EXPECT_EQ(e.impacts[0].delay, 5); + + rec.patch(5100, {0.0f, 0.0f, 0.0f}, 79); + EXPECT_TRUE(rec.take(1050).empty()); // within the 100 ms + events = rec.take(1100); + ASSERT_EQ(events.size(), 1u); + EXPECT_EQ(events[0].first, 2); + EXPECT_EQ(events[0].parts, 1u); // every part since the reset, again + EXPECT_TRUE(rec.take(1300).empty()); // nothing new + EXPECT_EQ(rec.recorded(), 3u); +} + +TEST(DamageSync, RecorderSplitsLongBatchesKeepingTheIndices) { + DamageRecorder rec; + for (int i = 0; i < 40; ++i) + rec.patch(1000 + static_cast(i), {0.01f * static_cast(i), 0.0f, 0.0f}, + static_cast(i)); + const auto events = rec.take(10); + ASSERT_EQ(events.size(), 3u); + EXPECT_EQ(events[0].first, 0); + EXPECT_EQ(events[1].first, 16); + EXPECT_EQ(events[2].first, 32); + EXPECT_EQ(events[2].patches.size(), 8u); + EXPECT_EQ(events[2].patches.back().seed, 39u); + for (const auto& e : events) + EXPECT_EQ(overTheWire(e).patches.size(), e.patches.size()); +} + +// A reset sends what came before it, then the new epoch from its time; a +// reset with nothing after it before the next one is superseded by it. +TEST(DamageSync, RecorderStartsANewEpochOnAReset) { + DamageRecorder rec; + rec.patch(5000, {0.1f, 0.1f, 0.1f}, 1); + rec.part(5001, "WHL2"); + rec.reset(5050); + EXPECT_EQ(rec.epoch(), 1); + EXPECT_EQ(rec.recorded(), 0u); + rec.patch(5060, {0.2f, 0.2f, 0.2f}, 2); + auto events = rec.take(100); + ASSERT_EQ(events.size(), 2u); + EXPECT_EQ(events[0].epoch, 0); + EXPECT_EQ(events[0].time, 5000u); + EXPECT_EQ(events[0].parts, 1u << 11); + EXPECT_EQ(events[1].epoch, 1); + EXPECT_EQ(events[1].time, 5050u); + EXPECT_EQ(events[1].first, 0); + ASSERT_EQ(events[1].patches.size(), 1u); + EXPECT_EQ(events[1].patches[0].delay, 10); + EXPECT_EQ(events[1].parts, 0u); + + rec.reset(6000); + rec.reset(6100); + events = rec.take(1000); + ASSERT_EQ(events.size(), 1u); + EXPECT_EQ(events[0].epoch, 3); + EXPECT_EQ(events[0].time, 6100u); + EXPECT_TRUE(events[0].patches.empty()); +} + +// The sender keeps under the host's relay budget: what does not fit waits, +// nothing is lost. +TEST(DamageSync, RecorderKeepsToItsBudget) { + DamageRecorder::Options o; + o.intervalMs = 0; + o.perSecond = 10.0; + o.burst = 2.0; + DamageRecorder rec(net::kDamageOwnCar, o); + for (int i = 0; i < 100; ++i) + rec.patch(1000, {0.0f, 0.01f * i, 0.0f}, static_cast(i)); + std::vector all; + auto events = rec.take(1000); + EXPECT_EQ(events.size(), 2u); + all.insert(all.end(), events.begin(), events.end()); + EXPECT_TRUE(rec.take(1050).empty()); // half a token + std::uint64_t now = 1100; + int takes = 0; + while (all.size() < 7 && takes++ < 100) { + events = rec.take(now); + EXPECT_LE(events.size(), 1u); + all.insert(all.end(), events.begin(), events.end()); + now += 100; + } + ASSERT_EQ(all.size(), 7u); + std::uint32_t next = 0; + for (const auto& e : all) { + EXPECT_EQ(e.first, next); + next += static_cast(e.patches.size()); + } + EXPECT_EQ(next, 100u); +} + +// --- DamageReplica ------------------------------------------------------------------------- + +// 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) { + DamageReplica rep; + net::VehicleDamageEvent e; + e.time = 10000; + e.patches = {{0, {0.1f, 0.2f, 0.3f}, 11}, {50, {0.4f, 0.5f, 0.6f}, 12}}; + e.impacts = {sampleImpact()}; + e.impacts[0].delay = 20; + e.parts = 1u << 9; + e.partsDelay = 80; + ASSERT_TRUE(rep.receive(1, overTheWire(e), 10050.0)); + const auto key = DamageReplica::playerKey(1); + + EXPECT_TRUE(rep.advance(key, 9990.0, 10050.0).empty()); + auto a = rep.advance(key, 10000.0, 10060.0); + EXPECT_TRUE(a.clear); // the first record: from a clean car + ASSERT_EQ(a.patches.size(), 1u); + EXPECT_EQ(a.patches[0].seed, 11u); + EXPECT_TRUE(a.impacts.empty()); + a = rep.advance(key, 10030.0, 10080.0); + EXPECT_FALSE(a.clear); + EXPECT_TRUE(a.patches.empty()); + ASSERT_EQ(a.impacts.size(), 1u); + a = rep.advance(key, 10100.0, 10100.0); + ASSERT_EQ(a.patches.size(), 1u); + EXPECT_EQ(a.patches[0].seed, 12u); + EXPECT_EQ(a.partsOff, 1u << 9); + EXPECT_TRUE(a.partsFresh); + EXPECT_TRUE(rep.advance(key, 10200.0, 10200.0).empty()); + EXPECT_EQ(rep.recordSize(key), 2u); + + const auto all = rep.replay(key); + EXPECT_TRUE(all.clear); + EXPECT_EQ(all.patches.size(), 2u); + EXPECT_EQ(all.partsOff, 1u << 9); + EXPECT_FALSE(all.partsFresh); + EXPECT_TRUE(all.impacts.empty()); +} + +// A time the car is never drawn at (a hostile one, or a car not drawn) is +// waited for at most kMaxHoldMs; what was late shows without its effects. +TEST(DamageSync, ReplicaWaitsAtMostTheHoldAndSkipsStaleEffects) { + DamageReplica rep; + net::VehicleDamageEvent e; + e.time = 4000000; + e.patches = {{0, {0.1f, 0.1f, 0.1f}, 1}}; + e.impacts = {sampleImpact()}; + e.parts = 1u; + ASSERT_TRUE(rep.receive(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); + EXPECT_EQ(a.patches.size(), 1u); + EXPECT_TRUE(a.impacts.empty()); + EXPECT_EQ(a.partsOff, 1u); + EXPECT_FALSE(a.partsFresh); + + // Drawn long after its time (the car came into view late). + e.time = 5000; + e.epoch = 0; + e.first = 1; + e.parts = 3u; + ASSERT_TRUE(rep.receive(2, e, 5100.0)); + a = rep.advance(key, 7000.0, 5200.0); + EXPECT_EQ(a.patches.size(), 1u); + EXPECT_TRUE(a.impacts.empty()); + EXPECT_EQ(a.partsOff, 2u); + EXPECT_FALSE(a.partsFresh); +} + +// A reset (a new epoch) clears the car when it is drawn at the reset's time, +// and only the new epoch's damage is shown after it. +TEST(DamageSync, ReplicaClearsTheCarOnAReset) { + DamageRecorder rec; + DamageReplica rep; + const auto key = DamageReplica::playerKey(3); + 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)); + auto a = rep.advance(key, 1100.0, 1100.0); + EXPECT_EQ(a.patches.size(), 1u); + EXPECT_EQ(a.partsOff, 2u); + + 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)); + 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); + EXPECT_TRUE(a.patches.empty()); + a = rep.advance(key, 2010.0, 2010.0); + ASSERT_EQ(a.patches.size(), 1u); + EXPECT_EQ(a.patches[0].seed, 2u); + const auto all = rep.replay(key); + EXPECT_EQ(all.patches.size(), 1u); + EXPECT_EQ(all.partsOff, 0u); +} + +// An event lost on the way (the host's budget, say): the parts converge with +// the next one, the patches it held are counted missing, and a car shown +// afresh replays the rest. +TEST(DamageSync, ReplicaConvergesAfterALostEvent) { + DamageRecorder::Options o; + o.intervalMs = 0; + DamageRecorder rec(net::kDamageOwnCar, o); + DamageReplica rep; + const auto key = DamageReplica::playerKey(4); + std::vector sent; + auto batch = [&](std::uint32_t t, const char* part) { + rec.patch(t, {0.01f * static_cast(t % 100), 0.1f, 0.0f}, t); + rec.patch(t + 1, {0.02f * static_cast(t % 100), 0.1f, 0.0f}, t + 1); + rec.part(t, part); + const auto e = rec.take(t); + sent.insert(sent.end(), e.begin(), e.end()); + }; + batch(1000, "BREAK0"); + 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 + 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 + EXPECT_EQ(rep.stats().missed, 2u); + const auto all = rep.replay(key); + EXPECT_EQ(all.patches.size(), 4u); + EXPECT_EQ(all.partsOff, (1u << 0) | (1u << 1) | (1u << 9)); +} + +// A car not drawn (out of view) takes its events after the hold, so it shows +// them when it comes into view; a player who left is forgotten. +TEST(DamageSync, ReplicaSettlesTheCarsNotDrawn) { + DamageReplica rep; + net::VehicleDamageEvent e; + e.subject = 405; + e.time = 1000; + e.patches = {{0, {0.3f, 0.3f, 0.3f}, 9}}; + ASSERT_TRUE(rep.receive(net::kHostPlayerId, e, 1000.0)); + const auto key = DamageReplica::ambientKey(405); + rep.settle(2500.0); + EXPECT_EQ(rep.recordSize(key), 0u); + rep.settle(3000.0); + EXPECT_EQ(rep.recordSize(key), 1u); + EXPECT_EQ(rep.replay(key).patches.size(), 1u); + // Drawn again without being shown afresh: the whole record, then what is + // new; from then on it is left to advance(). + e.time = 4000; + e.first = 1; + ASSERT_TRUE(rep.receive(net::kHostPlayerId, e, 4000.0)); + auto a = rep.advance(key, 4000.0, 4000.0); + EXPECT_TRUE(a.clear); + EXPECT_EQ(a.patches.size(), 2u); + e.time = 5000; + e.first = 2; + ASSERT_TRUE(rep.receive(net::kHostPlayerId, e, 5000.0)); + a = rep.advance(key, 5000.0, 5000.0); + EXPECT_FALSE(a.clear); + EXPECT_EQ(a.patches.size(), 1u); + rep.settle(9000.0); + EXPECT_EQ(rep.recordSize(key), 3u); + rep.forget(key); + EXPECT_EQ(rep.recordSize(key), 0u); + EXPECT_TRUE(rep.replay(key).empty()); +} + +// Everything a received event says is checked: a police car's damage from +// the host only, ids, counts, indices, finite and in-range values; the +// records and the waiting events are bounded. +TEST(DamageSync, ReplicaRefusesHostileEvents) { + DamageReplica rep; + net::VehicleDamageEvent e; + e.subject = 401; + EXPECT_FALSE(rep.receive(2, e, 0.0)); // a player claiming a police car + EXPECT_TRUE(rep.receive(net::kHostPlayerId, e, 0.0)); + e.subject = net::kDamageOwnCar; + EXPECT_FALSE(rep.receive(net::kInvalidPlayerId, e, 0.0)); + EXPECT_FALSE(rep.receive(static_cast(net::kMaxPlayers), e, 0.0)); + auto bad = e; + bad.patches = {{0, {std::numeric_limits::quiet_NaN(), 0.0f, 0.0f}, 0}}; + EXPECT_FALSE(rep.receive(1, bad, 0.0)); + bad = e; + bad.patches = {{0, {0.0f, 100.0f, 0.0f}, 0}}; + EXPECT_FALSE(rep.receive(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)); + bad.impacts[0] = sampleImpact(); + bad.impacts[0].speed = std::numeric_limits::infinity(); + EXPECT_FALSE(rep.receive(1, bad, 0.0)); + bad = e; + bad.first = static_cast(net::kMaxDamageRecord); + bad.patches = {{0, {}, 0}}; + EXPECT_FALSE(rep.receive(1, bad, 0.0)); + bad = e; + bad.patches.assign(net::kMaxDamagePatches + 1, {}); + EXPECT_FALSE(rep.receive(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)); + + // At most kMaxRecords cars. + for (std::uint16_t id = 0; id < net::kMaxAmbientIds; ++id) { + e.subject = id; + rep.receive(net::kHostPlayerId, e, 0.0); + } + EXPECT_EQ(rep.records(), DamageReplica::kMaxRecords); + // A flood of events for a car never drawn: bounded, and the oldest are + // taken at once. + e.subject = 0; + e.time = 100000000; + for (int i = 0; i < 1000; ++i) { + e.first = static_cast(i % 1000); + e.patches = {{0, {0.0f, 0.0f, 0.0f}, static_cast(i)}}; + rep.receive(net::kHostPlayerId, e, 0.0); + } + rep.settle(1.0); + EXPECT_LE(rep.recordSize(DamageReplica::ambientKey(0)), 1000u); + EXPECT_GT(rep.recordSize(DamageReplica::ambientKey(0)), 0u); +} + +// The whole trip without a network: what the owner recorded (patches across +// several events, parts, a reset) is what the receiver's record holds. +TEST(DamageSync, RecordReachesTheReceiverIntact) { + DamageRecorder::Options o; + o.intervalMs = 50; + DamageRecorder rec(net::kDamageOwnCar, o); + DamageReplica rep; + const auto key = DamageReplica::playerKey(5); + std::vector painted; + std::uint32_t t = 1000; + std::uint64_t now = 0; + auto pump = [&] { + for (const auto& e : rec.take(now)) + ASSERT_TRUE(rep.receive(5, overTheWire(e), t)); + rep.advance(key, t, t); + }; + for (int i = 0; i < 30; ++i) { + painted.push_back(rec.patch(t, {0.05f * i, 0.4f, -1.0f + 0.03f * i}, 1000u + i)); + if (i == 10) + rec.part(t, "BREAK2"); + t += 17; + now += 17; + pump(); + } + rec.reset(t); + painted.clear(); + for (int i = 0; i < 25; ++i) { + painted.push_back(rec.patch(t, {-0.05f * i, 0.3f, 1.0f}, 2000u + i)); + if (i == 3) + rec.part(t, "WHL1"); + t += 17; + now += 17; + pump(); + } + now += 1000; + t += 1000; + pump(); + const auto all = rep.replay(key); + ASSERT_EQ(all.patches.size(), painted.size()); + for (std::size_t i = 0; i < painted.size(); ++i) { + EXPECT_TRUE(samePoint(all.patches[i].point, painted[i])) << i; + EXPECT_EQ(all.patches[i].seed, 2000u + i); + } + EXPECT_EQ(all.partsOff, 1u << 10); + EXPECT_EQ(rep.stats().missed, 0u); +} + +// --- The same dents on both machines (retail data) --------------------------------------- + +namespace { + +// A device that keeps every texture's latest pixels, by the name it was made +// with. +class TextureDevice final : public render::Device { +public: + const render::DeviceInfo& info() const override { return m_info; } + render::TextureHandle createTexture(const render::TextureDesc& d, + std::span data) override { + const render::TextureHandle h{++m_next}; + m_names[h.id] = d.debugName; + m_sizes[h.id] = {d.width, d.height}; + if (!data.empty() && data[0].data && d.width && d.height) { + const auto* p = static_cast(data[0].data); + m_pixels[h.id].assign(p, p + static_cast(d.width) * d.height * 4); + } + return h; + } + void updateTexture(render::TextureHandle h, std::uint32_t, const render::Rect& r, const void* data, + std::uint32_t) override { + const auto [w, hgt] = m_sizes[h.id]; + if (r.x == 0 && r.y == 0 && r.width == w && r.height == hgt && data != nullptr) { + const auto* p = static_cast(data); + m_pixels[h.id].assign(p, p + static_cast(w) * hgt * 4); + } + } + void destroyTexture(render::TextureHandle) override {} + render::BufferHandle createBuffer(render::BufferKind, std::size_t, const void*) override { + return {++m_next}; + } + void updateBuffer(render::BufferHandle, std::size_t, std::span) override {} + void destroyBuffer(render::BufferHandle) override {} + render::BufferSlice uploadTransient(render::BufferKind, std::span) override { + return {{1}, 0}; + } + void applySettings(const render::DisplaySettings&) override {} + void notifyResized() override {} + bool beginFrame() override { return true; } + render::Extent2D outputExtent() const override { return {640, 480}; } + render::Extent2D sceneExtent() const override { return {640, 480}; } + void beginScene(const render::ClearValues&) override {} + void endScene() override {} + void beginOverlay(const Vec4&) override {} + void endOverlay() override {} + void endFrame() override {} + void setViewport(const render::Viewport&) override {} + void setScissor(const render::Rect*) override {} + void clear(const render::ClearValues&) override {} + void setFrameConstants(const render::FrameConstants&) override {} + void draw(const render::DrawCall&) override {} + void requestCapture() override {} + bool readCapture(render::Image&) override { return false; } + void waitIdle() override {} + const render::FrameStats& stats() const override { return m_stats; } + + // The texel damage copies whose name has `tag` ("##"), + // by "#". + std::map> damageCopies(const std::string& tag) const { + std::map> out; + for (const auto& [id, name] : m_names) { + const auto a = name.find('#'); + const auto b = a == std::string::npos ? a : name.find('#', a + 1); + if (b == std::string::npos || name.substr(a + 1, b - a - 1) != tag) + continue; + const auto it = m_pixels.find(id); + out[name.substr(0, a) + name.substr(b)] = it == m_pixels.end() ? std::vector{} + : it->second; + } + return out; + } + +private: + render::DeviceInfo m_info; + render::FrameStats m_stats; + std::uint32_t m_next = 0; + std::map m_names; + std::map> m_sizes; + std::map> m_pixels; +}; + +} // namespace + +// The owner paints its car's impacts; another machine replays them from the +// events: the damaged textures come out the same, texel for texel, and a +// reset clears both. +TEST(DamageSync, ReplayedDentsMatchTheOwnersTexelForTexel) { + MM2_REQUIRE_GAME_DATA(); + const vfs::Vfs& vfs = *test::gameData(); + auto read = [&](std::string_view path) { return vfs.readAll(path); }; + auto model = asset::loadVehicleModel("vpmustang99", read); + ASSERT_TRUE(model); + TextureDevice device; + game::TextureLibrary textures(device, vfs); + game::ModelLibrary models(device, vfs); + game::VehicleRenderer owner(device, textures, models, *model, 0); + game::VehicleRenderer remote(device, textures, models, *model, 0); + ASSERT_TRUE(owner.hasTexelDamage()); + // The renderers' copies are tagged with the model's name and a serial. + std::string ownerTag, remoteTag; + for (int serial = 1; serial < 10000 && remoteTag.empty(); ++serial) { + const std::string tag = std::format("vpmustang99{}", serial); + if (!device.damageCopies(tag).empty()) + (ownerTag.empty() ? ownerTag : remoteTag) = tag; + } + ASSERT_FALSE(remoteTag.empty()); + const auto clean = device.damageCopies(ownerTag); + + // The owner's side: vehCarDamage::Update's paint with the recorder, at + // points spread over the high LOD body. + std::vector body; + for (const auto& mesh : model->pkg.meshes) + if (str::iequals(mesh.part, "BODY") && mesh.lod == asset::Lod::High) + for (const auto& section : mesh.sections) + for (const auto& packet : section.packets) + for (const auto& v : packet.vertices) + body.push_back(v.position); + ASSERT_GT(body.size(), 50u); + std::vector hits; + for (std::size_t i = 0; i < 6; ++i) + hits.push_back(body[i * body.size() / 6] + Vec3{0.01f, -0.02f, 0.015f}); + DamageRecorder rec; + DamageReplica rep; + const float radius = 0.4f; + std::uint32_t t = 1000; + for (const Vec3& h : hits) { + const Vec3 p = rec.patch(t, h, owner.texelDamageState()); + owner.applyDamage(p, radius); + t += 50; + } + // Another impact on the receiver's own copy first would change its random + // state: the patches still come out the owner's. + const std::uint32_t before = remote.texelDamageState(); + remote.applyDamage(hits[1], radius); + EXPECT_NE(remote.texelDamageState(), before); + remote.resetDamage(); + for (const auto& e : rec.take(0)) + ASSERT_TRUE(rep.receive(1, overTheWire(e), t)); + game::DamageTarget target; + target.renderer = &remote; + target.texelRadius = radius; + game::applyDamage(rep.advance(DamageReplica::playerKey(1), t, t), target); + + const auto ownerCopies = device.damageCopies(ownerTag); + const auto remoteCopies = device.damageCopies(remoteTag); + ASSERT_EQ(ownerCopies.size(), remoteCopies.size()); + bool dented = false; + for (const auto& [name, pixels] : ownerCopies) { + ASSERT_TRUE(remoteCopies.contains(name)) << name; + EXPECT_TRUE(pixels == remoteCopies.at(name)) << name; + dented = dented || pixels != clean.at(name); + } + EXPECT_TRUE(dented); + + // mmPlayer::Reset: both clean again. + rec.reset(t); + owner.resetDamage(); + for (const auto& e : rec.take(1000)) + ASSERT_TRUE(rep.receive(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); + for (const auto& [name, pixels] : clean) + EXPECT_TRUE(after.at(name) == pixels) << name; +} + +// --- Two NetGames ------------------------------------------------------------------------ + +namespace { + +// Each test file has its own block of ports, all below 49152 (see +// test_netgame_sync.cpp). +std::uint16_t damagePort() { + static const std::uint16_t port = static_cast( + 29000 + (std::chrono::steady_clock::now().time_since_epoch().count() / 1000) % 2000 * 2); + return port; +} + +game::NetOptions netOptions(const std::string& name) { + game::NetOptions o; + o.playerName = name; + o.port = damagePort(); + o.portMapping = false; + o.discoveryPort = static_cast(damagePort() + 1); + return o; +} + +bool pump(std::initializer_list games, const std::function& done, + int timeoutMs = 4000) { + const auto end = std::chrono::steady_clock::now() + std::chrono::milliseconds(timeoutMs); + while (std::chrono::steady_clock::now() < end) { + for (game::NetGame* g : games) + g->update(); + if (done()) + return true; + std::this_thread::sleep_for(std::chrono::milliseconds(2)); + } + return false; +} + +} // namespace + +// A client's car and the host's police car: each machine's damage reaches +// the other through the session, and a client's claim to a police car's +// damage is refused. +TEST(DamageSync, DamageTravelsBetweenNetGames) { + game::NetGame host(netOptions("Host")); + game::NetGame client(netOptions("Client")); + game::RaceConfig cruise; + std::string err; + ASSERT_TRUE(host.host(cruise, {"Damage", "", 2, false}, {"vpbug", 0, 0}, &err)) << err; + ASSERT_TRUE(client.join(std::format("127.0.0.1:{}", damagePort()), "", {"vpbug", 0, 0}, &err)) << err; + ASSERT_TRUE(pump({&host, &client}, [&] { return host.players().size() == 2; })); + host.startRace(); + ASSERT_TRUE(pump({&host, &client}, [&] { + host.reportLoaded(); // nothing to load + client.reportLoaded(); + return host.raceStarted() && client.raceStarted(); + }, 10000)); + const std::uint8_t clientId = client.localId(); + + game::NetDamage hostSide, clientSide; + for (int i = 0; i < 20; ++i) + clientSide.own().patch(host.sessionTime(), {0.1f * i, 0.5f, 0.0f}, static_cast(i)); + clientSide.own().part(host.sessionTime(), "WHL3"); + clientSide.send(client, 0); + hostSide.police(402).patch(host.sessionTime(), {0.2f, 0.4f, 0.6f}, 99); + hostSide.police(402).part(host.sessionTime(), "BREAK0"); + hostSide.send(host, 0); + // A client pretending to be the host's police. + game::DamageRecorder fake(402); + fake.patch(0, {}, 1); + for (const auto& e : fake.take(0)) + client.sendEvent(net::kVehicleDamageEvent, net::encodePayload(e)); + + ASSERT_TRUE(pump({&host, &client}, [&] { + hostSide.receive(host.takeGameEvents(), host.frameTime()); + 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; + })); + EXPECT_EQ(hostSide.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); +} + +// --- Knocked traffic cars' wheels --------------------------------------------------------- + +TEST(DamageSync, KnockedCarsWheelsAreDrawnWhereTheHostHasThem) { + ai::VehicleData data; + data.wheels = {Vec3{-0.8f, 0.3f, -1.3f}, Vec3{0.8f, 0.3f, -1.3f}, Vec3{-0.8f, 0.3f, 1.4f}, + Vec3{0.8f, 0.3f, 1.4f}, Vec3{-0.8f, 0.3f, 2.4f}, Vec3{0.8f, 0.3f, 2.4f}}; + data.wheelCount = 6; + data.wheelRadius = 0.32f; + Mat34 body = Mat34::rotationY(0.7f); + body.m3 = {100.0f, 5.0f, -30.0f}; + // The host's wheels: the pivots moved by the spring and the tyre. + const std::array moved = {Vec3{0.01f, 0.08f, -0.02f}, Vec3{-0.02f, 0.11f, 0.0f}, + Vec3{0.0f, -0.07f, 0.03f}, Vec3{0.015f, 0.02f, 0.0f}}; + std::array host{}; + for (std::size_t i = 0; i < 4; ++i) + host[i] = Mat34::mul(Mat34::translation(data.wheels[i] + moved[i]), body); + const auto offsets = game::trafficWheelOffsets(body, host, data); + for (std::size_t i = 0; i < 4; ++i) { + EXPECT_NEAR(offsets[i].x, moved[i].x, 1e-4f); + EXPECT_NEAR(offsets[i].y, moved[i].y, 1e-4f); + EXPECT_NEAR(offsets[i].z, moved[i].z, 1e-4f); + } + std::array drawn{}; + std::array valid{}; + game::trafficWheelMatrices(body, offsets, data, drawn, valid); + for (std::size_t i = 0; i < 6; ++i) + EXPECT_TRUE(valid[i]); + for (std::size_t i = 0; i < 4; ++i) + EXPECT_LT(drawn[i].m3.dist(host[i].m3), 1e-3f); + // WHL4 at its pivot raised by WHL2's drawn height less the radius. + const Vec3 w4 = body.untransform(drawn[4].m3); + EXPECT_NEAR(w4.y, (data.wheels[2].y + moved[2].y - data.wheelRadius) + data.wheels[4].y, 1e-4f); + + // Through the host's message and the client's interpolation. + game::SharedCar car; + car.id = 7; + car.model = 0; + car.transform = body; + car.flags = net::kAmbientOffRail; + car.velocity = {1.0f, 0.0f, 0.0f}; + car.wheels = true; + car.wheelOffsets = offsets; + game::TrafficHost hostSide; + game::TrafficClient client(4, 0x1234); + client.receive(hostSide.build({1, body.m3}, std::span(&car, 1), 1000, 0, 0x1234)); + car.wheelOffsets[0].y += 0.1f; + car.transform.m3.x += 0.05f; + client.receive(hostSide.build({1, body.m3}, std::span(&car, 1), 1050, 0, 0x1234)); + client.update(1025.0); + ASSERT_EQ(client.cars().size(), 1u); + const auto& c = client.cars()[0]; + ASSERT_TRUE(c.wheels); + EXPECT_NEAR(c.wheelOffsets[0].y, offsets[0].y + 0.05f, 0.01f); // halfway between the two + // Kept as long as the messages carry them, drawn 100 ms behind. + for (std::uint32_t t = 1100; t < 3000; t += 50) { + car.transform.m3.x += 0.05f; + client.receive(hostSide.build({1, body.m3}, std::span(&car, 1), t, 0, 0x1234)); + client.update(static_cast(t) - 100.0); + ASSERT_EQ(client.cars().size(), 1u) << t; + EXPECT_TRUE(client.cars()[0].wheels) << t; + } + // Back on its rail (no body): no wheels. + car.flags = 0; + car.wheels = false; + client.receive(hostSide.build({1, body.m3}, std::span(&car, 1), 3000, 0, 0x1234)); + client.update(3000.0); + ASSERT_EQ(client.cars().size(), 1u); + EXPECT_FALSE(client.cars()[0].wheels); +} diff --git a/tests/game/test_interpolation.cpp b/tests/game/test_interpolation.cpp new file mode 100644 index 0000000..a813b34 --- /dev/null +++ b/tests/game/test_interpolation.cpp @@ -0,0 +1,459 @@ +// Drawing between simulation steps (game/Interpolation.h): the blends, when +// an object is drawn where it is instead, and that keeping the history +// leaves the simulation as it was, whatever the frame times. +#include "TestData.h" +#include "ai/World.h" +#include "city/CityData.h" +#include "game/Interpolation.h" +#include "game/PlayerVehicle.h" +#include "game/fx/Particles.h" +#include "game/net/TrafficSync.h" +#include "net/Protocol.h" +#include "phys/AgeMath.h" +#include "phys/World.h" + +#include + +#include +#include +#include +#include + +using namespace mm2; +using game::blendAngle; +using game::blendPose; +using game::blendTransform; +using game::Drawn; +using game::drawnKey; +using game::StepHistory; + +namespace { + +Mat34 placed(float yaw, const Vec3& at) { + Mat34 m = Mat34::rotationY(yaw); + m.m3 = at; + return m; +} + +void expectNear(const Mat34& a, const Mat34& b, float eps, const char* what) { + for (int i = 0; i < 4; ++i) + EXPECT_LT((a.row(i) - b.row(i)).mag(), eps) << what << " row " << i; +} + +// A wheel as vehWheel::Update leaves it: its unspun frame turned about its +// own axle by the accumulated turn (Matrix34::Rotate). +Mat34 spunWheel(const Mat34& unspun, float turn) { + Mat34 m = unspun; + const Vec3 axle = m.m0; + phys::age::rotate(m, axle, turn); + return m; +} + +} // namespace + +TEST(Interpolation, BlendTransformEndsExactlyAndMovesBetween) { + const Mat34 a = placed(0.0f, {0, 0, 0}); + const Mat34 b = placed(1.0f, {10, 2, -4}); + EXPECT_EQ(blendTransform(a, b, 0.0f).m3, a.m3); + EXPECT_EQ(blendTransform(a, b, -1.0f).m0, a.m0); + const Mat34 end = blendTransform(a, b, 1.0f); + for (int i = 0; i < 4; ++i) + EXPECT_EQ(end.row(i), b.row(i)); // exactly the live state once a step is due + // Half way: half the way there, half the turn. + expectNear(blendTransform(a, b, 0.5f), placed(0.5f, {5, 1, -2}), 1e-5f, "half"); + expectNear(blendTransform(a, b, 0.25f), placed(0.25f, {2.5f, 0.5f, -1}), 1e-5f, "quarter"); + // A scaled instance keeps its scale. + Mat34 s = a; + s.m0 = s.m0 * 2.0f; + s.m1 = s.m1 * 2.0f; + s.m2 = s.m2 * 2.0f; + EXPECT_NEAR(blendTransform(s, s, 0.5f).m1.mag(), 2.0f, 1e-5f); +} + +TEST(Interpolation, BlendAngleTakesTheShortWayRound) { + EXPECT_FLOAT_EQ(blendAngle(1.0f, 2.0f, 0.5f, 6.28f), 1.5f); + // A tyre's turn that wrapped from 6.2 to 0.1 went on by 0.18. + EXPECT_NEAR(blendAngle(6.2f, 0.1f, 0.5f, 6.28f), 6.29f, 1e-5f); + EXPECT_FLOAT_EQ(blendAngle(6.2f, 0.1f, 1.0f, 6.28f), 0.1f); +} + +TEST(Interpolation, JumpsAreTooFarOrTooMuchTurnForOneStep) { + const game::JumpLimits limits{5.0f, 1.6f}; + const Mat34 a = placed(0.0f, {0, 0, 0}); + EXPECT_FALSE(game::isJump(a, placed(0.3f, {2, 0, 0}), limits)); // 120 m/s, 18 rad/s + EXPECT_TRUE(game::isJump(a, placed(0.0f, {6, 0, 0}), limits)); // a respawn down the road + EXPECT_TRUE(game::isJump(a, placed(3.0f, {0, 0, 0}), limits)); // turned round where it was +} + +// vehWheel's turn is accumulated: a wheel turning 4 rad in a step (69 m/s on +// a 0.29 m wheel) is drawn half way round, not the short way backwards. +TEST(Interpolation, FastWheelsTurnForwards) { + game::VehiclePose from, to; + from.body = placed(0.0f, {0, 0, 0}); + to.body = placed(0.05f, {0, 0, -1.15f}); + const Mat34 pivot = Mat34::translation({0.8f, 0.3f, -1.2f}); + from.wheelTurn[0] = 10.0f; + to.wheelTurn[0] = 14.0f; + from.wheelWorld[0] = spunWheel(pivot * from.body, from.wheelTurn[0]); + to.wheelWorld[0] = spunWheel(pivot * to.body, to.wheelTurn[0]); + from.wheelValid[0] = to.wheelValid[0] = true; + from.hasWheelWorld = to.hasWheelWorld = true; + from.hasWheelTurn = to.hasWheelTurn = true; + to.brakeLights = true; + + const game::VehiclePose half = blendPose(from, to, 0.5f); + const Mat34 body = blendTransform(from.body, to.body, 0.5f); + expectNear(half.body, body, 1e-6f, "body"); + expectNear(half.wheelWorld[0], spunWheel(pivot * body, 12.0f), 1e-4f, "wheel"); + EXPECT_TRUE(half.brakeLights); // the flags are the live state's + // Without the turns (a traffic car's cheap wheels) the matrices are + // blended the short way. + from.hasWheelTurn = false; + const game::VehiclePose shortWay = blendPose(from, to, 0.5f); + EXPECT_GT((shortWay.wheelWorld[0].m1 - half.wheelWorld[0].m1).mag(), 0.5f); + // The wheel keeps its place on the body. + EXPECT_LT((shortWay.wheelWorld[0].m3 - (pivot * body).m3).mag(), 1e-4f); +} + +TEST(Interpolation, PlacePoseCarriesTheWheels) { + game::VehiclePose p; + p.body = placed(0.3f, {4, 0, 1}); + p.wheelWorld[2] = Mat34::translation({-0.8f, 0.3f, 1.3f}) * p.body; + p.hasWheelWorld = true; + const Mat34 there = placed(1.1f, {-20, 3, 7}); + const game::VehiclePose q = game::placePose(p, there); + expectNear(q.body, there, 1e-6f, "body"); + expectNear(q.wheelWorld[2], Mat34::translation({-0.8f, 0.3f, 1.3f}) * there, 1e-4f, "wheel"); +} + +TEST(StepHistory, BlendsFromTheStateBeforeTheLastStep) { + StepHistory h; + const auto key = drawnKey(Drawn::Prop, 7); + const Mat34 before = placed(0.0f, {0, 0, 0}); + const Mat34 now = placed(0.2f, {1, 0, 0}); + // Nothing recorded: drawn where it is. + h.setAlpha(0.25f); + EXPECT_EQ(h.transform(key, now).m3, now.m3); + EXPECT_FALSE(h.blends(key, now)); + h.beginStep(); + h.record(key, before); + // The step ran (the object is at `now`); a quarter of the next one has + // gone by: drawn a quarter of the way from where it was. + expectNear(h.transform(key, now), blendTransform(before, now, 0.25f), 1e-6f, "quarter"); + EXPECT_TRUE(h.blends(key, now)); + h.setAlpha(0.0f); + expectNear(h.transform(key, now), before, 1e-6f, "start"); + h.setAlpha(2.0f); // clamped + EXPECT_FLOAT_EQ(h.alpha(), 1.0f); + EXPECT_EQ(h.transform(key, now).m3, now.m3); + // Recorded at an earlier step but not the last one (it was not + // simulated since): drawn where it is, and forgotten a step later. + h.setAlpha(0.5f); + h.beginStep(); + EXPECT_EQ(h.transform(key, now).m3, now.m3); + h.beginStep(); + EXPECT_EQ(h.size(), 0u); +} + +TEST(StepHistory, DiscontinuitiesAreDrawnWhereTheyAre) { + StepHistory h; + h.setAlpha(0.5f); + const auto car = drawnKey(Drawn::Opponent, 0); + const auto rail = drawnKey(Drawn::RailCar, 3); + h.beginStep(); + h.record(car, placed(0.0f, {0, 0, 0}), 4); + h.record(rail, placed(0.0f, {50, 0, 0}), 1, 6.0f); + // A reset (the car's reset count went up): not blended across. + EXPECT_EQ(h.transform(car, placed(0.0f, {1, 0, 0}), 5).m3, (Vec3{1, 0, 0})); + EXPECT_NEAR(h.transform(car, placed(0.0f, {1, 0, 0}), 4).m3.x, 0.5f, 1e-6f); + // A recycled traffic slot (another spawn): neither its place nor its + // tyres blended. + EXPECT_EQ(h.transform(rail, placed(0.0f, {-300, 0, 9}), 2).m3, (Vec3{-300, 0, 9})); + EXPECT_FLOAT_EQ(h.turn(rail, placed(0.0f, {-300, 0, 9}), 0.5f, 6.28f, 2), 0.5f); + EXPECT_NEAR(h.turn(rail, placed(0.0f, {50.5f, 0, 0}), 0.2f, 6.28f, 1), 6.24f, 1e-5f); + // Moved further than a step allows (a teleport nobody counted). + EXPECT_EQ(h.transform(car, placed(0.0f, {30, 0, 0}), 4).m3, (Vec3{30, 0, 0})); + // A race restart forgets everything. + h.clear(); + EXPECT_EQ(h.transform(car, placed(0.0f, {1, 0, 0}), 4).m3, (Vec3{1, 0, 0})); +} + +TEST(StepHistory, PosesSnapOnAReset) { + StepHistory h; + h.setAlpha(0.5f); + game::VehiclePose a, b; + a.body = placed(0.0f, {0, 0, 0}); + b.body = placed(0.0f, {0, 0, -1}); + h.beginStep(); + h.record(drawnKey(Drawn::Player, 0), a, 1); + EXPECT_NEAR(h.pose(drawnKey(Drawn::Player, 0), b, 1).body.m3.z, -0.5f, 1e-6f); + EXPECT_EQ(h.pose(drawnKey(Drawn::Player, 0), b, 2).body.m3, b.body.m3); +} + +// phys::World calls its observer once at the start of every sample: what +// it records there is the state the drawing blends from. +TEST(StepHistory, PhysicsObserverRecordsEachSampleBeforeItRuns) { + phys::World world; + phys::Body body; + body.ics.setMass(1, 1, 1, 10.0f); + body.collideTerrain = body.collideInstances = body.collideMovers = false; + Mat34 m = Mat34::identity(); + m.m3 = {0, 100, 0}; + body.place(m); + world.add(&body); + StepHistory h; + int samples = 0; + world.setStepObserver([&] { + ++samples; + h.beginStep(); + h.record(drawnKey(Drawn::Prop, 0), body.ics.matrix); + }); + EXPECT_EQ(world.advanceFixed(1.0f / 144.0f), 0); + EXPECT_EQ(samples, 0); + std::vector heights{body.ics.matrix.m3.y}; + int total = 0; + for (int frame = 0; frame < 20; ++frame) { + const int n = world.advanceFixed(1.0f / 144.0f); + total += n; + EXPECT_EQ(samples, total) << "one call per sample"; + if (n > 0) + heights.push_back(body.ics.matrix.m3.y); + h.setAlpha(world.interpolationAlpha()); + const float drawn = h.transform(drawnKey(Drawn::Prop, 0), body.ics.matrix).m3.y; + if (heights.size() >= 2) { + const float a = heights[heights.size() - 2], b = heights.back(); + EXPECT_NEAR(drawn, a + (b - a) * h.alpha(), 1e-4f) << frame; + } + } + EXPECT_GT(samples, 5); +} + +namespace { + +// A flat floor 2 km across for the car's wheels. +phys::PolygonSoup floorSoup() { + phys::SoupGeometry g; + constexpr float half = 1000.0f; + g.vertices = {{-half, 0, -half}, {-half, 0, half}, {half, 0, half}, {half, 0, -half}}; + phys::SoupGeometry::Poly p; + p.v = {0, 1, 2, 3}; + p.count = 4; + g.polys.push_back(p); + g.materialNames = {"_default"}; + phys::PolygonSoup soup; + soup.add(g, Mat34::identity(), phys::MaterialTable{}); + soup.finalize(64.0f); + return soup; +} + +struct CarState { + Mat34 matrix; + Vec3 velocity, spin; + float rpm = 0.0f; + int gear = 0; + bool operator==(const CarState& o) const { + for (int i = 0; i < 4; ++i) + if (!(matrix.row(i) == o.matrix.row(i))) + return false; + return velocity == o.velocity && spin == o.spin && rpm == o.rpm && gear == o.gear; + } +}; + +CarState stateOf(const game::SimVehicle& car) { + const auto& s = car.sim(); + return {s.body.ics.matrix, s.body.ics.linearVelocity, s.body.ics.angularVelocity, s.engine.rpm, + s.trans.getCurrentGear()}; +} + +// The car after each of `samples` physics samples, driven with fixed +// pedals over frames of the lengths `frameTime` gives. With `drawn`, the +// race screen's presentation runs as it does in a race: the step observer +// records the car's pose before each sample and every frame asks for the +// pose as drawn. +std::vector drive(const vfs::Vfs& vfs, int samples, const std::function& frameTime, + bool drawn) { + phys::World world; + world.setStatic(floorSoup()); + std::string error; + auto car = game::SimVehicle::loadPlayer(vfs, "vpmustang99", &error); + EXPECT_TRUE(car) << error; + if (!car) + return {}; + car->addTo(world); + car->reset(Mat34::translation({0, 1.0f, 0})); + std::vector states; + StepHistory history; + const auto key = drawnKey(Drawn::Player, 0); + world.setStepObserver([&] { + states.push_back(stateOf(*car)); // the state after the sample before + if (drawn) { + history.beginStep(); + history.record(key, car->pose(), car->sim().resets); + } + }); + for (int frame = 0; static_cast(states.size()) <= samples; ++frame) { + car->sim().setInputs(1.0f, 0.0f, 0.15f, 0.0f); + world.advanceFixed(frameTime(frame)); + if (drawn) { + history.setAlpha(world.interpolationAlpha()); + const game::VehiclePose pose = history.pose(key, car->pose(), car->sim().resets); + EXPECT_TRUE(std::isfinite(pose.body.m3.x)); + } + } + states.resize(static_cast(samples) + 1); + return states; +} + +} // namespace + +// The presentation only reads: the car's every sample is the same at 60, 144 +// and 240 fps and with uneven frames, with and without the drawing's +// history. +TEST(StepHistory, TheSimulationIsTheSameWhateverTheFrameTimes) { + MM2_REQUIRE_GAME_DATA(); + const vfs::Vfs& vfs = *test::gameData(); + constexpr int kSamples = 600; // 10 s + const auto reference = drive(vfs, kSamples, [](int) { return 1.0f / 60.0f; }, false); + ASSERT_EQ(reference.size(), static_cast(kSamples + 1)); + EXPECT_GT(reference.back().velocity.mag(), 20.0f) << "the car is at speed"; + std::uint32_t seed = 12345; + const std::function runs[] = { + [](int) { return 1.0f / 60.0f; }, + [](int) { return 1.0f / 144.0f; }, + [](int) { return 1.0f / 240.0f; }, + [](int) { return 1.0f / 90.0f; }, + [&seed](int) { + seed = seed * 1103515245u + 12345u; + return 0.002f + static_cast((seed >> 16) % 1000) * 0.00004f; // 2..42 ms + }, + }; + for (std::size_t r = 0; r < std::size(runs); ++r) { + const auto states = drive(vfs, kSamples, runs[r], true); + ASSERT_EQ(states.size(), reference.size()) << r; + for (std::size_t k = 0; k < states.size(); ++k) + ASSERT_TRUE(states[k] == reference[k]) << "run " << r << " differs at sample " << k; + } +} + +// The AI's traffic and pedestrians step for step, with the drawing's +// history kept by its step observer or not. +TEST(StepHistory, TheTrafficIsTheSameWhateverTheFrameTimes) { + MM2_REQUIRE_GAME_DATA(); + const vfs::Vfs& vfs = *test::gameData(); + const auto city = city::loadCity(vfs, "london"); + ASSERT_TRUE(city); + const Vec3 spot{430, 0, -150}; + constexpr int kSteps = 150; // 5 s + auto run = [&](float frame, bool drawn) { + ai::Settings settings; + auto world = ai::World::create(*city, vfs, settings); + std::vector> states; + StepHistory history; + auto snapshot = [&] { + std::vector s; + for (const auto& c : world->cars()) + s.push_back(c.transform); + for (const auto& p : world->peds()) + s.push_back(p.transform); + return s; + }; + world->setStepObserver([&] { + states.push_back(snapshot()); + if (drawn) + game::recordAiStep(history, *world); + }); + while (static_cast(states.size()) <= kSteps) { + world->update(frame, spot, {}); + if (drawn) { + history.setAlpha(world->interpolationAlpha()); + for (const auto& c : world->cars()) + history.transform(drawnKey(Drawn::RailCar, static_cast(c.id)), c.transform, + static_cast(c.spawns)); + } + } + states.resize(kSteps + 1); + return states; + }; + const auto reference = run(1.0f / 30.0f, false); + ASSERT_GT(reference.back().size(), 10u) << "traffic and pedestrians"; + for (const float frame : {1.0f / 60.0f, 1.0f / 144.0f, 1.0f / 240.0f}) { + const auto states = run(frame, true); + for (std::size_t k = 0; k < states.size(); ++k) { + ASSERT_EQ(states[k].size(), reference[k].size()) << frame << " step " << k; + for (std::size_t i = 0; i < states[k].size(); ++i) + for (int row = 0; row < 4; ++row) + ASSERT_EQ(states[k][i].row(row), reference[k][i].row(row)) << frame << " step " << k; + } + } +} + +// A client's shared traffic is drawn a physics step behind the time its +// physics proxies are placed at (TrafficClient::transformAt), from the +// snapshots update() keeps for it; before a car's first state, at that state. +TEST(StepHistory, SharedTrafficIsSampledWhereTheSceneIsDrawn) { + auto car = [](float z) { + game::SharedCar c; + c.id = 5; + c.model = 1; + c.transform = Mat34::translation({0, 0, z}); + c.speed = 10.0f; + c.velocity = {0, 0, -10.0f}; + return c; + }; + auto message = [](std::uint32_t time, const game::SharedCar& c) { + game::TrafficHost host; + const std::vector list{c}; + net::AmbientStateMsg out; + const auto msg = host.build({1, {0, 0, 0}}, list, time, 0, 0x1234); + EXPECT_TRUE(net::decodeMessage(net::encodeMessage(msg), out)); + return out; + }; + game::TrafficClient client(8, 0x1234); + client.receive(message(1000, car(0.0f))); + client.receive(message(1050, car(-0.5f))); + client.receive(message(1100, car(-1.0f))); + client.update(1100.0); + ASSERT_EQ(client.cars().size(), 1u); + const float now = client.cars()[0].transform.m3.z; // the positions are quantised on the wire + EXPECT_NEAR(now, -1.0f, 0.02f); + // At 10 m/s the car was a step's 16.7 cm further back. + const double drawn = 1100.0 - phys::kFixedSampleStep * 1000.0; + const auto m = client.transformAt(5, drawn); + ASSERT_TRUE(m); + EXPECT_NEAR(m->m3.z - now, 10.0f * phys::kFixedSampleStep, 1e-3f); + // Before the car's first state: there. + const auto first = client.transformAt(5, 900.0); + ASSERT_TRUE(first); + EXPECT_NEAR(first->m3.z - now, 1.0f, 1e-3f); + EXPECT_FALSE(client.transformAt(6, drawn)); +} + +// The particles the effects update at a fixed 60 Hz are drawn back along +// their velocities by FixedTicker::behind: where they were between their last +// two updates, as the rest of the scene is drawn. +TEST(StepHistory, ParticlesAreDrawnBetweenTheirLastTwoUpdates) { + using game::fx::FixedTicker; + FixedTicker ticker; + EXPECT_EQ(ticker.advance(0.01f), 0); + EXPECT_NEAR(ticker.behind(), FixedTicker::kStep - 0.01f, 1e-6f); + EXPECT_EQ(ticker.advance(0.01f), 1); + EXPECT_NEAR(ticker.behind(), 2.0f * FixedTicker::kStep - 0.02f, 1e-6f); + + game::fx::BirthRule rule; + rule.velocity = {3.0f, 8.0f, -2.0f}; + rule.life = 5.0f; + rule.drag = 0.1f; + rule.gravity = -9.8f; + game::fx::ParticleSystem system; + system.init(4, 1, 1); + system.setBirthRule(&rule); + system.blast(1); + system.update(FixedTicker::kStep); + ASSERT_EQ(system.count(), 1); + const Vec3 before = system.positions()[0].position; + system.update(FixedTicker::kStep); + const Vec3 now = system.positions()[0].position; + EXPECT_GT((now - before).mag(), 0.1f); + // A whole step behind: where it was at the update before. + EXPECT_LT((now - system.info()[0].velocity * FixedTicker::kStep - before).mag(), 1e-5f); +} diff --git a/tests/game/test_netgame.cpp b/tests/game/test_netgame.cpp index d535125..150f9c5 100644 --- a/tests/game/test_netgame.cpp +++ b/tests/game/test_netgame.cpp @@ -4,6 +4,7 @@ #include #include +#include #include #include #include @@ -142,12 +143,19 @@ TEST(NetGame, HostJoinChatReadyCountdownAndState) { ASSERT_TRUE(pump({&host, &client}, [&] { return client.raceConfig().laps == 2; })); EXPECT_EQ(client.raceConfig().city, "london"); - // Countdown: both sides get the race start, at the same session time. - host.startRace(400); + // GO DRIVE: both sides load the race; its start follows once both have + // reported it loaded, at the same session time. + host.startRace(); ASSERT_TRUE(pump({&host, &client}, [&] { return client.phase() == NetGame::Phase::Countdown; })); EXPECT_TRUE(host.takeRaceStart()); EXPECT_TRUE(client.takeRaceStart()); EXPECT_FALSE(client.takeRaceStart()); // only once + EXPECT_EQ(host.raceOrderTime(), client.raceOrderTime()); + EXPECT_FALSE(client.raceStartKnown()); + EXPECT_TRUE(std::isinf(client.secondsToStart())); + host.reportLoaded(); + client.reportLoaded(); + ASSERT_TRUE(pump({&host, &client}, [&] { return client.raceStartKnown(); })); EXPECT_EQ(host.raceStartTime(), client.raceStartTime()); EXPECT_GT(client.secondsToStart(), 0.0); ASSERT_TRUE(pump({&host, &client}, [&] { return host.raceStarted() && client.raceStarted(); })); diff --git a/tests/game/test_netgame_lobby.cpp b/tests/game/test_netgame_lobby.cpp index 8f4a2a7..6dd11f4 100644 --- a/tests/game/test_netgame_lobby.cpp +++ b/tests/game/test_netgame_lobby.cpp @@ -81,15 +81,18 @@ struct Lobby { client.setReady(true); ASSERT_TRUE(pump({&host, &client}, [&] { return host.everyoneReady(false); })); } - // GO DRIVE: both machines see the race start once. + // GO DRIVE: both machines see the race start once, load it (nothing to + // load here) and report it loaded. void start() { - host.startRace(200); + host.startRace(); bool h = false, c = false; ASSERT_TRUE(pump({&host, &client}, [&] { h = h || host.takeRaceStart(); c = c || client.takeRaceStart(); return h && c; })); + host.reportLoaded(); + client.reportLoaded(); ASSERT_TRUE(pump({&host, &client}, [&] { return host.raceStarted() && client.raceStarted(); })); } }; @@ -171,7 +174,7 @@ TEST(NetGameLobby, ReturnAndNewCountdownInOneUpdate) { ASSERT_NO_FATAL_FAILURE(l.start()); const std::uint32_t first = l.client.raceNumber(); l.host.returnToLobby(); - l.host.startRace(500); // MultiAllReady is the lobby's check, not the session's + l.host.startRace(); // MultiAllReady is the lobby's check, not the session's // Only the host is serviced until both messages have left it. settle({&l.host}, 100); ASSERT_TRUE(pump({&l.client}, [&] { return l.client.raceNumber() == first + 1; })); diff --git a/tests/game/test_netgame_start.cpp b/tests/game/test_netgame_start.cpp new file mode 100644 index 0000000..3e85251 --- /dev/null +++ b/tests/game/test_netgame_start.cpp @@ -0,0 +1,393 @@ +// A network race's start on each machine (game::NetRaceStart, MM2's +// mmMultiRace / mmMultiCircuit / mmMultiBlitz::UpdateGame state 0) and the +// handshake through game::NetGame (docs/multiplayer.md, "Race start"). +#include "game/Strings.h" +#include "game/net/NetGame.h" +#include "game/net/RaceStart.h" + +#include + +#include +#include +#include +#include +#include +#include + +using namespace mm2; +using game::NetGame; +using game::NetRaceStart; + +namespace { + +using Clock = std::chrono::steady_clock; +constexpr float kFrame = 1.0f / 60.0f; + +// Each test file has its own block of ports, all below 49152 (Windows' +// dynamic range) and below Linux's ephemeral range; within the block, +// 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); + return port; +} + +game::NetOptions options(const std::string& name, std::uint32_t loadWaitMs = 60000) { + game::NetOptions o; + o.playerName = name; + o.port = basePort(); + o.portMapping = false; // never touch a real router from tests + o.discoveryPort = static_cast(basePort() + 1); + o.loadWaitMs = loadWaitMs; + return o; +} + +bool pump(std::initializer_list games, const std::function& done, int timeoutMs = 4000) { + const auto end = Clock::now() + std::chrono::milliseconds(timeoutMs); + while (Clock::now() < end) { + for (NetGame* g : games) + g->update(); + if (done()) + return true; + std::this_thread::sleep_for(std::chrono::milliseconds(2)); + } + return false; +} + +game::RaceConfig raceIn(game::GameMode mode) { + game::RaceConfig c; + c.mode = mode; + c.city = "sf"; + c.raceIndex = mode == game::GameMode::Circuit ? 0 : -1; + return c; +} + +// A host and a joined client, ordered to load a race. +struct Pair { + NetGame host; + NetGame client; + + explicit Pair(game::GameMode mode, std::uint32_t loadWaitMs = 60000) + : host(options("Hosty", loadWaitMs)), client(options("Clienty")) { + std::string err; + game::NetHostOptions h; + h.sessionName = "Start test"; + h.advertiseOnLan = false; + EXPECT_TRUE(host.host(raceIn(mode), h, {"vpbug", 0, 0}, &err)) << err; + EXPECT_TRUE(client.join(std::format("127.0.0.1:{}", basePort()), "", {"vpcab", 0, 1}, &err)) << err; + EXPECT_TRUE(pump({&host, &client}, [&] { + return client.phase() == NetGame::Phase::Lobby && host.players().size() == 2; + })); + pump({&host, &client}, [] { return false; }, 400); // the clock sync burst + host.startRace(); + EXPECT_TRUE(pump({&host, &client}, [&] { return client.phase() == NetGame::Phase::Countdown; })); + EXPECT_TRUE(host.takeRaceStart()); + EXPECT_TRUE(client.takeRaceStart()); + } +}; + +// One machine's race after it has loaded: its NetRaceStart once a frame. +struct Machine { + NetGame& net; + NetRaceStart start; + std::optional loadedAt; // the race screen is running from here + int maxWaitingFor = 0; + std::optional countdownAt, goAt; // session times + bool everReleasedEarly = false; + + Machine(NetGame& n, game::GameMode mode) : net(n), start(NetRaceStart::kindOf(mode)) {} + void frame(float dt) { + if (!loadedAt || Clock::now() < *loadedAt) + return; + const auto out = start.update(dt, net); + maxWaitingFor = std::max(maxWaitingFor, out.waitingFor); + if (out.countdownBegan) + countdownAt = net.frameTime(); + if (out.went) + goAt = net.frameTime(); + if (!out.held && !start.gone()) + everReleasedEarly = true; + } +}; + +// Runs both machines at about 60 frames a second until `done`. +bool run(Machine& a, Machine& b, const std::function& done, int timeoutMs) { + const auto end = Clock::now() + std::chrono::milliseconds(timeoutMs); + auto last = Clock::now(); + while (Clock::now() < end) { + a.net.update(); + b.net.update(); + const auto now = Clock::now(); + const float dt = std::chrono::duration(now - last).count(); + last = now; + a.frame(dt); + b.frame(dt); + if (done()) + return true; + std::this_thread::sleep_for(std::chrono::microseconds(16600)); + } + return false; +} + +} // namespace + +// --- NetRaceStart on its own ----------------------------------------------------------- + +// mmMultiRace::UpdateGame state 0: 5 s of frames held and silent, RaceReady, +// then "Waiting for N players" while others have not reported. +TEST(NetRaceStart, RaceReportsAfterFiveSecondsThenWaits) { + NetRaceStart s(NetRaceStart::Kind::Race); + NetRaceStart::Input in; + in.othersLoading = 2; + float t = 0.0f; + int reports = 0; + while (t < 5.0f) { + const auto out = s.update(kFrame, in); + reports += out.report; + EXPECT_TRUE(out.held); + EXPECT_EQ(out.waitingFor, 0) << "nothing is shown before RaceReady"; + EXPECT_FALSE(out.secondsToGo.has_value()); + t += kFrame; + } + EXPECT_EQ(reports, 0); + auto out = s.update(kFrame, in); // +0x408 is past 5.0 now + for (int i = 0; i < 2 && !out.report; ++i) + out = s.update(kFrame, in); + EXPECT_TRUE(out.report); + EXPECT_EQ(out.waitingFor, 2); + in.othersLoading = 1; + out = s.update(kFrame, in); + EXPECT_FALSE(out.report); // once + EXPECT_EQ(out.waitingFor, 1); + in.othersLoading = 0; + out = s.update(kFrame, in); + EXPECT_EQ(out.waitingFor, 0); + EXPECT_TRUE(out.held); +} + +// The countdown follows the shared start: Ready... from 2.5 s before it, the +// car released at it. +TEST(NetRaceStart, CountdownFollowsTheSharedStart) { + NetRaceStart s(NetRaceStart::Kind::CopsAndRobbers); + EXPECT_EQ(NetRaceStart::countdownSeconds(NetRaceStart::Kind::Race), 2.5f); + EXPECT_EQ(NetRaceStart::countdownSeconds(NetRaceStart::Kind::CopsAndRobbers), 0.0f); + NetRaceStart r(NetRaceStart::Kind::Race); + NetRaceStart::Input in; + in.startKnown = true; // the start came during the settle: it goes first + in.secondsToStart = 2.7; + auto out = r.update(kFrame, in); + EXPECT_TRUE(out.report); + ASSERT_TRUE(out.secondsToGo); + EXPECT_FLOAT_EQ(*out.secondsToGo, 2.7f); + EXPECT_FALSE(out.countdownBegan); + EXPECT_TRUE(out.held); + in.secondsToStart = 2.49; + out = r.update(kFrame, in); + EXPECT_TRUE(out.countdownBegan); + in.secondsToStart = 0.001; + out = r.update(kFrame, in); + EXPECT_TRUE(out.held); + EXPECT_FALSE(out.went); + in.secondsToStart = -0.002; + out = r.update(kFrame, in); + EXPECT_FALSE(out.held); + EXPECT_TRUE(out.went); + out = r.update(kFrame, in); + EXPECT_FALSE(out.went); // once + EXPECT_FALSE(r.ownCountdown()); +} + +// A machine loaded after the start (the host stopped waiting for it) counts +// the whole countdown on its own, as MM2 does with a start message that +// waited in its queue (0x20f in state 0). +TEST(NetRaceStart, LoadedAfterTheStartCountsDownOnItsOwn) { + NetRaceStart r(NetRaceStart::Kind::Race); + NetRaceStart::Input in; + in.startKnown = true; + in.secondsToStart = -12.0; + auto out = r.update(kFrame, in); + EXPECT_TRUE(out.report); + EXPECT_TRUE(r.ownCountdown()); + ASSERT_TRUE(out.secondsToGo); + EXPECT_FLOAT_EQ(*out.secondsToGo, 2.5f); + EXPECT_TRUE(out.countdownBegan); + EXPECT_TRUE(out.held); + int frames = 1; + while (out.held && frames < 1000) { + in.secondsToStart -= kFrame; + out = r.update(kFrame, in); + ++frames; + } + EXPECT_TRUE(out.went); + EXPECT_NEAR(static_cast(frames - 1) * kFrame, 2.5f, 1.5f * kFrame); + + // Cops and Robbers has no countdown: it goes at once. + NetRaceStart cr(NetRaceStart::Kind::CopsAndRobbers); + out = cr.update(kFrame, in); + EXPECT_TRUE(out.report); + EXPECT_TRUE(out.went); + EXPECT_FALSE(out.held); +} + +// mmMultiCR::Init and mmMultiRoam::Init report at the end of the loading; +// a cruise is never held, Cops and Robbers until the start. +TEST(NetRaceStart, CruiseAndCopsAndRobbersReportAtOnce) { + NetRaceStart cruise(NetRaceStart::Kind::Cruise); + NetRaceStart cr(NetRaceStart::Kind::CopsAndRobbers); + NetRaceStart::Input in; + in.othersLoading = 1; + auto a = cruise.update(kFrame, in); + auto b = cr.update(kFrame, in); + EXPECT_TRUE(a.report); + EXPECT_FALSE(a.held); + EXPECT_EQ(a.waitingFor, 0); + EXPECT_TRUE(b.report); + EXPECT_TRUE(b.held); + EXPECT_EQ(b.waitingFor, 1); + EXPECT_EQ(NetRaceStart::kindOf(game::GameMode::Cruise), NetRaceStart::Kind::Cruise); + EXPECT_EQ(NetRaceStart::kindOf(game::GameMode::CopsAndRobbers), NetRaceStart::Kind::CopsAndRobbers); + for (auto m : {game::GameMode::Blitz, game::GameMode::Circuit, game::GameMode::Checkpoint}) + EXPECT_EQ(NetRaceStart::kindOf(m), NetRaceStart::Kind::Race); +} + +// Strings 31-37, "Waiting for 1 player" ... "Waiting for 7 players". +TEST(NetRaceStart, WaitingText) { + const auto none = game::Strings::fromTable({}); + EXPECT_EQ(NetRaceStart::waitingText(none, 1), "Waiting for 1 player"); + EXPECT_EQ(NetRaceStart::waitingText(none, 3), "Waiting for 3 players"); + EXPECT_EQ(NetRaceStart::waitingText(none, 12), "Waiting for 12 players"); + data::PeStringTable table; + table.strings[31] = "One left"; + table.strings[37] = "Seven left"; + table.strings[38] = "has left the game"; + const auto strings = game::Strings::fromTable(table); + EXPECT_EQ(NetRaceStart::waitingText(strings, 1), "One left"); + EXPECT_EQ(NetRaceStart::waitingText(strings, 7), "Seven left"); + EXPECT_EQ(NetRaceStart::waitingText(strings, 8), "Waiting for 8 players"); +} + +// --- Through the network --------------------------------------------------------------- + +// A circuit where the client takes a second longer to load: the host waits +// (after its own 5 s it shows "Waiting for 1 player"), and once both have +// reported both count down from the same session time and go together. +TEST(NetGameStart, SlowLoaderIsWaitedForAndBothGoTogether) { + Pair p(game::GameMode::Circuit); + Machine host(p.host, game::GameMode::Circuit), client(p.client, game::GameMode::Circuit); + host.loadedAt = Clock::now(); + client.loadedAt = Clock::now() + std::chrono::milliseconds(1200); + ASSERT_TRUE(run(host, client, [&] { return host.goAt && client.goAt; }, 15000)); + EXPECT_EQ(host.maxWaitingFor, 1); + 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. + 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); + EXPECT_FALSE(host.everReleasedEarly); + EXPECT_FALSE(client.everReleasedEarly); +} + +// A player whose loading never ends: the host starts without it after its +// wait; when it does load it joins the running race on its own countdown, +// and its car shows on the host only from its report (MM2 activates a +// network car on its RaceReady). +TEST(NetGameStart, PlayerStillLoadingAfterTheWaitJoinsTheRunningRace) { + Pair p(game::GameMode::CopsAndRobbers, 800); + Machine host(p.host, game::GameMode::CopsAndRobbers), client(p.client, game::GameMode::CopsAndRobbers); + host.loadedAt = Clock::now(); + // The client is "loading": its race sends nothing yet. The host waits. + ASSERT_TRUE(run(host, client, [&] { return host.goAt.has_value(); }, 4000)); + EXPECT_FALSE(p.host.playerLoaded(p.client.localId())); + EXPECT_TRUE(p.client.raceStartKnown()); // its session hears of the start while it loads + // A state from the client before its report is not shown. + net::VehicleControls ctl; + p.client.submitLocalState(Mat34::rotationY(0.0f), {}, {}, ctl, 0.0f, 0); + pump({&p.host, &p.client}, [] { return false; }, 300); + auto cars = p.host.remoteCars(); + ASSERT_EQ(cars.size(), 1u); + EXPECT_FALSE(cars[0].hasState); + + client.loadedAt = Clock::now(); + ASSERT_TRUE(run(host, client, [&] { return client.goAt.has_value(); }, 3000)); + EXPECT_TRUE(client.start.ownCountdown()); + EXPECT_GT(*client.goAt, static_cast(p.client.raceStartTime())); + ASSERT_TRUE(pump({&p.host, &p.client}, [&] { + p.client.submitLocalState(Mat34::rotationY(0.0f), {}, {}, ctl, 0.0f, 0); + cars = p.host.remoteCars(); + return p.host.playerLoaded(p.client.localId()) && cars.size() == 1 && cars[0].hasState; + })); +} + +// A joiner who quits the race (or cannot load it) before reporting no longer +// holds the others' start (MM2: SystemMessage 0x2d in state 0). +TEST(NetGameStart, PlayerLeavingTheRaceReleasesTheStart) { + Pair p(game::GameMode::CopsAndRobbers); + p.host.reportLoaded(); + pump({&p.host, &p.client}, [] { return false; }, 200); + EXPECT_FALSE(p.host.raceStartKnown()); + EXPECT_EQ(p.host.playersLoading(), 1); + p.client.sendLeftRace(); + ASSERT_TRUE(pump({&p.host, &p.client}, [&] { return p.host.raceStartKnown(); })); + EXPECT_EQ(p.host.playersLoading(), 0); +} + +// Either machine may report first; the start comes with the second report +// and is the same on both, race after race. +TEST(NetGameStart, EitherMachineMayReportFirst) { + Pair p(game::GameMode::Checkpoint); + for (int round = 0; round < 2; ++round) { + NetGame& first = round == 0 ? p.client : p.host; + NetGame& second = round == 0 ? p.host : p.client; + first.reportLoaded(); + ASSERT_TRUE(pump({&p.host, &p.client}, [&] { + return p.host.playerLoaded(first.localId()) && p.client.playerLoaded(first.localId()); + })); + pump({&p.host, &p.client}, [] { return false; }, 100); + EXPECT_FALSE(p.host.raceStartKnown()); + EXPECT_FALSE(p.client.raceStartKnown()); + EXPECT_EQ(second.playersLoading(), 0); + EXPECT_EQ(first.playersLoading(), 1); + second.reportLoaded(); + ASSERT_TRUE( + pump({&p.host, &p.client}, [&] { return p.host.raceStarted() && p.client.raceStarted(); })); + EXPECT_EQ(p.host.raceStartTime(), p.client.raceStartTime()); + EXPECT_EQ(p.host.raceNumber(), p.client.raceNumber()); + // The countdown and a lead after the second report. + EXPECT_GE(p.host.raceStartTime(), p.host.raceOrderTime() + 2500u + 200u); + if (round == 0) { + p.host.returnToLobby(); + ASSERT_TRUE( + pump({&p.host, &p.client}, [&] { return p.client.phase() == NetGame::Phase::Lobby; })); + p.host.startRace(); + ASSERT_TRUE(pump({&p.host, &p.client}, [&] { return p.client.takeRaceStart(); })); + EXPECT_TRUE(p.host.takeRaceStart()); + } + } +} + +// The client reports the race it is loading just as the host has gone back +// to the lobby and ordered the next one: that report does not count for the +// next race, whose start waits for the client's report of it. +TEST(NetGameStart, AReportForAnEarlierRaceDoesNotCount) { + Pair p(game::GameMode::Checkpoint); + const std::uint32_t first = p.client.raceNumber(); + p.host.returnToLobby(); + p.host.startRace(); + p.client.reportLoaded(); // still race `first` here + ASSERT_TRUE(pump({&p.host, &p.client}, [&] { return p.client.raceNumber() == first + 1; })); + pump({&p.host, &p.client}, [] { return false; }, 150); + EXPECT_FALSE(p.host.playerLoaded(p.client.localId())); + EXPECT_TRUE(p.client.backToLobby(first)); + EXPECT_TRUE(p.client.takeRaceStart()); + p.host.reportLoaded(); + pump({&p.host, &p.client}, [] { return false; }, 150); + EXPECT_FALSE(p.host.raceStartKnown()); + EXPECT_EQ(p.client.playersLoading(), 0); + p.client.reportLoaded(); + ASSERT_TRUE(pump({&p.host, &p.client}, [&] { return p.client.raceStartKnown(); })); + EXPECT_EQ(p.host.raceStartTime(), p.client.raceStartTime()); +} diff --git a/tests/game/test_netgame_sync.cpp b/tests/game/test_netgame_sync.cpp index bb1d263..2f00033 100644 --- a/tests/game/test_netgame_sync.cpp +++ b/tests/game/test_netgame_sync.cpp @@ -55,8 +55,12 @@ TEST(NetGameSync, StatesCarryTheSimulationTimeAndCarsAreSampledWithItsLag) { ASSERT_TRUE(client.join(std::format("127.0.0.1:{}", syncPort()), "", {"vpbug", 0, 0}, &err)) << err; ASSERT_TRUE(pump({&host, &client}, [&] { return host.players().size() == 2; })); pump({&host, &client}, [] { return false; }, 400); // clock sync - host.startRace(200); - ASSERT_TRUE(pump({&host, &client}, [&] { return host.raceStarted() && client.raceStarted(); })); + host.startRace(); + ASSERT_TRUE(pump({&host, &client}, [&] { + host.reportLoaded(); // nothing to load + client.reportLoaded(); + return host.raceStarted() && client.raceStarted(); + })); // The client's simulation is 10 ms behind each frame; its car runs along // +X at 10 m/s, at x = time / 100 of the session time the state belongs diff --git a/tests/game/test_traffic_net.cpp b/tests/game/test_traffic_net.cpp index 99c0329..729aaf9 100644 --- a/tests/game/test_traffic_net.cpp +++ b/tests/game/test_traffic_net.cpp @@ -78,8 +78,12 @@ TEST(SharedTrafficNet, HostAndClientInOneProcess) { EXPECT_TRUE(client.sharedTraffic()); // In the lobby nothing is sent. EXPECT_EQ(host.sendAmbientState(client.localId(), net::AmbientStateMsg{}), 0u); - host.startRace(200); - ASSERT_TRUE(pump({&host, &client}, [&] { return host.raceStarted() && client.raceStarted(); })); + host.startRace(); + ASSERT_TRUE(pump({&host, &client}, [&] { + host.reportLoaded(); // nothing to load + client.reportLoaded(); + return host.raceStarted() && client.raceStarted(); + })); // Two cars along -Z at 10 m/s and a cop chasing the client. TrafficHost th; diff --git a/tests/net/test_ambient_state.cpp b/tests/net/test_ambient_state.cpp index 6e57773..3072c34 100644 --- a/tests/net/test_ambient_state.cpp +++ b/tests/net/test_ambient_state.cpp @@ -232,8 +232,10 @@ TEST(AmbientState, SizesFitTheBudget) { offRail.flags |= kAmbientOffRail; const AmbientEntity cop = copCar(2, {}); EXPECT_EQ(ambientEntityBits(rail), 119u); - EXPECT_EQ(ambientEntityBits(offRail), 177u); - EXPECT_EQ(ambientEntityBits(cop), 204u); + // 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); + EXPECT_EQ(ambientEntityBits(cop), 208u); AmbientEntity held = rail; held.hasState = false; EXPECT_EQ(ambientEntityBits(held), 13u); @@ -290,8 +292,10 @@ TEST(AmbientState, HostSendsEachClientItsOwnState) { EXPECT_TRUE(a.settings().sharedTraffic); // Not in the lobby: the cars are replicated during a race only. EXPECT_EQ(host.sendAmbientState(a.localId(), sampleMessage()), 0u); - host.startCountdown(100); + host.startRace(100); ASSERT_TRUE(pumpUntil({&host, &a, &b}, [&] { + for (Session* s : {&host, &a, &b}) + s->reportLoaded(); // nothing to load return a.phase() == SessionPhase::InGame && b.phase() == SessionPhase::InGame; })); diff --git a/tests/net/test_fuzz.cpp b/tests/net/test_fuzz.cpp index 870cc4d..c8ca8e4 100644 --- a/tests/net/test_fuzz.cpp +++ b/tests/net/test_fuzz.cpp @@ -7,6 +7,7 @@ #include "net/Discovery.h" #include "net/NatPmp.h" #include "net/Session.h" +#include "net/VehicleDamage.h" #include @@ -83,7 +84,11 @@ std::vector corpus() { w.players.push_back(samplePlayer(i)); w.phase = SessionPhase::Countdown; w.hostTime = 123456; - w.countdownEnd = 130000; + w.race = 4; + w.raceOrderTime = 120000; + w.startKnown = true; + w.startTime = 130000; + w.loaded = {0, 2, 5}; c.push_back(encodeMessage(w)); c.push_back(encodeMessage(RejectMsg{DisconnectReason::ServerFull, "full"})); c.push_back(encodeMessage(PlayerJoinedMsg{samplePlayer(4)})); @@ -92,7 +97,9 @@ std::vector corpus() { c.push_back(encodeMessage(PlayerRequestMsg{"vpcoop", 4, 1, true})); c.push_back(encodeMessage(ChatMsg{1, "hello there, é"})); c.push_back(encodeMessage(SettingsMsg{sampleSettings()})); - c.push_back(encodeMessage(CountdownMsg{99999})); + c.push_back(encodeMessage(RaceLoadMsg{2, 99999})); + c.push_back(encodeMessage(RaceLoadedMsg{2, 3})); + c.push_back(encodeMessage(RaceStartMsg{2, 104000})); c.push_back(encodeMessage(ReturnToLobbyMsg{})); c.push_back(encodeMessage(KickMsg{"bye"})); c.push_back(encodeMessage(TimeRequestMsg{777})); @@ -137,6 +144,11 @@ std::vector corpus() { car.velocity = {3.0f, -1.0f, 2.0f}; car.angularVelocity = {0.5f, 1.0f, -0.5f}; ambient.entities.push_back(car); + car.id = 14; // knocked, with its body's wheels + car.wheels = true; + car.wheelOffsets = {Vec3{0.01f, 0.1f, 0.0f}, Vec3{0.0f, -0.05f, 0.02f}, Vec3{}, Vec3{0.1f, 0.9f, -0.2f}}; + ambient.entities.push_back(car); + car.wheels = false; car.id = 401; car.kind = AmbientKind::Police; car.flags = kAmbientSiren | kAmbientPursuit; @@ -163,6 +175,30 @@ std::vector corpus() { c.push_back(encodePayload(FinishEvent{90000, 1})); c.push_back(encodePayload(CollisionEvent{2, {1, 2, 3}, 50.0f})); c.push_back(encodePayload(DamageEvent{0.5f, 3})); + // A car's damage, alone and as the game event that carries it. + VehicleDamageEvent damage; + damage.subject = 402; + damage.epoch = 2; + damage.time = 77777; + damage.first = 30; + for (std::uint32_t i = 0; i < 5; ++i) + damage.patches.push_back({static_cast(i), {0.1f * i, 0.5f, -1.0f}, 0xDA6Au * i}); + damage.parts = 0x0F0F1u; + damage.partsDelay = 9; + DamageImpact hit; + hit.point = {0.4f, 0.6f, 1.8f}; + hit.normal = {0.0f, 0.0f, -1.0f}; + hit.total = 800.0f; + hit.speed = 30.0f; + hit.sound = 9000.0f; + hit.audioId = 2; + damage.impacts = {hit, hit}; + c.push_back(encodePayload(damage)); + GameEventMsg damageEvent; + damageEvent.type = kVehicleDamageEvent; + damageEvent.time = 5000; + damageEvent.payload = encodePayload(damage); + c.push_back(encodeMessage(damageEvent)); // A PCP MAP success (60 bytes) and NAT-PMP replies. Bytes pcp(60, std::byte{0}); @@ -263,6 +299,15 @@ void checkAmbient(const AmbientStateMsg& m) { ASSERT_LE(std::abs(e.position.y - origin.y), kAmbientHeightRange + 0.01f); ASSERT_LE(std::abs(e.velocity.x), kAmbientVelocityRange + 0.01f); ASSERT_TRUE(e.target == kAmbientNoTarget || e.target < kMaxPlayers); + ASSERT_TRUE(!e.wheels || (e.kind == AmbientKind::Traffic && (e.flags & kAmbientOffRail))); + for (const Vec3& w : e.wheelOffsets) { + ASSERT_TRUE(std::isfinite(w.x) && std::isfinite(w.y) && std::isfinite(w.z)); + ASSERT_LE(std::abs(w.x), kAmbientWheelAcross + 0.001f); + ASSERT_LE(std::abs(w.y), kAmbientWheelTravel + 0.001f); + ASSERT_LE(std::abs(w.z), kAmbientWheelAcross + 0.001f); + } + ASSERT_GE(e.damage, 0.0f); + ASSERT_LE(e.damage, 1.0f); ASSERT_GE(e.gear, -1); ASSERT_LE(e.gear, 8); const float n = std::sqrt(e.orientation.x * e.orientation.x + e.orientation.y * e.orientation.y + @@ -311,7 +356,9 @@ std::optional payload(std::span b) { void decodeEverything(std::span b) { (void)peekMessageType(b); (void)decoded(b); - (void)decoded(b); + (void)decoded(b); + (void)decoded(b); + (void)decoded(b); (void)decoded(b); (void)decoded(b); (void)decoded(b); @@ -326,6 +373,7 @@ void decodeEverything(std::span b) { for (const auto& p : m->players) checkPlayer(p); ASSERT_LE(m->phase, SessionPhase::Last); + ASSERT_LE(m->loaded.size(), kMaxPlayers); } if (const auto m = decoded(b)) { ASSERT_LE(m->reason, DisconnectReason::Last); @@ -393,6 +441,29 @@ 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->subject, kDamageOwnCar); + ASSERT_LE(e->patches.size(), kMaxDamagePatches); + ASSERT_LE(e->first + e->patches.size(), kMaxDamageRecord); + ASSERT_LE(e->impacts.size(), kMaxDamageImpacts); + ASSERT_EQ(e->parts >> kDamagePartCount, 0u); + for (const auto& p : e->patches) + for (float v : {p.point.x, p.point.y, p.point.z}) { + ASSERT_TRUE(std::isfinite(v)); + ASSERT_LE(std::abs(v), kDamagePointRange); + } + for (const auto& m : e->impacts) { + for (float v : + {m.point.x, m.point.y, m.point.z, m.normal.x, m.normal.y, m.normal.z, m.total, m.speed, m.sound}) + ASSERT_TRUE(std::isfinite(v)); + ASSERT_LE(std::abs(m.point.x), kDamagePointRange); + ASSERT_LE(std::abs(m.normal.y), 1.0f); + ASSERT_GE(m.total, 0.0f); + ASSERT_GE(m.sound, 0.0f); + ASSERT_LE(m.speed, kDamageMaxSpeed); + ASSERT_LE(m.audioId, 1000); + } + } // Text cleanup on arbitrary bytes. const std::string_view text(reinterpret_cast(b.data()), b.size()); @@ -410,6 +481,20 @@ bool pumpFor(int ms, const std::function& step) { return true; } +// A session's race: loaded flags only for players it knows, no start in the +// lobby. +void checkRaceState(const Session& s) { + for (int id = 0; id < 256; ++id) { + if (s.playerLoaded(static_cast(id))) { + EXPECT_NE(s.player(static_cast(id)), nullptr) << id; + } + } + if (s.phase() == SessionPhase::Lobby) { + EXPECT_FALSE(s.raceStartKnown()); + EXPECT_FALSE(s.playerLoaded(s.localId())); + } +} + template std::optional takeMessage(std::vector& events) { for (auto it = events.begin(); it != events.end(); ++it) { @@ -510,6 +595,7 @@ TEST(Fuzz, HostSurvivesMutatedTraffic) { }); EXPECT_EQ(host.state(), Session::State::Active); EXPECT_LE(host.players().size(), static_cast(host.settings().maxPlayers)); + checkRaceState(host); for (const auto& p : host.players()) { EXPECT_TRUE(isValidAssetName(p.car)) << p.car; EXPECT_EQ(sanitizeText(p.name, kMaxNameLength), p.name); @@ -577,6 +663,7 @@ TEST(Fuzz, ClientSurvivesMutatedTraffic) { EXPECT_EQ(client.state(), Session::State::Active); EXPECT_LE(client.players().size(), kMaxPlayers); EXPECT_EQ(client.player(kInvalidPlayerId), nullptr); + checkRaceState(client); for (const auto& p : client.players()) { EXPECT_TRUE(isValidAssetName(p.car)) << p.car; EXPECT_EQ(sanitizeText(p.name, kMaxNameLength), p.name); @@ -596,3 +683,113 @@ TEST(Fuzz, ClientSurvivesMutatedTraffic) { for (const auto& m : ambient) checkAmbient(m); } + +// The race start under mutated race messages from a joiner: whatever it +// sends counts at most as its own report; the start comes once, after the +// honest players have reported, and the honest client has the same one. +TEST(Fuzz, RaceStartSurvivesMutatedReports) { + Session host, honest; + HostParams hp; + hp.bind = Address::loopback(0); + hp.advertiseOnLan = false; + hp.player.name = "Host"; + ASSERT_TRUE(host.host(hp)); + JoinParams jp; + jp.host = Address::loopback(host.port()); + jp.player.name = "Honest"; + ASSERT_TRUE(honest.join(jp)); + Transport fuzzer; + ASSERT_TRUE(fuzzer.startClient()); + const PeerId toHost = fuzzer.connect(Address::loopback(host.port())); + std::vector events; + std::optional challenge; + pumpFor(300, [&] { + host.update(); + honest.update(); + fuzzer.service(events); + if (!challenge) + challenge = takeMessage(events); + }); + ASSERT_TRUE(challenge); + HelloMsg hello; + hello.name = "Fuzzer"; + hello.car = "vpbug"; + fuzzer.send(toHost, Channel::Control, encodeMessage(hello)); + pumpFor(300, [&] { + host.update(); + honest.update(); + fuzzer.service(events); + }); + ASSERT_EQ(host.players().size(), 3u); + ASSERT_EQ(honest.players().size(), 3u); + + host.startRace(0); + pumpFor(100, [&] { + host.update(); + honest.update(); + fuzzer.service(events); + }); + ASSERT_EQ(honest.phase(), SessionPhase::Countdown); + const std::uint32_t race = host.raceNumber(); + // Race messages of every kind, for this race and others. + std::vector c; + for (std::uint32_t r : {race - 1, race, race + 1}) { + for (std::uint8_t id : {std::uint8_t{0}, std::uint8_t{1}, std::uint8_t{2}, std::uint8_t{200}}) + c.push_back(encodeMessage(RaceLoadedMsg{r, id})); + c.push_back(encodeMessage(RaceLoadMsg{r, 100})); + c.push_back(encodeMessage(RaceStartMsg{r, 1000})); + } + std::mt19937 rng(31337); + for (int i = 0; i < 2000; ++i) { + fuzzer.send(toHost, static_cast(rng() % kChannelCount), mutate(c, rng)); + if (i % 50 == 0) { + host.update(); + honest.update(); + fuzzer.service(events); + events.clear(); + } + } + pumpFor(300, [&] { + host.update(); + honest.update(); + fuzzer.service(events); + events.clear(); + }); + EXPECT_EQ(host.state(), Session::State::Active); + EXPECT_EQ(host.phase(), SessionPhase::Countdown); + EXPECT_FALSE(host.raceStartKnown()); // the host and the honest client have not reported + EXPECT_FALSE(host.playerLoaded(host.localId())); + EXPECT_FALSE(host.playerLoaded(honest.localId())); + EXPECT_FALSE(honest.raceStartKnown()); + checkRaceState(host); + checkRaceState(honest); + + host.reportLoaded(); + honest.reportLoaded(); + std::vector hostEvents, honestEvents; + pumpFor(400, [&] { + host.update(); + honest.update(); + fuzzer.service(events); + events.clear(); + for (auto& e : host.takeEvents()) + hostEvents.push_back(std::move(e)); + for (auto& e : honest.takeEvents()) + honestEvents.push_back(std::move(e)); + }); + // Without the fuzzer's report the host waits for it (MM2 waits for every + // player in the session); with it, the start is set once. + if (host.playerLoaded(2)) { + ASSERT_TRUE(host.raceStartKnown()); + ASSERT_TRUE(honest.raceStartKnown()); + EXPECT_EQ(honest.raceStartTime(), host.raceStartTime()); + auto starts = [](const std::vector& v) { + return std::ranges::count_if( + v, [](const SessionEvent& e) { return std::holds_alternative(e); }); + }; + EXPECT_EQ(starts(hostEvents), 1); + EXPECT_EQ(starts(honestEvents), 1); + } else { + EXPECT_FALSE(host.raceStartKnown()); + } +} diff --git a/tests/net/test_hostile_input.cpp b/tests/net/test_hostile_input.cpp index 6c71e3e..94e0ebc 100644 --- a/tests/net/test_hostile_input.cpp +++ b/tests/net/test_hostile_input.cpp @@ -4,6 +4,7 @@ #include "net/Discovery.h" #include "net/PortMapper.h" #include "net/Session.h" +#include "net/VehicleDamage.h" #include @@ -371,6 +372,163 @@ TEST(HostileInput, WrongPasswordsLockTheAddressOut) { EXPECT_EQ(host.session->players().size(), 1u); } +// --- The race start --------------------------------------------------------------------- + +// A joiner's race reports count only for itself, only for the race the host +// is starting and only once: one in the lobby, for another race number, with +// another player's id, or repeated changes nothing else and does not reach +// the others again. +TEST(HostileInput, HostCountsOnlyAJoinersOwnFirstReportOfTheRace) { + Peer host; + ASSERT_TRUE(host.session->host(hostParams())); + Peer watcher; + JoinParams jp; + jp.host = Address::loopback(host.session->port()); + jp.player.name = "Watcher"; + ASSERT_TRUE(watcher.session->join(jp)); + ASSERT_TRUE(waitFor([&] { + host.pump(); + watcher.pump(); + return watcher.find() != nullptr; + })); + RawEnd raw; + const std::uint8_t id = rawJoin(host, raw); + ASSERT_NE(id, kInvalidPlayerId); + const std::uint8_t watcherId = watcher.session->localId(); + auto pumpAll = [&](int ms) { + waitFor([&] { + host.pump(); + raw.pump(); + watcher.pump(); + return false; + }, ms); + }; + + // In the lobby nothing is loading. + raw.send(RaceLoadedMsg{0, id}); + raw.send(RaceLoadedMsg{1, id}); + pumpAll(100); + EXPECT_EQ(host.count(), 0); + + host.session->startRace(0); + ASSERT_TRUE(waitFor([&] { + host.pump(); + raw.pump(); + watcher.pump(); + return watcher.session->phase() == SessionPhase::Countdown && raw.take().has_value(); + })); + const std::uint32_t race = host.session->raceNumber(); + raw.send(RaceLoadedMsg{race - 1, id}); + raw.send(RaceLoadedMsg{race + 1, id}); + raw.send(RaceLoadedMsg{0xFFFFFFFFu, id}); + pumpAll(100); + EXPECT_FALSE(host.session->playerLoaded(id)); + EXPECT_EQ(host.count(), 0); + + // Another player's id: the host takes the report as the sender's. + raw.send(RaceLoadedMsg{race, watcherId}); + for (int i = 0; i < 100; ++i) + raw.send(RaceLoadedMsg{race, id}); + pumpAll(200); + EXPECT_TRUE(host.session->playerLoaded(id)); + EXPECT_FALSE(host.session->playerLoaded(watcherId)); + EXPECT_EQ(host.count(), 1); + EXPECT_EQ(watcher.count(), 1); + EXPECT_TRUE(watcher.session->playerLoaded(id)); + // The start still waits for the host and the watcher. + EXPECT_FALSE(host.session->raceStartKnown()); + host.session->reportLoaded(); + watcher.session->reportLoaded(); + ASSERT_TRUE(waitFor([&] { + host.pump(); + raw.pump(); + watcher.pump(); + return watcher.session->raceStartKnown(); + })); + EXPECT_EQ(watcher.session->raceStartTime(), host.session->raceStartTime()); +} + +// A host's race messages out of turn leave a client's race alone: a start +// or a report in the lobby, an order that does not number a new race, a +// start for another race or a second one, a report for a player the client +// does not know, for itself or for another race. +TEST(HostileInput, ClientIgnoresRaceMessagesOutOfTurn) { + RawEnd raw; + Peer client; + rawHost(raw, client, welcomeFor(1)); + ASSERT_EQ(client.session->state(), Session::State::Active); + auto pumpBoth = [&](int ms) { + waitFor([&] { + client.pump(); + raw.pump(); + return false; + }, ms); + }; + raw.send(RaceStartMsg{1, 5000}); + raw.send(RaceLoadedMsg{1, kHostPlayerId}); + pumpBoth(100); + EXPECT_EQ(client.session->phase(), SessionPhase::Lobby); + EXPECT_FALSE(client.session->raceStartKnown()); + EXPECT_FALSE(client.session->playerLoaded(kHostPlayerId)); + + raw.send(RaceLoadMsg{3, 100}); + raw.send(RaceLoadMsg{2, 100}); // an earlier number + raw.send(RaceLoadMsg{3, 100}); // the same race again + raw.send(RaceStartMsg{2, 5000}); + raw.send(RaceLoadedMsg{3, 9}); + raw.send(RaceLoadedMsg{3, 1}); // the client itself: it reports that itself + raw.send(RaceLoadedMsg{2, kHostPlayerId}); + pumpBoth(150); + EXPECT_EQ(client.session->phase(), SessionPhase::Countdown); + EXPECT_EQ(client.session->raceNumber(), 3u); + EXPECT_EQ(client.count(), 1); + EXPECT_FALSE(client.session->raceStartKnown()); + EXPECT_FALSE(client.session->playerLoaded(9)); + EXPECT_FALSE(client.session->playerLoaded(1)); + EXPECT_FALSE(client.session->playerLoaded(kHostPlayerId)); + // The client's own report still goes out, for race 3. + client.session->reportLoaded(); + std::optional report; + ASSERT_TRUE(waitFor([&] { + client.pump(); + raw.pump(); + report = raw.take(); + return report.has_value(); + })); + EXPECT_EQ(report->race, 3u); + EXPECT_EQ(report->player, 1); + + raw.send(RaceLoadedMsg{3, kHostPlayerId}); + raw.send(RaceStartMsg{3, 7000}); + raw.send(RaceStartMsg{3, 9000}); // a second start + pumpBoth(150); + EXPECT_TRUE(client.session->playerLoaded(kHostPlayerId)); + EXPECT_EQ(client.session->raceStartTime(), 7000u); + EXPECT_EQ(client.count(), 1); +} + +// A Welcome during a race: the client takes the race, the start and the +// players who have loaded, leaving out ids it does not know. +TEST(HostileInput, WelcomeDuringARaceKeepsOnlyKnownLoadedPlayers) { + RawEnd raw; + Peer client; + WelcomeMsg w = welcomeFor(1); + w.phase = SessionPhase::Countdown; + w.race = 2; + w.raceOrderTime = 50; + w.loaded = {kHostPlayerId, kHostPlayerId, 7, 200, 1}; + rawHost(raw, client, w); + ASSERT_EQ(client.session->state(), Session::State::Active); + client.pump(); + EXPECT_EQ(client.session->raceNumber(), 2u); + EXPECT_TRUE(client.session->playerLoaded(kHostPlayerId)); + EXPECT_FALSE(client.session->playerLoaded(7)); + EXPECT_FALSE(client.session->playerLoaded(200)); + EXPECT_FALSE(client.session->playerLoaded(1)); // it reports itself + EXPECT_FALSE(client.session->raceStartKnown()); + EXPECT_EQ(client.count(), 1); +} + // --- Floods ----------------------------------------------------------------------------- // A client cannot make the host relay an unbounded stream of chat lines, @@ -422,6 +580,52 @@ TEST(HostileInput, HostRateLimitsWhatItRelays) { })); } +// A car's damage events (net/VehicleDamage.h) have a relay budget of their +// own: a flood of them is cut down, and the race events a player sends after +// it still all reach the others. +TEST(HostileInput, HostRateLimitsDamageEventsOnTheirOwn) { + Peer host; + ASSERT_TRUE(host.session->host(hostParams())); + Peer watcher; + JoinParams jp; + jp.host = Address::loopback(host.session->port()); + jp.player.name = "Watcher"; + ASSERT_TRUE(watcher.session->join(jp)); + ASSERT_TRUE(waitFor([&] { + host.pump(); + watcher.pump(); + return watcher.find() != nullptr; + })); + RawEnd raw; + ASSERT_NE(rawJoin(host, raw), kInvalidPlayerId); + + VehicleDamageEvent damage; + damage.patches = {{0, {0.1f, 0.2f, 0.3f}, 1}}; + GameEventMsg flood; + flood.type = kVehicleDamageEvent; + flood.payload = encodePayload(damage); + for (int i = 0; i < 300; ++i) + raw.send(flood, Channel::Events); + GameEventMsg checkpoint; + checkpoint.type = static_cast(GameEventType::CheckpointReached); + checkpoint.payload = encodePayload(CheckpointEvent{1, 1000}); + for (int i = 0; i < 10; ++i) + raw.send(checkpoint, Channel::Events); + waitFor([&] { + host.pump(); + raw.pump(); + watcher.pump(); + return false; + }, 600); + int damageEvents = 0, raceEvents = 0; + for (const auto& e : watcher.events) + if (const auto* g = std::get_if(&e)) + ++(g->type == kVehicleDamageEvent ? damageEvents : raceEvents); + EXPECT_GT(damageEvents, 0); + EXPECT_LT(damageEvents, 60); // a burst of 30 and 15 a second + EXPECT_EQ(raceEvents, 10); +} + // A peer cannot make the host buffer a huge message (ENet's default limit // is 32 MiB per packet, allocated when the first fragment arrives). TEST(HostileInput, OversizedPacketsAreNotDelivered) { diff --git a/tests/net/test_protocol.cpp b/tests/net/test_protocol.cpp index d0a94fa..8e90da5 100644 --- a/tests/net/test_protocol.cpp +++ b/tests/net/test_protocol.cpp @@ -47,13 +47,25 @@ TEST(Protocol, LobbyMessagesRoundTrip) { w.players = {PlayerInfo{0, "Host", "vpcop", 1, 0, true, true, 0}, PlayerInfo{3, "Joe", "vpbug", 2, 1, false, false, 42}}; w.phase = SessionPhase::Countdown; w.hostTime = 123456; - w.countdownEnd = 130000; + w.race = 7; + w.raceOrderTime = 120000; + w.startKnown = true; + w.startTime = 130000; + w.loaded = {0, 3}; const auto w2 = roundTrip(w); EXPECT_EQ(w2.yourId, 3); EXPECT_EQ(w2.settings, w.settings); EXPECT_EQ(w2.players, w.players); EXPECT_EQ(w2.phase, SessionPhase::Countdown); - EXPECT_EQ(w2.countdownEnd, 130000u); + EXPECT_EQ(w2.race, 7u); + EXPECT_EQ(w2.raceOrderTime, 120000u); + EXPECT_TRUE(w2.startKnown); + EXPECT_EQ(w2.startTime, 130000u); + EXPECT_EQ(w2.loaded, w.loaded); + // At most one entry per player. + w.loaded.assign(kMaxPlayers + 1, 1); + WelcomeMsg tooMany; + EXPECT_FALSE(decodeMessage(encodeMessage(w), tooMany)); HelloMsg h; h.name = "Player"; @@ -72,7 +84,10 @@ TEST(Protocol, LobbyMessagesRoundTrip) { EXPECT_EQ(roundTrip(RejectMsg{DisconnectReason::BadPassword, "nope"}).reason, DisconnectReason::BadPassword); EXPECT_EQ(roundTrip(PlayerLeftMsg{9, DisconnectReason::Kicked}).reason, DisconnectReason::Kicked); EXPECT_EQ(roundTrip(TimeResponseMsg{11, 22}).hostTime, 22u); - EXPECT_EQ(roundTrip(CountdownMsg{5000}).startTime, 5000u); + EXPECT_EQ(roundTrip(RaceLoadMsg{3, 5000}).orderTime, 5000u); + EXPECT_EQ(roundTrip(RaceLoadedMsg{3, 4}).player, 4); + EXPECT_EQ(roundTrip(RaceStartMsg{3, 9000}).startTime, 9000u); + EXPECT_EQ(roundTrip(RaceStartMsg{3, 9000}).race, 3u); roundTrip(ReturnToLobbyMsg{}); GameEventMsg e; diff --git a/tests/net/test_race_start.cpp b/tests/net/test_race_start.cpp new file mode 100644 index 0000000..151d2ab --- /dev/null +++ b/tests/net/test_race_start.cpp @@ -0,0 +1,353 @@ +// The race start handshake on loopback sessions (docs/multiplayer.md, "Race +// start"; MM2's RaceReady / start messages, mmMultiRace::UpdateGame state 0): +// the host starts a race once every player still in the session has +// reported it loaded, at a session time every machine shares. +#include "net/Session.h" + +#include + +#include +#include +#include +#include +#include +#include + +using namespace mm2; +using namespace mm2::net; + +namespace { + +using Clock = std::chrono::steady_clock; + +SessionConfig config(std::uint32_t loadWaitMs = 60000) { + SessionConfig c; + c.connectTimeoutMs = 3000; + c.joinTimeoutMs = 3000; + c.snapshotRateHz = 50; + c.interpolationDelayMs = 50; + c.loadWaitMs = loadWaitMs; + return c; +} + +struct Peer { + std::string name; + std::unique_ptr session; + std::vector events; + std::optional startedAt; // when GameStarted came out + + explicit Peer(std::string n, SessionConfig c = config()) : name(std::move(n)), session(new Session(c)) {} + void pump() { + session->update(); + for (auto& e : session->takeEvents()) { + if (std::holds_alternative(e) && !startedAt) + startedAt = Clock::now(); + events.push_back(std::move(e)); + } + } + template + int count() const { + int n = 0; + for (const auto& e : events) + n += std::holds_alternative(e); + return n; + } + template + const E* last() const { + for (auto it = events.rbegin(); it != events.rend(); ++it) + if (const auto* p = std::get_if(&*it)) + return p; + return nullptr; + } + void clear() { + events.clear(); + startedAt.reset(); + } +}; + +bool pumpUntil(const std::vector& peers, const std::function& done, int timeoutMs = 3000) { + const auto deadline = Clock::now() + std::chrono::milliseconds(timeoutMs); + while (Clock::now() < deadline) { + for (Peer* p : peers) + p->pump(); + if (done()) + return true; + std::this_thread::sleep_for(std::chrono::milliseconds(1)); + } + return false; +} + +void settle(const std::vector& peers, int ms) { pumpUntil(peers, [] { return false; }, ms); } + +bool allIn(const std::vector& peers, SessionPhase phase) { + return std::ranges::all_of(peers, [&](Peer* p) { return p->session->phase() == phase; }); +} + +// A host and its joined, clock-synced clients. +struct Table { + Peer host; + std::vector> clients; + + explicit Table(int n, SessionConfig hostConfig = config()) : host("Host", hostConfig) { + HostParams h; + h.bind = Address::loopback(0); + h.advertiseOnLan = false; + h.player.name = "Host"; + EXPECT_TRUE(host.session->host(h)); + for (int i = 0; i < n; ++i) { + auto c = std::make_unique(std::string(1, static_cast('A' + i))); + JoinParams j; + j.host = Address::loopback(host.session->port()); + j.player.name = c->name; + EXPECT_TRUE(c->session->join(j)); + clients.push_back(std::move(c)); + } + EXPECT_TRUE(pumpUntil(all(), [&] { + return std::ranges::all_of(clients, [&](const auto& c) { + return c->session->clockSynced() && c->session->players().size() == clients.size() + 1; + }); + })); + settle(all(), 300); // the clock sync burst + } + std::vector all() { + std::vector v{&host}; + for (auto& c : clients) + v.push_back(c.get()); + return v; + } + Peer& client(int i) { return *clients[static_cast(i)]; } + // GO DRIVE: every machine is loading. + void order(std::uint32_t countdownMs = 0) { + for (Peer* p : all()) + p->clear(); + host.session->startRace(countdownMs); + EXPECT_TRUE(pumpUntil(all(), [&] { return allIn(all(), SessionPhase::Countdown); })); + } + bool anyStartKnown() { + return std::ranges::any_of(all(), [](Peer* p) { return p->session->raceStartKnown(); }); + } + bool everyStartKnown() { + return std::ranges::all_of(all(), [](Peer* p) { return p->session->raceStartKnown(); }); + } + void backToLobby() { + host.session->returnToLobby(); + EXPECT_TRUE(pumpUntil(all(), [&] { return allIn(all(), SessionPhase::Lobby); })); + } +}; + +} // namespace + +// Three machines report in every order: nothing starts until the last report, +// then every machine has the host's start time, which lies the countdown and +// a lead ahead; every machine knows who has loaded. +TEST(RaceStart, StartsAfterTheLastReportInEveryOrder) { + Table t(2); + std::array order{0, 1, 2}; + std::uint32_t lastRace = 0; + do { + constexpr std::uint32_t kCountdown = 300; + t.order(kCountdown); + const auto peers = t.all(); + EXPECT_EQ(t.host.session->raceNumber(), lastRace + 1); + for (Peer* p : peers) + EXPECT_EQ(p->session->raceNumber(), t.host.session->raceNumber()); + lastRace = t.host.session->raceNumber(); + for (std::size_t k = 0; k < order.size(); ++k) { + Peer& reporter = *peers[static_cast(order[k])]; + reporter.session->reportLoaded(); + const std::uint8_t id = reporter.session->localId(); + ASSERT_TRUE(pumpUntil(peers, [&] { + return std::ranges::all_of(peers, [&](Peer* p) { return p->session->playerLoaded(id); }); + })) << reporter.name; + if (k + 1 < order.size()) { + settle(peers, 60); + EXPECT_FALSE(t.anyStartKnown()) << "started after " << k + 1 << " of 3 reports"; + } + } + const std::uint32_t reportedAt = t.host.session->time(); + ASSERT_TRUE(pumpUntil(peers, [&] { return t.everyStartKnown(); })); + const std::uint32_t start = t.host.session->raceStartTime(); + for (Peer* p : peers) { + EXPECT_EQ(p->session->raceStartTime(), start) << p->name; + EXPECT_EQ(p->count(), 1) << p->name; + EXPECT_EQ(p->count(), 3) << p->name; + EXPECT_EQ(p->session->phase(), SessionPhase::Countdown) << p->name; + } + // The countdown and a lead of 200 ms (loopback) from the last report. + EXPECT_GE(start, reportedAt + kCountdown + 200 - 5); + EXPECT_LE(start, reportedAt + kCountdown + 200 + 50); + ASSERT_TRUE(pumpUntil(peers, [&] { return allIn(peers, SessionPhase::InGame); })); + t.backToLobby(); + for (Peer* p : peers) { + EXPECT_FALSE(p->session->raceStartKnown()); + EXPECT_FALSE(p->session->playerLoaded(p->session->localId())); + } + } while (std::next_permutation(order.begin(), order.end())); + EXPECT_EQ(lastRace, 6u); +} + +// Every machine starts the race at the same moment: the start is a session +// time, and each machine's clock follows the host's. +TEST(RaceStart, EveryMachineStartsAtTheSharedTime) { + Table t(2); + t.order(500); + for (Peer* p : t.all()) + p->session->reportLoaded(); + ASSERT_TRUE(pumpUntil(t.all(), [&] { + return std::ranges::all_of(t.all(), [](Peer* p) { return p->startedAt.has_value(); }); + })); + for (auto& c : t.clients) { + using Ms = std::chrono::duration; + const double skew = Ms(*c->startedAt - *t.host.startedAt).count(); + EXPECT_LT(std::abs(skew), 25.0) << c->name << " started " << skew << " ms from the host"; + } +} + +// A player who leaves (or is ejected) while the others wait for it no longer +// holds the start (MM2: SystemMessage 0x2d takes it off the count). +TEST(RaceStart, PlayerLeavingWhileTheOthersWaitDoesNotHoldTheStart) { + Table t(3); + t.order(); + t.host.session->reportLoaded(); + t.client(0).session->reportLoaded(); + settle(t.all(), 100); + EXPECT_FALSE(t.anyStartKnown()); + // C is ejected, B leaves: the two who reported start. + t.host.session->kick(t.client(2).session->localId(), "too slow"); + settle(t.all(), 100); + EXPECT_FALSE(t.anyStartKnown()); // B is still loading + t.client(1).session->leave(); + ASSERT_TRUE(pumpUntil({&t.host, &t.client(0)}, [&] { + return t.host.session->raceStartKnown() && t.client(0).session->raceStartKnown(); + })); + EXPECT_EQ(t.host.session->raceStartTime(), t.client(0).session->raceStartTime()); + EXPECT_EQ(t.host.session->players().size(), 2u); +} + +// A player whose machine stops answering while the others wait is timed out +// by the host, and the start goes on without it. +TEST(RaceStart, PlayerTimingOutWhileTheOthersWaitDoesNotHoldTheStart) { + SessionConfig hostConfig = config(); + hostConfig.transport.timeoutMinMs = 300; + hostConfig.transport.timeoutMaxMs = 800; + Table t(2, hostConfig); + t.order(); + t.host.session->reportLoaded(); + t.client(0).session->reportLoaded(); + // B freezes: it is not serviced again. + const std::vector alive{&t.host, &t.client(0)}; + ASSERT_TRUE(pumpUntil(alive, [&] { return t.host.session->raceStartKnown(); }, 6000)); + EXPECT_EQ(t.host.last()->reason, DisconnectReason::Timeout); + ASSERT_TRUE(pumpUntil(alive, [&] { return t.client(0).session->raceStartKnown(); })); +} + +// MM2 waits for every player; OpenMM2's host stops waiting after +// SessionConfig::loadWaitMs. The machine still loading has the start when it +// is ready, and its late report still tells the others it is in the race, +// without changing the start. +TEST(RaceStart, HostStartsWithoutPlayersStillLoadingAfterTheWait) { + Table t(2, config(500)); + t.order(300); + const auto ordered = Clock::now(); + t.host.session->reportLoaded(); + t.client(0).session->reportLoaded(); + ASSERT_TRUE(pumpUntil(t.all(), [&] { return t.everyStartKnown(); })); + const double waited = std::chrono::duration(Clock::now() - ordered).count(); + EXPECT_GE(waited, 450.0); + const std::uint32_t start = t.host.session->raceStartTime(); + // B, still loading, has the start too (its session is serviced while it loads). + Peer& slow = t.client(1); + EXPECT_EQ(slow.session->raceStartTime(), start); + EXPECT_FALSE(slow.session->playerLoaded(slow.session->localId())); + ASSERT_TRUE(pumpUntil(t.all(), [&] { return slow.session->phase() == SessionPhase::InGame; })); + + // B has loaded after the start. + slow.session->reportLoaded(); + const std::uint8_t id = slow.session->localId(); + ASSERT_TRUE(pumpUntil(t.all(), [&] { + return t.host.session->playerLoaded(id) && t.client(0).session->playerLoaded(id); + })); + settle(t.all(), 50); + for (Peer* p : t.all()) { + EXPECT_EQ(p->session->raceStartTime(), start); + EXPECT_EQ(p->count(), 1); + } +} + +// A report sent for a race the host has already left (it went back to the +// lobby and ordered the next one meanwhile) does not count for the next +// race, and repeated reports count once. +TEST(RaceStart, ReportsForAnEarlierRaceAndRepeatsAreIgnored) { + Table t(1); + Peer& a = t.client(0); + t.order(); + EXPECT_EQ(a.session->raceNumber(), 1u); + // The host ends race 1 and orders race 2 before A has heard of either; + // A reports race 1 loaded. + t.host.session->returnToLobby(); + t.host.session->startRace(0); + settle({&t.host}, 50); + a.session->reportLoaded(); // still race 1 on A + ASSERT_TRUE(pumpUntil(t.all(), [&] { return a.session->raceNumber() == 2; })); + settle(t.all(), 100); + EXPECT_FALSE(t.host.session->playerLoaded(a.session->localId())); + t.host.session->reportLoaded(); + settle(t.all(), 100); + EXPECT_FALSE(t.anyStartKnown()); // still waiting for A's report of race 2 + + a.session->reportLoaded(); + a.session->reportLoaded(); // a repeat + ASSERT_TRUE(pumpUntil(t.all(), [&] { return t.everyStartKnown(); })); + settle(t.all(), 50); + // Race 2: the host's and A's, once each, on both machines (A also has + // its own report of race 1). + auto loadedIn = [](const Peer& p, std::uint32_t race) { + return std::ranges::count_if(p.events, [&](const SessionEvent& e) { + const auto* l = std::get_if(&e); + return l && l->race == race; + }); + }; + EXPECT_EQ(loadedIn(t.host, 1), 0); + EXPECT_EQ(loadedIn(t.host, 2), 2); + EXPECT_EQ(loadedIn(a, 2), 2); +} + +// A host alone starts as soon as it has loaded. +TEST(RaceStart, HostAloneStartsAtOnce) { + Table t(0); + t.order(); + EXPECT_FALSE(t.host.session->raceStartKnown()); + t.host.session->reportLoaded(); + EXPECT_TRUE(t.host.session->raceStartKnown()); + t.host.pump(); + const auto* set = t.host.last(); + ASSERT_NE(set, nullptr); + EXPECT_EQ(set->race, 1u); +} + +// The host numbers the races: a player who joins after some have been run +// has the same number for the next one. +TEST(RaceStart, ALaterJoinerNumbersTheRacesAsTheHost) { + Table t(1); + for (int race = 0; race < 2; ++race) { + t.order(); + for (Peer* p : t.all()) + p->session->reportLoaded(); + ASSERT_TRUE(pumpUntil(t.all(), [&] { return t.everyStartKnown(); })); + t.backToLobby(); + } + Peer late("Late"); + JoinParams j; + j.host = Address::loopback(t.host.session->port()); + j.player.name = "Late"; + ASSERT_TRUE(late.session->join(j)); + std::vector peers = t.all(); + peers.push_back(&late); + ASSERT_TRUE(pumpUntil(peers, [&] { return late.session->clockSynced(); })); + EXPECT_EQ(late.session->raceNumber(), 2u); + t.host.session->startRace(0); + ASSERT_TRUE(pumpUntil(peers, [&] { return late.session->phase() == SessionPhase::Countdown; })); + EXPECT_EQ(late.session->raceNumber(), 3u); + EXPECT_EQ(t.client(0).session->raceNumber(), 3u); + EXPECT_EQ(late.session->raceOrderTime(), t.host.session->raceOrderTime()); +} diff --git a/tests/net/test_session.cpp b/tests/net/test_session.cpp index 870e58c..16cbe17 100644 --- a/tests/net/test_session.cpp +++ b/tests/net/test_session.cpp @@ -246,11 +246,13 @@ TEST(Session, CountdownSnapshotsAndEvents) { EXPECT_TRUE(a.session->clockSynced()); EXPECT_NEAR(static_cast(a.session->time()), static_cast(host.session->time()), 20.0); - host.session->startCountdown(300); + host.session->startRace(300); ASSERT_TRUE(pumpUntil({&host, &a, &b}, [&] { + for (Peer* p : {&host, &a, &b}) + p->session->reportLoaded(); // nothing to load return host.find() && a.find() && b.find(); })); - EXPECT_TRUE(a.find()); + EXPECT_TRUE(a.find()); EXPECT_EQ(a.session->phase(), SessionPhase::InGame); // A drives along +X; B and the host see it through the interpolation buffer. @@ -332,7 +334,8 @@ TEST(Session, KickAndHostShutdown) { TEST(Session, JoinInProgressPolicy) { Peer host, late; ASSERT_TRUE(host.session->host(hostParams())); - host.session->startCountdown(0); + host.session->startRace(0); + host.session->reportLoaded(); // alone: the start follows at once ASSERT_TRUE(pumpUntil({&host}, [&] { return host.find(); })); ASSERT_TRUE(late.session->join(joinParams(host, "Late"))); ASSERT_TRUE(pumpUntil({&host, &late}, [&] { return late.find(); })); diff --git a/tests/net/test_session_lobby.cpp b/tests/net/test_session_lobby.cpp index 5e24d3a..263914c 100644 --- a/tests/net/test_session_lobby.cpp +++ b/tests/net/test_session_lobby.cpp @@ -70,6 +70,17 @@ JoinParams joinParams(const Peer& host, const std::string& name) { return j; } +// GO DRIVE, and every machine reports the race loaded (there is nothing to +// load) until it has started. +void startRace(Peer& host, Peer& a, Peer& b) { + host.session->startRace(100); + pumpUntil({&host, &a, &b}, [&] { + for (Peer* p : {&host, &a, &b}) + p->session->reportLoaded(); + return host.session->phase() == SessionPhase::InGame; + }); +} + VehicleSnapshot at(float x) { VehicleSnapshot s; s.position = {x, 0, 0}; @@ -93,7 +104,7 @@ TEST(SessionLobby, NoVehicleStatesBetweenRaces) { })); const std::uint8_t aId = a.session->localId(); - host.session->startCountdown(100); + startRace(host, a, b); ASSERT_TRUE(pumpUntil({&host, &a, &b}, [&] { return a.session->phase() == SessionPhase::InGame; })); VehicleSnapshot seen; ASSERT_TRUE(pumpUntil({&host, &a, &b}, [&] { @@ -117,7 +128,7 @@ TEST(SessionLobby, NoVehicleStatesBetweenRaces) { // The next race starts with nothing from the last one; A's new start // place comes through. - host.session->startCountdown(100); + startRace(host, a, b); ASSERT_TRUE(pumpUntil({&host, &a, &b}, [&] { return b.session->phase() == SessionPhase::InGame; })); settle({&host, &a, &b}, 100); EXPECT_EQ(b.session->sampleRemote(aId, seen), SnapshotBuffer::Result::Empty); diff --git a/tests/net/test_sync.cpp b/tests/net/test_sync.cpp index de10bfd..3041465 100644 --- a/tests/net/test_sync.cpp +++ b/tests/net/test_sync.cpp @@ -159,8 +159,12 @@ void connect(Peer& host, Peer& client) { } void startRace(Peer& host, Peer& client) { - host.session->startCountdown(100); - ASSERT_TRUE(pumpUntil({&host, &client}, [&] { return client.has(); })); + host.session->startRace(100); + ASSERT_TRUE(pumpUntil({&host, &client}, [&] { + host.session->reportLoaded(); // nothing to load + client.session->reportLoaded(); + return client.has(); + })); } } // namespace @@ -251,8 +255,12 @@ TEST(SessionSync, TheHostPassesEveryJoinersStateOnAtOnce) { ASSERT_TRUE(b.session->join(j)); ASSERT_TRUE(pumpUntil({&host, &a, &b}, [&] { return b.has(); })); pumpFor({&host, &a, &b}, 400); - host.session->startCountdown(100); - ASSERT_TRUE(pumpUntil({&host, &a, &b}, [&] { return a.has() && b.has(); })); + host.session->startRace(100); + ASSERT_TRUE(pumpUntil({&host, &a, &b}, [&] { + for (Peer* p : {&host, &a, &b}) + p->session->reportLoaded(); + return a.has() && b.has(); + })); const std::uint8_t aId = a.session->localId(); std::vector seen; b.session->setStateObserver([&](std::uint8_t id, const VehicleSnapshot& s, double) { diff --git a/tests/net/test_vehicle_damage.cpp b/tests/net/test_vehicle_damage.cpp new file mode 100644 index 0000000..b2b473a --- /dev/null +++ b/tests/net/test_vehicle_damage.cpp @@ -0,0 +1,179 @@ +// A car's damage on the wire (net/VehicleDamage.h) and the knocked traffic +// cars' wheels and the police's damage in AmbientState. +#include "net/AmbientState.h" +#include "net/VehicleDamage.h" + +#include + +#include +#include + +using namespace mm2; +using namespace mm2::net; + +namespace { + +VehicleDamageEvent sampleEvent() { + VehicleDamageEvent e; + e.subject = kDamageOwnCar; + e.epoch = 3; + e.time = 123456; + e.first = 40; + for (int i = 0; i < 5; ++i) + e.patches.push_back({static_cast(i * 10), {0.8f - 0.1f * i, 0.5f, -2.1f + 0.3f * i}, + 0xDA6Au + static_cast(i) * 7919u}); + e.parts = (1u << 0) | (1u << 9) | (1u << 13); + e.partsDelay = 33; + DamageImpact m; + m.delay = 12; + m.point = {-0.9f, 0.4f, 1.7f}; + m.normal = Vec3{0.6f, 0.0f, 0.8f}; + m.total = 4321.0f; + m.speed = 27.5f; + m.sound = 12000.0f; + m.audioId = 7; + e.impacts.push_back(m); + return e; +} + +} // namespace + +TEST(VehicleDamage, RoundTripsWithinItsQuantization) { + const VehicleDamageEvent e = sampleEvent(); + VehicleDamageEvent d; + ASSERT_TRUE(decodePayload(encodePayload(e), d)); + EXPECT_EQ(d.subject, e.subject); + EXPECT_EQ(d.epoch, e.epoch); + EXPECT_EQ(d.time, e.time); + EXPECT_EQ(d.first, e.first); + ASSERT_EQ(d.patches.size(), e.patches.size()); + for (std::size_t i = 0; i < e.patches.size(); ++i) { + EXPECT_EQ(d.patches[i].delay, e.patches[i].delay); + EXPECT_EQ(d.patches[i].seed, e.patches[i].seed); + EXPECT_NEAR(d.patches[i].point.x, e.patches[i].point.x, 0.0003f); + EXPECT_NEAR(d.patches[i].point.z, e.patches[i].point.z, 0.0003f); + } + EXPECT_EQ(d.parts, e.parts); + EXPECT_EQ(d.partsDelay, e.partsDelay); + ASSERT_EQ(d.impacts.size(), 1u); + const DamageImpact& m = d.impacts[0]; + EXPECT_EQ(m.delay, 12); + EXPECT_NEAR(m.point.z, 1.7f, 0.005f); + EXPECT_NEAR(m.normal.x, 0.6f, 0.01f); + EXPECT_NEAR(m.total, 4321.0f, 4321.0f * 0.02f); // log scale: 1.4% steps + EXPECT_NEAR(m.speed, 27.5f, 0.15f); + EXPECT_NEAR(m.sound, 12000.0f, 12000.0f * 0.02f); + EXPECT_EQ(m.audioId, 7); +} + +// The owner paints at the point as it arrives, so both machines run the +// texel damage on the same numbers. +TEST(VehicleDamage, AQuantizedPointArrivesExactly) { + const Vec3 points[] = {{0.123456f, 0.98765f, -1.8765f}, {-7.99f, 7.99f, 0.0f}, {12.0f, -30.0f, 0.5f}}; + for (const Vec3& p : points) { + VehicleDamageEvent e; + e.patches.push_back({0, quantizeDamagePoint(p), 1}); + VehicleDamageEvent d; + ASSERT_TRUE(decodePayload(encodePayload(e), d)); + EXPECT_EQ(d.patches[0].point.x, e.patches[0].point.x); + EXPECT_EQ(d.patches[0].point.y, e.patches[0].point.y); + EXPECT_EQ(d.patches[0].point.z, e.patches[0].point.z); + EXPECT_LE(std::abs(d.patches[0].point.y), kDamagePointRange); + } +} + +// Whatever the sender's numbers, what is read is finite and in range. +TEST(VehicleDamage, NonFiniteValuesArriveFiniteAndInRange) { + constexpr float nan = std::numeric_limits::quiet_NaN(); + constexpr float inf = std::numeric_limits::infinity(); + VehicleDamageEvent e; + e.patches.push_back({0, {nan, inf, -inf}, 0}); + DamageImpact m; + m.point = {nan, 1e9f, -inf}; + m.normal = {nan, nan, nan}; + m.total = inf; + m.speed = nan; + m.sound = -5.0f; + m.audioId = 60000; + e.impacts.push_back(m); + e.parts = 0xFFFFFFFFu; + VehicleDamageEvent d; + ASSERT_TRUE(decodePayload(encodePayload(e), d)); + for (float v : {d.patches[0].point.x, d.patches[0].point.y, d.patches[0].point.z}) { + EXPECT_TRUE(std::isfinite(v)); + EXPECT_LE(std::abs(v), kDamagePointRange); + } + const DamageImpact& r = d.impacts[0]; + for (float v : + {r.point.x, r.point.y, r.point.z, r.normal.x, r.normal.y, r.normal.z, r.total, r.speed, r.sound}) + EXPECT_TRUE(std::isfinite(v)); + EXPECT_GE(r.total, 0.0f); + EXPECT_GE(r.sound, 0.0f); + EXPECT_LE(r.speed, kDamageMaxSpeed); + EXPECT_LE(r.audioId, 1000); + EXPECT_EQ(d.parts, (1u << kDamagePartCount) - 1u); +} + +// A record holds at most kMaxDamageRecord patches: an event reaching past it +// does not decode, nor does a cut one. +TEST(VehicleDamage, MalformedEventsDoNotDecode) { + VehicleDamageEvent e = sampleEvent(); + e.first = static_cast(kMaxDamageRecord - 2); + VehicleDamageEvent d; + EXPECT_FALSE(decodePayload(encodePayload(e), d)); + e = sampleEvent(); + auto bytes = encodePayload(e); + for (std::size_t n = 0; n < bytes.size(); ++n) { + const std::vector cut(bytes.begin(), bytes.begin() + static_cast(n)); + EXPECT_FALSE(decodePayload(cut, d)) << n; + } +} + +// The police's damage level travels at 10 bits (the smoke's four levels +// change at quarters of it); a knocked car with a body carries its wheels, +// one bit says so, a car on its rail or a police car has neither. +TEST(AmbientState, PoliceDamageAndKnockedCarsWheelsRoundTrip) { + AmbientStateMsg msg; + msg.time = 5000; + msg.setOrigin({100.0f, 10.0f, -50.0f}); + AmbientEntity police; + police.id = 401; + police.kind = AmbientKind::Police; + police.position = {110.0f, 10.0f, -40.0f}; + police.damage = 0.2507f; + msg.entities.push_back(police); + AmbientEntity knocked; + knocked.id = 12; + knocked.position = {90.0f, 11.0f, -60.0f}; + knocked.flags = kAmbientOffRail; + knocked.wheels = true; + knocked.wheelOffsets = {Vec3{0.01f, 0.12f, -0.02f}, Vec3{-0.03f, -0.07f, 0.0f}, Vec3{0.0f, 0.3f, 0.05f}, + Vec3{0.2f, -0.9f, -0.2f}}; + msg.entities.push_back(knocked); + AmbientEntity rail; + rail.id = 13; + rail.position = {95.0f, 10.0f, -45.0f}; + rail.wheels = true; // on its rail: not sent + msg.entities.push_back(rail); + + AmbientStateMsg out; + ASSERT_TRUE(decodeMessage(encodeMessage(msg), out)); + ASSERT_EQ(out.entities.size(), 3u); + EXPECT_NEAR(out.entities[0].damage, 0.2507f, 0.0005f); + EXPECT_GT(std::ceil(out.entities[0].damage * 4.0f), 1.0f); // still the second smoke level + EXPECT_FALSE(out.entities[0].wheels); + ASSERT_TRUE(out.entities[1].wheels); + for (std::size_t i = 0; i < 4; ++i) { + EXPECT_NEAR(out.entities[1].wheelOffsets[i].x, knocked.wheelOffsets[i].x, 0.005f); + EXPECT_NEAR(out.entities[1].wheelOffsets[i].y, knocked.wheelOffsets[i].y, 0.005f); + EXPECT_NEAR(out.entities[1].wheelOffsets[i].z, knocked.wheelOffsets[i].z, 0.005f); + } + EXPECT_FALSE(out.entities[2].wheels); + // The wheels' cost: one bit for any knocked car, 80 more with a body. + AmbientEntity bare = knocked; + bare.wheels = false; + EXPECT_EQ(ambientEntityBits(knocked), ambientEntityBits(bare) + 80); + AmbientEntity onRail = rail; + onRail.wheels = false; + EXPECT_EQ(ambientEntityBits(rail), ambientEntityBits(onRail)); +} diff --git a/tools/netprobe/main.cpp b/tools/netprobe/main.cpp index 6f0494f..b53fafd 100644 --- a/tools/netprobe/main.cpp +++ b/tools/netprobe/main.cpp @@ -93,8 +93,12 @@ void printEvent(const Session& s, const SessionEvent& e) { std::println("<{}> {}", name(ev.from), ev.text); else if constexpr (std::is_same_v) std::println("settings: city {}, mode {}", ev.settings.city, static_cast(ev.settings.mode)); - else if constexpr (std::is_same_v) - std::println("countdown: race starts at session time {} ms", ev.startTime); + else if constexpr (std::is_same_v) + std::println("race {} ordered at session time {} ms: loading", ev.race, ev.orderTime); + else if constexpr (std::is_same_v) + std::println("{} has loaded race {}", name(ev.id), ev.race); + else if constexpr (std::is_same_v) + std::println("race {} starts at session time {} ms", ev.race, ev.startTime); else if constexpr (std::is_same_v) std::println("game started"); else if constexpr (std::is_same_v) @@ -182,8 +186,12 @@ int cmdJoin(const Args& a) { const auto start = std::chrono::steady_clock::now(); while (keepRunning(start, a.getInt("--seconds", 0)) && s.state() != Session::State::Closed) { s.update(); - for (const auto& e : s.takeEvents()) + for (const auto& e : s.takeEvents()) { printEvent(s, e); + // Nothing to load: a probe never holds the others' start. + if (std::holds_alternative(e)) + s.reportLoaded(); + } if (!chat.empty() && !chatted && s.state() == Session::State::Active) { s.sendChat(chat); chatted = true;