Skip to content

Fix 0.2.0's known limitations: smooth drawing, shared race start, network damage (0.3.0) - #3

Merged
lowlevelmetal merged 25 commits into
mainfrom
integration
Oct 9, 2026
Merged

lowlevelmetal merged 25 commits into
mainfrom
integration

Conversation

@lowlevelmetal

Copy link
Copy Markdown
Owner

Fixes the three known limitations of 0.2.0. Version 0.3.0, network protocol 4.

Drawing between simulation steps

  • The world is drawn between the last two fixed simulation steps. This covers the player's car and camera, opponents, police, traffic (on rails and physical), pedestrians, props, network cars, shared traffic and particles. Discontinuities (resets, jumps, recycled slots) snap.
  • The simulation is unchanged: a car on fixed inputs is bit-identical at every frame rate, and the sweep is unchanged.
  • Judder at 144 fps, median per-frame second difference: car 39 -> 0.24 mm, roadside object 442 -> 2.1 mm.
  • Also fixed: the headlights flag was never sent in network car states.

Network race start

  • MM2's handshake (mmMulti*::UpdateGame state 0, SendRaceReady 0x1f6 / 0x20f). Every machine reports when it has loaded, the host waits for every player still in the session, and all countdowns end at one shared session time.
  • After 60 s the host starts without a player still loading, who joins the running race when its load finishes. MM2 has no rule for this case.
  • A joiner loading 4 s slower now goes within a few ms of the host; in 0.2.0 it was 0.5-2.5 s late.

Damage in multiplayer

  • Other players' cars show their owner's damage: the same dents texel for texel, broken parts, smoke from the damage level, sparks, shards and sounds of impacts. This is a reliable event per car with an epoch per reset.
  • Shared-traffic police show their damage and wrecks, and knocked traffic cars show their wheels.
  • MM2 sent only the damage level, and each machine made its own dents; this is a recorded deviation.

Testing

  • Full suite with game data: 925/925. Sweep unchanged: 516/517 finished, 508/517 across the line.
  • Two-instance checks:
    • a 4 s slower joiner over two races, both machines going together;
    • same-moment damage screenshots;
    • remote headlights at night.

🤖 Generated with Claude Code

lowlevelmetal and others added 25 commits October 9, 2026 01:07
OPENMM2_DEBUG_DRAW_TRACE=<file> (off unless set) writes, once a frame,
the frame's time, the physics samples it ran, the player's car, a point
by the road ahead and the nearest other car as drawn, in the camera's
frame. Differencing those positions from frame to frame measures how
smoothly they move at a given frame rate, which is what drawing between
the fixed simulation steps is meant to fix.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
MM2's network races do not start on a timer. After loading, each machine
sits on the grid with every car held (mmMultiRace / mmMultiCircuit /
mmMultiBlitz::InitNetworkPlayers), counts 5 s of frames, then sends
RaceReady (0x1f6, mmGameMulti::SendRaceReady) to everyone, and shows
"Waiting for N players" (strings 31-37) while some other player's has not
arrived (UpdateGame state 0). A player who leaves meanwhile is taken off
the count (SystemMessage 0x2d). When the host's count reaches 0 it sends
the start (0x20f) and every machine counts Ready... and Set..., 1.25 s
each, then Go!.

Protocol 3 does the same on a shared clock. GO DRIVE sends RaceLoad (the
host's race number and the order time) in place of Countdown; each
machine reports RaceLoaded with the race number, which the host relays;
once every player still in the session has reported, the host sends
RaceStart with the session time of the Go, a lead (twice the slowest
round trip plus 100 ms, 200-1000 ms) plus the mode's countdown ahead, so
every countdown ends at the same moment. Reports for another race,
repeats and reports in the lobby are dropped. MM2 has no limit on the
wait; OpenMM2's host starts without the players still loading 60 s after
the order, and such a machine counts down on its own when it has loaded
and joins the running race, as an MM2 machine does with a start message
that waited in its queue.

game::NetRaceStart runs the machine's side (the 5 s, the report, the
waiting text, the countdown on the shared start); the race session's
countdown follows the time left (Session::setNetStart). Cops and Robbers
reports at once and says "Go!" at the shared start (OpenMM2 waits for
everyone there too, since its rules run on that clock); cruise reports at
once and is never held, showing "<name> has joined" as the others' reports
come (mmGameMulti::GameMessageCB 0x1fa). A network car is shown once its
player has reported, as MM2 activates it on RaceReady. A joiner that
quits or cannot load the race no longer holds the start, and a machine
still loading leaves when the host takes everyone back to the lobby.

OPENMM2_DEBUG_LOAD_DELAY_MS stalls this machine's loading for tests.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
MM2 sends a car's vehCarDamage::CurrentDamage in every position packet
(mmNetObject::SetPositionData) and sets the network car's to it
(mmNetObject::PositionUpdate, clearing its damage when it drops to 0);
every machine simulates the network car, so its own collisions paint the
dents (fxTexelDamage) and break the parts there. OpenMM2 draws those cars
kinematically, so nothing showed: no smoke, no fire, no dents, no parts.

Now the damage level from the snapshot (and the police's from the shared
traffic, at 10 bits) is the network car's CurrentDamage, and
vehCarDamage::Update's smoke, fire, exhaust and tyre wobble follow it. What
the owner's vehCarDamage::ApplyImpact painted and broke travels as a
reliable game event (net/VehicleDamage.h): each texel damage patch with the
point (painted at its wire precision on the owner too) and the random state
it started from, so the receivers' fxTexelDamage paints the same patches;
the parts broken off since the reset; the damaging impacts' sparks, shards
and sound; a new epoch on every reset (vehCar::ClearDamage). The receivers
(game/net/DamageSync) keep one record per car and apply each entry when the
car is drawn at the time it happened. The host relays a joiner's damage
events with a budget of their own.

A knocked traffic car with a body on the host now carries its wheels'
drawing offsets (vehWheelCheap), so a client draws it on them as the host
does (aiVehicleInstance::Draw). Protocol version 4.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
MM2 ran its game once a frame with the frame's time (datTimeManager::
Update, mmGame::Update, dgPhysManager::Update's samples inside it), so
every frame showed the state its own update reached. OpenMM2 steps the
physics in fixed 1/60 s samples and the AI in fixed 1/30 s steps, and
drew the last step's state: above 60 fps the cars, the camera and the
props moved in steps against the frame, and the traffic and pedestrians
in 30 Hz steps at any frame rate.

The drawing now blends each simulated object from its state when the
last step began to its current one by the share of a step the
simulation has not run yet (game::StepHistory, fed by step observers on
phys::World and ai::World): the player's car, trailer and wheels (spun
by their accumulated turns, so a fast wheel does not turn backwards),
the cameras that follow it, the mirror, the opponents, the police, the
traffic with a body, the props an active simulates, the traffic on its
rails and the pedestrians, and the HUD's icons and map. The network
players' cars and a client's shared traffic, which are sampled from
snapshots, are sampled a step further back for drawing, where the rest
of the scene is. A car's reset count (vehCar::Reset), a recycled
traffic slot, a pedestrian put on a road, a race restart and any move
too long for one step are drawn where they now are, never blended.

Nothing the simulation, the rules, the audio or the snapshots read
changes; the scene is drawn up to one step (16.7 ms, 33 ms for the
AI's objects) behind the frame.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Session tests on loopback: three machines report in all six orders, and
the race starts only after the last report, at the same session time on
every machine, the countdown and a lead after it; every machine starts
within a few milliseconds of the host; a player ejected, leaving or timed
out while the others wait no longer holds the start; after the load wait
the host starts without a player still loading, which has the start when
it loads and whose late report shows it to the others without changing
it; a report for an earlier race and repeats are dropped.

Hostile input: a joiner's reports count only for itself, for the race
being started, once; a host's race messages out of turn (in the lobby,
for another race, a second start, unknown players, the client itself, an
order that numbers no new race) leave the client's race alone; a Welcome
during a race keeps only known loaded players. The fuzz corpus has the new
messages, the live fuzz tests check the race state, and a joiner sending
mutated race messages can neither start the race nor make it start twice.

NetGame: the machine side (the 5 s, the report, the waiting count, the
countdown on the shared start, a late machine's own countdown, cruise and
Cops and Robbers), a slow loader that both machines' countdowns wait for
and end together, a loader that never finishes and joins later, and a
joiner leaving the race. NetOptions::loadWaitMs lets the tests shorten the
wait. A Welcome's list of loaded players no longer marks the joiner
itself, which reports on its own.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The blends (half way is half the way and half the turn, a wheel that
turns more than half a revolution a step still turns forwards), when an
object is drawn where it is instead (nothing recorded at the last step,
another generation, a move too long for a step, a restart), the physics
observer's one call per sample, and the shared traffic's draw-time
sample. With game data: a car driven with fixed pedals takes the same
state at every sample at 60, 90, 144 and 240 fps and with uneven frames
of 2 to 42 ms, with the drawing's history kept or not, and London's
traffic and pedestrians do the same step for step.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Unit tests for the damage event's encoding (round trip, the owner's point
arriving bit for bit, non-finite values, a record overflow), the recorder
(batching, splitting, resets, its own budget), the receivers' records (each
entry at its time, the hold, a reset clearing the car, convergence after a
lost event, cars not drawn, hostile events), the same texel damage on two
renderers from the replayed events (retail data), two NetGames, and the
knocked traffic cars' wheels. The fuzz corpus and the hostile-input tests
cover the new event and its relay budget.

Events piling up for one car now go into its record at once instead of
being dropped, and the car shows its whole record when next drawn.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
rendering.md gets the record: what MM2 did (one update a frame with the
frame's time, datTimeManager::Update, dgPhysManager::Update's samples),
why OpenMM2 keeps its fixed physics and AI steps, what is blended and
how, what is never blended (resets, recycled slots, a pedestrian put on
a road, restarts, jumps) and what stays stepped (the particles and
tracks), the latency it costs and why the other options were rejected,
and the measured judder before and after at 60, 90, 144 and 240 fps, in
a race and in a network cruise. physics.md says the fixed step stays and
how the observer reads the samples; multiplayer.md how the remote cars
and the shared traffic are sampled for drawing; the sync review's S1 is
fixed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
docs/multiplayer.md gains a "Race start" section: what MM2 does in state 0
of its race modes (RaceReady 5 s after loading, "Waiting for N players",
0x20f, Ready / Set / Go; no wait in Cops and Robbers, "has joined" in a
cruise), what protocol 3 does instead (RaceLoad, RaceLoaded, RaceStart, a
shared start a lead ahead, the 60 s wait and a late machine's own
countdown, which reports are dropped), and the measured countdowns of two
processes with a slow loader, also through netprobe's lossy relay. The
messages table, the race flow, the race screen's contract, the automation
hooks and the tests follow it.

OPENMM2_DEBUG_NET_SHOT_MS=+<ms> takes each machine's screenshot that long
after the race was ordered, also before the start (the waiting screen).

The parity and game mode records that described the old fixed start, and
Cops and Robbers' seed (now the order time), are updated; the lobby
review's O1 is fixed, and the sync review's note on it follows.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The race screen made its NetRaceStart only with a race session. A network
race whose rules could not be set up (a race index the city lacks) then
never reported itself loaded, held its car for good and kept the others
waiting until the host's load wait ran out; it now reports and goes as
any other.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Count the players still loading with an explicit test, and compare the
screenshot hook's session times as long long throughout, so that no
compiler sees a bool added to an int or a mixed-sign comparison.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
MM2 updated its particle systems once a frame with the frame's time
(asParticles::Update); OpenMM2 runs those updates at a fixed 60 Hz
(fx::FixedTicker), so the rain (35 m/s, 58 cm an update), the wheels'
dust and smoke, the engine smoke and the props' debris moved in 60 Hz
steps against a scene that is now drawn smoothly. An update moves a
particle by its new velocity, so drawing it back along that velocity by
the time since the last update was due (FixedTicker::behind) puts it
where it was between its last two updates, with the rest of the scene.
The tracks, shards and sparks (which MM2 also steps at 1/30 s) stay as
they are updated.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The Welcome carried the race only during a race, so a player who joined in
the lobby after some races counted from 0 until the next order, unlike
everyone else. It now always carries the host's number (the last race's in
the lobby) and its order time.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…tGame

Through two NetGames: the client reporting first and then the host, race
after race, starts once the second has reported, at the same time on both;
a report sent for a race the host has already left does not count for the
next one, which waits for the client's report of it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The same two-process run on 0.2.0 (with only the load delay and the
countdown's logs added): a joiner loading 4 s more slowly began its
countdown 0.5 s late and went 560 ms after the host; 6 s slower, 2.55 s
after it. With the handshake both go at the start.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The client's view of the host's car through netprobe relay (50 +- 15 ms each way, 1% loss, reordering) moves by 1.3 mm a frame (median second difference) instead of 114 mm, as on a clean link.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
sendLocalState read the flag from the simulated pose, which never carries
the lights (only the drawn poses get them from carLights), so the other
players' cars never showed their headlights at night. Found by the render
interpolation work.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
docs/multiplayer.md's race screen contract keeps both: the cars drawn from
a second sample between the steps, and no state for a player who has not
reported the race loaded.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
OPENMM2_DEBUG_FOCUS=net:<id> frames a network player's car (this machine's
own or another's as drawn) and police:<id> a shared police car, so both
machines can take the same view of one car; OPENMM2_DEBUG_RESPAWN_MS puts
the player's car back at its reset position at given session times (as the
water does); OPENMM2_DEBUG_INPUT takes several inputs separated by '/' in
turn (OPENMM2_DEBUG_INPUT_MS each), to ram a wall again and again;
OPENMM2_DEBUG_NETDAMAGE also logs the cars' damage levels every 2 s.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The network cars' damage smoke and replayed impacts come from where the
cars are drawn, a physics step behind their kinematic bodies; a client's
knocked traffic cars stand on their wheels as drawn.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Protocol 4 on top of the race start's 3. OPENMM2_DEBUG_RESPAWN_MS takes
"+<ms>" from the race's order as OPENMM2_DEBUG_NET_SHOT_MS does.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
OPENMM2_DEBUG_NET_SHOT_MS takes a list of times, saving a numbered picture
at each but the last; the focus frames a shared police car, a traffic car
by id or the knocked car nearest a player; OPENMM2_DEBUG_START_NEAR_POLICE
and OPENMM2_DEBUG_START place a cruise's car; OPENMM2_DEBUG_POLICE_TOUGHNESS
scales the host's police damage limits; the input phases count from the
start of a network race. The host's nettraffic statistics give the share of
the police damage bits and the knocked cars' wheels, the client's how many
cars stand on their bodies' wheels.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
docs/multiplayer.md gains "Damage": what MM2 sends (vehCarDamage's
CurrentDamage in the position packet) and simulates on every machine, what
OpenMM2 replays instead (the level, the owner's texel patches with their
random state, parts, impacts and resets as reliable events), the police's
damage and the knocked traffic cars' wheels in AmbientState, the bounds, the
budgets, the hooks and the measured bandwidth. S5 of the in-race review is
fixed; the shared-traffic parity notes and the effects page say what a
client now shows.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The recorder returned straight from its loop when its tokens ran out,
skipping the bookkeeping after it; it now stops the loop and carries on
from the same place.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@lowlevelmetal
lowlevelmetal merged commit 7e64112 into main Oct 9, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant