diff --git a/README.md b/README.md index 0a22a33..a0ca011 100644 --- a/README.md +++ b/README.md @@ -12,8 +12,11 @@ of that, OpenMM2 adds: - online multiplayer over UDP with automatic port forwarding (UPnP) - a Windows installer, and native Linux builds -> **Status:** early development. OpenMM2 reads the original game data and its -> subsystems are being built up; it is not playable yet. +> **Status:** early but playable. London and San Francisco, cruise, Blitz, +> checkpoint and circuit races, the crash courses, Cops and Robbers and online +> multiplayer all run from the original game data. Behaviour is checked +> against the original game function by function; the results, and the gaps +> still open (mostly in network play), are in [docs/parity/](docs/parity/README.md). ## You need the original game diff --git a/docs/ai.md b/docs/ai.md index d8a4098..a10e094 100644 --- a/docs/ai.md +++ b/docs/ai.md @@ -58,10 +58,18 @@ Poles (`aiTrafficLightInstance::Init`, `World::signals()`): at `trafficLightAxis` (which points away from the road on retail data), Z = (-x.z, 0, x.x), so the glows on +Z face the arriving traffic and the SF models' arms (-X) reach over the road. The dual model for approaches with -two or more lanes. `DrawGlow` draws the light's glow together with the -pedestrian signal (WALK in state 4, NOWALK otherwise) and only when the -model has both; NIGHT glows from the evening on (time of day > 1). Stop -signs are static instances in `city/_ai.inst`, drawn with the city. +two or more lanes. Each pole is an unhit Y banger of its model's banger +data (`aiTrafficLightInstance` derives from `dgUnhitYBangerInstance`): its +frame (GetMatrix) is the base + R * CG, at which the CG-centred body is +drawn; `SetFourWay` puts it in the room of its CG (GetPosition). Cars +collide with it as with any prop and knock it over into its BREAKnn parts +(`BangerSet`; the parts are ordinary hit bangers), after which neither its +body nor its glows are drawn; the lights keep cycling. `DrawGlow` draws +the light's glow together with the pedestrian signal (WALK in state 4, +NOWALK otherwise), modelled from the base (GetMatrix less R * CG), and +only when the model has both; NIGHT glows from the evening on (time of +day > 1). Stop signs are static instances in `city/_ai.inst`, drawn +with the city. ## Ambient traffic (`Traffic`, `AmbientRoute`) diff --git a/docs/audio.md b/docs/audio.md index 215911f..8811908 100644 --- a/docs/audio.md +++ b/docs/audio.md @@ -198,7 +198,7 @@ a time. Ported from MM2 `Aud3DObject` and `Aud3DObjectManager`: | Pre-race: r = RandomizeNumber(11.5); ≤ 7.5 a random row of the mode's pre-race range, ≤ 8.5 time of day, ≤ 9.5 weather (nothing if that table was not loaded), else the car's line (else the mode's); it starts 1.5 s later | MM2 (`mmRaceSpeech::PlayPreRace`) | | Results: 1st win; a place above half the field mid; else poor. Win/mid use the car's line when r > 5 of 10, poor when r > 8; without a "mid" table (blitz) nothing is said | MM2 (`PlayResults`, `PlayResultsWin` / `Mid` / `Poor`) | | Final checkpoint, final lap, damage penalty, race progress, texture unlock and results stop the line playing and empty the queue; unlock-race and unlock-vehicle lines wait 0.1 s in the queue. The queue starts with one slot and gains one for every unlock-vehicle line loaded (and every unlock header after the first in a table); a play that finds no free slot is dropped; the first due play starts once nothing is playing | MM2 (`AudSpeech::Play`, `PutInQueue`, `Update`, `Stop`, `AllocateQueuePlayData`) | -| The queue is updated twice a frame while the game runs (GameLoop's `AudManager::Update`, then `mmGame::Update`), so its delays pass at twice the clock's speed: the pre-race line starts 0.75 s after it is queued, not 1.5 s. While the game is paused only `mmGame::Update`'s update is left: the queue counts at the clock's speed and a due line starts during the pause (after the pause has stopped every sound, below) | MM2 (`AudManager::Update`, `mmGame::Update`, `mmSpeechContainer::Update`); `AudioManager` | +| The queue is updated three times a frame while the game runs (`AudManager::Update` twice, by GameLoop and again as asRoot's first node, then `mmGame::Update`), so its delays pass at three times the clock's speed: the pre-race line starts 0.5 s after it is queued, not 1.5 s. While the game is paused only `mmGame::Update`'s update is left: the queue counts at the clock's speed and a due line starts during the pause (after the pause has stopped every sound, below) | MM2 (`AudManager::Update`, `mmGame::Update`, `mmSpeechContainer::Update`); `AudioManager` | | What calls them: the pre-race line at the start (`mmGame::Reset`), the final checkpoint when a checkpoint race has only the finish left and when a circuit's final lap reaches its last checkpoint (`mmWaypoints::Update`, the race's FinalCheckpoint event), the damage penalty and blitz results (`mmSingleBlitz::UpdateGame`), the results and unlock lines (`mmGameSingle::UpdateRewards`). Nothing in build 3393 calls the final lap, race progress or unlock-race lines (`mmRaceSpeech::PlayFinalLap`, `PlayRaceProgress`, `PlayUnlockRace`), the crash course's unlock line or the Cops & Robbers commentary (`mmCNRSpeech::Play`): their tables load but are never heard, and OpenMM2 does not play them either | MM2 (callers of the Play methods) | | File names `NN` | verified against the sample names | | Cops & Robbers: `bullshit.csv` in SF, `cnrlondon.csv` elsewhere; an event draws one of its rows, cuts the line playing and starts after 0.01 s; a fourth "num used" column smaller than the row's range picks a random window of that many lines. The London rows' names end in a space ("AL1\AL1ROBROB "), which MM2 keeps, so those files are never found and London's Cops & Robbers commentary is silent | MM2 (`mmSpeechContainer::InitCNR`, `mmCNRSpeech::LoadGroup`, `SetReadState`, `Play`); the missing files **inferred** from `AudStream::PlayOnce`'s path | @@ -236,7 +236,7 @@ a time. Ported from MM2 `Aud3DObject` and `Aud3DObjectManager`: | Behaviour | Evidence | |---|---| -| While the game is paused (the popup menu in single player, the full-screen map) every wave sound stops, on the first two paused frames: engines, skids, sirens, ambience, rain, voices and the announcer's line. Loops start again from the beginning once the game runs on, as their owners find them stopped; one-shots are cut. The music (DirectMusic) plays on; the popup switches it to its pause segment (`mmPopup::PlayPauseMusic`) | MM2 (`AudManager::Update`, `AudManagerBase::UpdatePaused` with its counter limit of 1, `StopAllSounds`); `audio/game/AudioManager.h`, the race's hook at the start of its frame | +| While the game is paused (the popup menu in single player, the full-screen map) every wave sound stops, on the first two paused updates (both in the first paused frame, `AudManager::Update` running twice a frame): engines, skids, sirens, ambience, rain, voices and the announcer's line. Loops start again from the beginning once the game runs on, as their owners find them stopped; one-shots are cut. The music (DirectMusic) plays on; the popup switches it to its pause segment (`mmPopup::PlayPauseMusic`) | MM2 (`AudManager::Update`, `AudManagerBase::UpdatePaused` with its counter limit of 1, `StopAllSounds`); `audio/game/AudioManager.h`, the race's hook at the start of its frame | ## Not implemented diff --git a/docs/bangers.md b/docs/bangers.md index 9530458..d9705be 100644 --- a/docs/bangers.md +++ b/docs/bangers.md @@ -81,9 +81,12 @@ name. A mass of 0 or less becomes 1 in OpenMM2 (no retail file has one). xrefs place (lvlLevel::LoadInstances, the "xrefs" chunk read by lvlInstance::EndGeom): each xref's matrix times the record's, dropped when a row is zero or two rows have a dot product above 0.01, rows - outside 0.97..1.03 squared length normalised, kept as a full matrix with - the record's variant, in the room FindRoomId finds from the record's - room. An xref whose model has no banger data is not placed at all (MM2 + outside 0.97..1.03 squared length normalised, then requested as Y + bangers (RequestBanger(name, 0), dgUnhitYBangerInstance: only the turn + about Y is kept, the CG offset is turned by the whole matrix) with the + record's variant, in the room FindRoomId finds from the record's room. + 68 retail xrefs were exported Z up (awnings, the Chinatown sign, the + bridge and tower lights): MM2 stands them upright. An xref whose model has no banger data is not placed at all (MM2 reports it as not exported): cl10's trees. 33 retail models have xrefs (tower and bridge lights, awnings, doors, windows): 65 bangers in London, 368 in San Francisco. They are not drawn with their parent model. diff --git a/docs/formats/bai.md b/docs/formats/bai.md index a2d4451..27c24b3 100644 --- a/docs/formats/bai.md +++ b/docs/formats/bai.md @@ -125,10 +125,12 @@ for its end-1 and its end-0 intersection, in that order): * each end intersection lists it last, then sorts its list by `atan2(dx, dz)` from its centre to each road's far end (the road's last centre vertex when its end 0 is the intersection, else its first), - smallest first, and rewrites every listed road's index there. MM2 first - moves the centre to the bound-sphere centre of the intersection's room; - OpenMM2 keeps the file's centre (**inferred**: every main-file list is - already sorted round it); + smallest first, and rewrites every listed road's index there. First the + intersection's centre moves to the bound-sphere centre of its room + (`cityLevel::GetBoundSphere`: the room perimeter's area centroid at the + mean height of its corners); the file's centres of these intersections are + already within 0.21 m of it (66 in San Francisco), and `mmGame::RespawnXYZ` + starts a cruise at the moved centre; * `aiMap::MapRoadToRooms(road, 2)` lists it, as component type 2, for the rooms of its centre vertices 1 to n - 2 and for every room its two curbs cross, sampled trunc(section length) times per section (the second diff --git a/docs/formats/environment.md b/docs/formats/environment.md index 348067e..29278c4 100644 --- a/docs/formats/environment.md +++ b/docs/formats/environment.md @@ -6,11 +6,11 @@ Parsers: `src/city/Environment.{h,cpp}`; loaded by `loadCity`. |------|--------|---------| | `.lt00`..`.lt15` | Angel "type: a" | Lighting per time of day and weather. Index = timeOfDay × 4 + weather (time: morning, noon, evening, night; weather: clear, cloudy, foggy, rainy; verified from the block names). Fields `KeyHeading/KeyPitch/KeyColor`, `Fill1*`, `Fill2*` (radians, 0..1 colours), `Ambient` (signed int, ARGB). | | `_fog.csv` | CSV | 16 rows in the same order: fog colour (0..255), start, end. The description column is ignored per its header. | -| `.sky` | text | `model a b c` (`sky_dome_l 0 0.95 0.005`). Parameter meanings unknown. | +| `.sky` | text | `model a b c` (`sky_dome_l 0 0.95 0.005`), read by `lvlSky::AutoInit` (`%s %f %f %f`): the dome sits at the camera with its height = camera height x b + a (`lvlSky::DrawHat`) and turns about +Y at c radians per second (`lvlSky::Update`). | | `.water` | text | water plane height, then room ids (inferred: rooms that use it). | -| `.ext` | text | `minX minZ maxX maxZ`, a world rectangle inside the PSDL bounds (inferred: HUD map extent). | -| `.reset` | text | `x y z # comment` recovery points ("GG fallthru" = Golden Gate fall-through). | -| `.lmap` | binary | `"LMP0"`, `u32 n`, `u32 argb[n]`. London: n = rooms - 1, entry 0 = 0xCDCDCDCD. SF: n = 1,125 for 1,171 rooms. Likely per-room ambient colours (mm2hook `lvlRoomInfo::Color`). Indexing is unconfirmed and SF's count does not match its PSDL. | +| `.ext` | text | `minX minZ maxX maxZ`, a world rectangle inside the PSDL bounds. midtown2.exe has no reader for it (an editor file). | +| `.reset` | text | `x y z # comment` recovery points ("GG fallthru" = Golden Gate fall-through). midtown2.exe has no reader for it: the game respawns at checkpoints or intersections instead. | +| `.lmap` | binary | `"LMP0"`, `u32 n`, `u32 argb[n]`: one colour per room id (entry 0, the dummy room, is 0xCDCDCDCD). `cityLevel::Load` takes it only when n equals the PSDL's room count (dummy room 0 included); then room i gets argb[i] (`lvlRoomInfo` +0x1c, the street colour and the instance light tint), else every room is white. Neither retail file matches (London 1,341 for 1,342, SF 1,125 for 1,172), so the retail game draws every room white. | | `materials.mtl` | text | Physics materials: `mtl name { elasticity: f friction: f effect: s sound: n drag: f width: f height: f depth: f ptxindex: a b ptxthreshold: a b }`. | | `materials.csv` | CSV | `texture,physics`: texture name → material name (`none`, `cobblestone`, `grass`, `water`, `deepwater`, `sand`, `mud`, `ash`). | diff --git a/docs/formats/images.md b/docs/formats/images.md index 6065542..c74ee87 100644 --- a/docs/formats/images.md +++ b/docs/formats/images.md @@ -40,7 +40,7 @@ rectangle. MM2 stores these images the other way round: its TGA, BMP and JPEG readers all put the picture's top row first (only `.tex` files are copied in file order). Its 2D drawing blits them upright, as OpenMM2's UI does. On a 3D mesh -MM2's v = 0 is the top of a TGA picture, while OpenMM2 uploads row 0 (the -bottom) as v = 0, so a TGA on a mesh is upside down relative to MM2 unless -the renderer flips it. The only retail case is the HUD map dots, which are -symmetric. +MM2's v = 0 is the first stored row, so the top of a TGA, BMP or JPEG picture +and the bottom of a `.tex` one: `game::TextureLibrary::readImage` flips +TGA, BMP and JPEG images to the top row first before it uploads them, so +meshes sample every format as MM2 does. diff --git a/docs/formats/race.md b/docs/formats/race.md index 139ae2c..05208fc 100644 --- a/docs/formats/race.md +++ b/docs/formats/race.md @@ -39,14 +39,21 @@ editor files or crash-course event lists. Header plus one row per race: `Description`, then two blocks of 10 columns: amateur then professional. Each block is `CarType, TimeofDay (0..3), Weather (0..3), Opponents, Cops, Ambient (density 0..1), Peds (density), -NumLaps, TimeLimit (seconds), Difficulty`. +NumLaps, TimeLimit (seconds), Difficulty`. `Cops` is a count (0 to 8 in +the retail tables) that the race menu keeps as the cop density unchanged; +the police placement clamps it to 0..1 (all posts from 1 up). ### Waypoints: `Nwaypoints.csv` `x,y,z,a,radius,...`: checkpoint position, heading in degrees, checkpoint radius. Some files label column 5 `poly count`. The remaining columns (`frame rate`, `state changes`, `texture changes`, `msg`) are editor profiling -fields and are kept verbatim. Crash-course event lists, `copchase.csv` and +fields and are kept verbatim, except that column 6 is the waypoint's hit +flag. Heading h faces (sin h, 0, -cos h): 0 is -Z and 90 is +X. The game +turns the car at the start by h x -0.017453292 about +Y +(`mmWaypoints::GetStartAngle`); a heading of exactly 0 on a waypoint after +the first means "towards the next waypoint" (`mmWaypoints::LoadCSV`). A +radius of 0 means 15. Crash-course event lists, `copchase.csv` and `multicopwaypoints.csv` use the same layout. ### Opponent lines: `*.opp` @@ -55,6 +62,14 @@ CSV `x,y,z,brake,forward offset,side offset,target speed,speed start,side start` Named `-a-.opp` (amateur) and `-p-.opp` (professional) and referenced from the `[Opponent]` section of the `.aimap`. +MM2's `aiRouteRacer::Init` reads only the first four numbers of a row +(`atof`). The first row is the racer's grid place: its fourth column (headed +`brake`) is the start angle in degrees, stored as the car's reset rotation +times 0.017444445 (MM2's own degree factor) and, unlike a waypoint heading, +not negated: 90 faces -X. The rows in between become route waypoints (the +first intersection of the room each point lies in) and the last row is the +destination. The other columns are not read. + ### AI setup: `*.aimap`, `*.aimap_p` `[Section]` headers, `#` comments. A section is a *list* when its first line is @@ -71,7 +86,7 @@ reproduces it for every retail file. | Ambients Drive On The Left | value | 0/1 | | Traffic Lights | value | two model names | | Exceptions | list | `road density speedLimit` per AI path id | -| Police | list | `car x y z heading mode lane ? ?` (the file's comment header is stale) | +| Police | list | `car x y z heading mode behaviours chance range` (the file's comment header is stale): heading in degrees, stored as the reset rotation heading x -0.017444445 (`aiRaceData::aiRaceData`); `mode` is read and never used; `behaviours` (default 15) are the apprehend bits; `chance` (default 0.5) and `range` (default 50 m) govern pursuing opponents | | Opponent | list | `car oppFile ` | | Ambient Types/Density | list | `model cumulativeProbability ?` | | GoodWeatherPedName / BadWeatherPedName | list | `goodModel badModel` | diff --git a/docs/parity/README.md b/docs/parity/README.md index 972bbca..10a4f3a 100644 --- a/docs/parity/README.md +++ b/docs/parity/README.md @@ -72,15 +72,15 @@ record's summary counts them: | phys-bounds | 159 | 101 | 36 | 13 | 1 | 0 | 8 | | vehicle | 141 | 76 | 42 | 6 | 2 | 1 | 14 | | ai-vehicles | 170 | 71 | 67 | 3 | 2 | 1 | 26 | -| ai-ambient-city | 236 | 116 | 61 | 13 | 22 | 4 | 20 | -| session | 205 | 89 | 86 | 6 | 4 | 1 | 19 | +| ai-ambient-city | 236 | 116 | 63 | 11 | 22 | 4 | 20 | +| session | 205 | 87 | 89 | 5 | 4 | 1 | 19 | | camera-props | 195 | 107 | 57 | 11 | 2 | 0 | 18 | | rendering-fx | 197 | 67 | 95 | 8 | 3 | 4 | 20 | | audio | 265 | 69 | 155 | 11 | 10 | 0 | 20 | | frontend-ui | 151 | 80 | 39 | 11 | 3 | 2 | 16 | | formats | 119 | 23 | 44 | 11 | 7 | 0 | 34 | | OpenMM2-only | 34 | 6 | 8 | 5 | 4 | 1 | 10 | -| **Total** | **2078** | **880** | **786** | **107** | **70** | **15** | **220** | +| **Total** | **2078** | **878** | **791** | **104** | **70** | **15** | **220** | The open rows and each record's "Missing" table list what still differs from MM2 and what porting it needs. The larger missing features are the @@ -105,7 +105,7 @@ placement. | Subsystem | Record | Reachable | Ported (new) | Replaced | Not needed | Open | | --- | --- | ---: | ---: | ---: | ---: | ---: | | world objects | [world-objects.md](mm2/world-objects.md) | 297 | 187 (124) | 30 | 75 | 5 | -| game flow and spawns | [game-flow.md](mm2/game-flow.md) | 608 | 382 (62) | 66 | 151 | 9 | +| game flow and spawns | [game-flow.md](mm2/game-flow.md) | 608 | 382 (62) | 65 | 152 | 9 | | AI | [ai.md](mm2/ai.md) | 507 | 409 (17) | 6 | 92 | 0 | | vehicle and physics | [vehicle-physics.md](mm2/vehicle-physics.md) | 736 | 574 (0) | 13 | 146 | 3 | | props and effects | [props-fx.md](mm2/props-fx.md) | 169 | 126 (1) | 0 | 43 | 0 | @@ -127,6 +127,36 @@ record's Open section; the largest are late joins for network cruise and Cops and Robbers, the network tuning checksum, the water respawn sync and remote cars' sounds and damage. +## Round 3: cross-cutting themes (2026-10-13) + +Two differences the maintainer saw after both audits (traffic lights out of +line with their glows, and where cruise mode starts) were classes of bug +that a function-by-function audit misses, because each function looked +right on its own. Round 3 followed data across systems, one theme at a +time, with records under [round3/](round3/): + +| Theme | Record | Cases | Verified | Fixed | Deviation | Inferred | Open | Other | +| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | +| frames: where models, parts, glows and shadows are drawn | [frames.md](round3/frames.md) | 68 | 56 | 6 | 2 | 1 | 0 | 3 no retail data | +| the cruise start (`mmGame::RespawnXYZ`) | [cruise-spawn.md](round3/cruise-spawn.md) | 22 | 5 | 11 | 4 | 1 | 0 | 1 openmm2 | +| MM2's global random stream and its consumers | [random-streams.md](round3/random-streams.md) | 41 | 7 | 16 | 12 | 0 | 0 | 6 not needed | +| init, reset, update and draw order | [order.md](round3/order.md) | 51 | 35 | 10 | 5 | 0 | 1 | | +| units, signs, axes and conventions in data | [conventions.md](round3/conventions.md) | 104 | 83 | 8 | 6 | 3 | 0 | 4 openmm2 | +| **Total** | | **286** | **186** | **51** | **29** | **5** | **1** | **14** | + +The fixes that show most: traffic lights stand on their CG as +`aiTrafficLightInstance` does (the poles were 3.9 m in the ground under +correctly placed glows) and are props cars knock over; cruise starts where +MM2 does, from the one global stream right after `aiMap::Reset` (London +intersection 19 at Marble Arch, San Francisco intersection 121 on the +Embarcadero, with the cruise menu's default densities); a race is set up +from that stream in `mmGame::Init`'s order; semi trailers, knocked traffic +cars' wheels and shadows, and police headlight beams are drawn as MM2 draws +them; a restart happens at the start of the next frame; a paused game keeps +its camera, sky, rain and HUD message going; 68 PKG xref props stand +upright; the player's steering tune comes from `tune/.asnode`. The one +open case is F2's pause (OpenMM2's F2 is the fly camera). + ## Manifest Lines as of the start of the audit (files the audit added: as of when diff --git a/docs/parity/ai-ambient-city.md b/docs/parity/ai-ambient-city.md index 16d3010..ebacfd9 100644 --- a/docs/parity/ai-ambient-city.md +++ b/docs/parity/ai-ambient-city.md @@ -2,10 +2,10 @@ Audited against MM2Recomp (midtown2.exe build 3393) on 2026-10-07; second pass (the other areas' changes to the city code, room flags, props, numbers) -on 2026-10-08. +on 2026-10-08; two random-stream rows updated by round 3 (random-streams). Summary: 236 functions (named entries in the tables; a few constructors -appear in several rows by aspect); verified 116, fixed 61, deviation 13, +appear in several rows by aspect); verified 116, fixed 63, deviation 11, inferred 22, open 4, openmm2 20. ## Random (`src/ai/Random.h`) @@ -14,7 +14,7 @@ inferred 22, open 4, openmm2 20. | --- | --- | --- | --- | | `Random::irand` | `irand()` | fixed | Was an xorshift64*. MM2's irand is the MSVC rand() generator: seed = seed x 214013 + 2531011, bits 16..30. | | `Random::frand` | `frand()` | fixed | Was 24-bit; MM2 is irand() x 3.0517578e-05, so probabilities come in 1/32768 steps. | -| `Random::Random`, `seed` | `ResetRandomSeed` | deviation | MM2 has one global seed (set to 1 by aiMap::Reset) shared by traffic, pedestrians, police, audio and effects; OpenMM2 gives each AI subsystem its own generator (default seed 1) so runs replay deterministically. Exact draw sequences cannot match MM2 anyway, since other subsystems draw from the same global there. | +| `Random::Random`, `seed` | `ResetRandomSeed` | fixed | Round 3 (`docs/parity/round3/random-streams.md`): the traffic and the pedestrians share one stream, which the race's set-up hands over in MM2's order and `World::reset` sets to 1; the police, glows, wheels and effects, which draw only during play (or from MM2's secondary seed), keep their own. | ## Pedestrians (`src/ai/Pedestrians.{h,cpp}`) @@ -115,7 +115,7 @@ inferred 22, open 4, openmm2 20. | `World::step` | `aiMap::Update` | fixed | Order (ambient traffic, pedestrians, then the light sets as children) verified. The player's room is now looked up from the last room as the hint (MM2 passes aiVehiclePlayer's last room; using the last room found is inferred equivalent); 0 leaves the populations alone. | | `World::roomAt` | `cityLevel::FindRoomId` | fixed | Takes a hint (see RoomLocator). | | `World::create` | `aiMap::Init`, `aiCityData`, `aiTrafficLightInstance::Init` | fixed | Pool trunc([Ped Pool] x density), types (race list else city list, good/bad weather), light models (lanes >= 2: the second) and pole matrix verified. [Ped Pool] now comes from the config as sscanf "%d" (a "0" was lost before, see Race); the room locator gets the map name. | -| `World::create` (seeds) | `ResetRandomSeed` | deviation | Traffic seeded with `seed`, pedestrians with `seed x 7919 + 1` (one global in MM2). | +| `World::create` (seeds) | `ResetRandomSeed` | fixed | Round 3: one stream for the traffic and the pedestrians (`Settings::random`, else the world's own from `seed`), the pool's draws first; was traffic `seed`, pedestrians `seed x 7919 + 1`. | | `World::create` (drive on the left fallback) | `aiCityData` | deviation | With no city AI map MM2 drives on the right; OpenMM2 falls back to the cruise maps' flag (retail city maps have the key). | | `defaultAmbientTypes`, `citySuffix` | — | deviation | OpenMM2 fallback when no AI map lists ambient types (MM2 would have none). | | `loadPedTypes` | `pedAnimationInstance::Load` | inferred | Loads every type present; MM2 loads the named types on demand. Same tables. | diff --git a/docs/parity/ai-vehicles.md b/docs/parity/ai-vehicles.md index a5c8dcf..8c8ebdb 100644 --- a/docs/parity/ai-vehicles.md +++ b/docs/parity/ai-vehicles.md @@ -10,7 +10,7 @@ Scope: `src/ai/Course.*`, `Driving.*` (with `DrivingRoute.cpp` and Summary (after the second pass): 170 rows (a row may cover several helpers of one MM2 function); verified 71, fixed 67, open 1, inferred 2, deviation 3, openmm2 26 (after merging integration and applying its physics API). The Missing table lists 10 MM2 functions or groups, 4 of them -now ported. In the opponent sweep (`OPENMM2_AI_SWEEP=1`) 516 of 517 +now ported (5 with round 3's CreateRoadMap centre). In the opponent sweep (`OPENMM2_AI_SWEEP=1`) 516 of 517 opponents finish (517 after the first pass, 516 before the audit). The one that does not, in sf race11 p, is wrecked by landing impacts: every racer of that 8 km professional race (vppanozgt at 40 - 100 m/s over SF's crests) @@ -237,7 +237,7 @@ rather than on every row: | `Polyline::finalize`, `pointAt`, `project` | none | openmm2 | | | `RoadNetwork::build` | `aiMap::ReadBinary`, `aiPath::ReverseDirection`, `InitRoadTurns`, the speed-limit loop of aiMap's init, `aiIntersection::NumSources` / `NumSinks`, aiTrafficLightSet ctor | verified | Reverse only roads whose first side has lanes; exception limit, else city limit (+ 12.5 on freeways); sources and sinks as coded. Second pass: a loop road's light is its end-1 light (aiTrafficLightSet's constructor and SetFourWay take end 1 when the path's end-1 intersection is theirs; was end 0); each road's sharp turns; shortcut roads flagged, without lanes (their lane rows are zeros; no ambient drives them). | | `RoadNetwork::lane`, `exits`, `sidewalksAt` | none | openmm2 | Lookups. | -| shortcut roads (`city::parseShortcutBai`, `addShortcuts`) | `aiMap::ReadBinary`, `aiPath::ReadShortcut`, `aiIntersection::AddRoad`, `CreateRoadMap` | fixed | New (second pass; src/city). _sup.bai's roads (70 London, 49 SF) take the next path ids, both sides closed to ambients and pedestrians, and join their end intersections' lists, which are sorted round the centre and re-indexed. MM2 first moves the centre to the room's bound-sphere centre; OpenMM2 keeps the file's (inferred: the main lists are already sorted round it). Racers and police route over them; pedestrians step over them (GetRoadToRight / GetRoadToLeft). Their waypoint pairs: 317 of London's and 328 of SF's are joined only by a shortcut. | +| shortcut roads (`city::parseShortcutBai`, `addShortcuts`) | `aiMap::ReadBinary`, `aiPath::ReadShortcut`, `aiIntersection::AddRoad`, `CreateRoadMap` | fixed | New (second pass; src/city). _sup.bai's roads (70 London, 49 SF) take the next path ids, both sides closed to ambients and pedestrians, and join their end intersections' lists, which are sorted round the centre and re-indexed. MM2 first moves the centre to the room's bound-sphere centre; so does OpenMM2 since round 3 (cruise-spawn: the file's centres were within 0.21 m of it; RespawnXYZ starts there). Racers and police route over them; pedestrians step over them (GetRoadToRight / GetRoadToLeft). Their waypoint pairs: 317 of London's and 328 of SF's are joined only by a shortcut. | ## Vehicle data and physical traffic (`ai/VehicleData`, `game/TrafficBodies`) @@ -264,7 +264,7 @@ rather than on every row: | `aiRouteRacer::Finished` | The game's finish-line test for opponents | session area | | `aiPoliceOfficer::Push`, `Barricade` | Apprehend behaviours | never chosen in this build (ApprehendPerpetrator always sets Block) | | `aiGoalAvoidPlayer` road re-mapping via `aiPath::DetermineRoadPosInfo` | The avoiding car's road position from its room's component | inferred (lane projection; DetermineRoadPosInfo is now ported for the regain and could serve here) | -| `aiIntersection::CreateRoadMap`'s centre | The intersections a shortcut road joins take their room's bound-sphere centre | open (the file's centre is kept; affects the Shortcut target and the pedestrians' intersections there) | +| `aiIntersection::CreateRoadMap`'s centre | The intersections a shortcut road joins take their room's bound-sphere centre | fixed in round 3 (docs/parity/round3/cruise-spawn.md): `addShortcuts` moves them to `sdlRoomBoundSphere`'s centre | | Entity-less traffic movers (`aiGoalCollision`, `aiGoalRegainRail` declaring the rail instance, flags 8 and 10) | Ambient cars off their rails that the physics manager moves without a body | open: needs phys::World to take Instance movers without a Body (phys-core) | ## Outside this area diff --git a/docs/parity/camera-props.md b/docs/parity/camera-props.md index d6a43f7..0fb6150 100644 --- a/docs/parity/camera-props.md +++ b/docs/parity/camera-props.md @@ -224,11 +224,11 @@ and the existing `test_camera.cpp`, `test_bangers.cpp`, `test_catalog.cpp`, | `AiRoad::sidewalkVertexMulti` | `lvlAiMap::GetSidewalkVertexMulti` | fixed | New; the 0x400000 curb pull (95% towards the centre) ported though no retail road has it. | | `AiRoad::sidewalkVertexSingle` | `lvlAiMap::GetSidewalkVertexSingle` | fixed | New; left curb/outer vertices 1/0, right 2/3 (divided 4/5); without sidewalks vertex 0 left, 1 right. | | `AiRoad::vertexSingleCenter` | `lvlAiMap::GetVertexSingleCenter` | fixed | New (used by the curb pull only). | -| `placeStreetProps` | `cityLevel::Load` (propulate loop), `cityPropulator::Propulate`, `lvlSDL::Propulate`, the propulator's placement callback | fixed | Rule 0 has no props; the left rule stands on the walk with side 1, walked first; X axis normalised with the (z, y, x) sum; maxUse spent per rule side and road even when the chosen file cell is empty; frand / irand order verified. Roads without the sidewalk flag are skipped (identical result: every candidate is degenerate). MM2 quits on a missing rule or def; OpenMM2 skips it. Retail effect: same 10,184 street props, positions within 7 cm, 10 room changes in London. | +| `placeStreetProps` | `cityLevel::Load` (propulate loop), `cityPropulator::Propulate`, `lvlSDL::Propulate`, the propulator's placement callback | fixed | Rule 0 has no props; the left rule stands on the walk with side 1, walked first; X axis normalised with the (z, y, x) sum; maxUse spent per rule side and road even when the chosen file cell is empty; frand / irand order verified. Roads without the sidewalk flag are walked too (round 3: they draw as in MM2, and every candidate is degenerate, so nothing more stands); the stream's state after the last road is reported for the rest of the set-up (`docs/parity/round3/random-streams.md`). MM2 quits on a missing rule or def; OpenMM2 skips it. Retail effect: same 10,184 street props, positions within 7 cm, 10 room changes in London. | | `racePropsName` | `cityLevel::Load`, `dgGameModeNames` | fixed | New: the race's path set name (roam, race%d, multicop, circuit%d, blitz%d, crash%d). | | `placeCityProps` | `cityLevel::Load`, `lvlLevel::LoadInstances` | fixed | cityLevel::Load's order (street rules, .inst, _ai.inst, props.pathset, then the race's path set, which was not placed at all); .inst bangers keep the full matrix unless stored in the Y form and take the variant byte; after each record (banger or not) the bangers its geometry's PKG xrefs place (were drawn by CityRenderer as static children instead). Banger records are picked by banger data, MM2 by the record flag 0x200: identical on retail (107 flagged records, all with data, no unflagged record with data). | | `pkgXrefs` | `lvlInstance::EndGeom` (`modPackage::OpenFile("xrefs")`) | fixed | New: the "xrefs" chunk of geometry/.pkg (named with its NUL; a count, then 80-byte entries). Agrees with asset::parsePkg on all 33 retail models that have one. | -| `placeXrefs` | `lvlLevel::LoadInstances` (xref loop) | fixed | New: xref matrix times the record's (Matrix34::Dot); a zero row or a pair of rows with a dot product above 0.01 drops it ("bad x-ref matrix"), rows outside 0.97..1.03 squared length normalised; only models with banger data (RequestBanger, else "not exported": cl10's trees); full matrix, the record's variant, room found from the placement point starting at the record's room (`PlacedProp::roomHint`). Retail: 65 bangers in London, 368 in San Francisco. | +| `placeXrefs` | `lvlLevel::LoadInstances` (xref loop) | fixed | New: xref matrix times the record's (Matrix34::Dot); a zero row or a pair of rows with a dot product above 0.01 drops it ("bad x-ref matrix"), rows outside 0.97..1.03 squared length normalised; only models with banger data (RequestBanger, else "not exported": cl10's trees); Y rotation only (RequestBanger(name, 0); round 3, conventions: was the full matrix, which tipped 68 Z-up xrefs such as SF's awnings 90 degrees), the record's variant, room found from the placement point starting at the record's room (`PlacedProp::roomHint`). Retail: 65 bangers in London, 368 in San Francisco. | ## src/game/bangers/RoadDecals.{h,cpp} diff --git a/docs/parity/mm2/ai.md b/docs/parity/mm2/ai.md index 3f3ad96..500f7d7 100644 --- a/docs/parity/mm2/ai.md +++ b/docs/parity/mm2/ai.md @@ -197,7 +197,7 @@ The light sets and their poles (first audit: ai-ambient-city). | --- | --- | --- | --- | | `aiTrafficLightSet::aiTrafficLightSet`, `Reset`, `SetFourWay`, `Update` | ported | `TrafficLights` | First audit. | | `aiTrafficLightSet::~aiTrafficLightSet`, `scalar_deleting_destructor` | not needed | | | -| `aiTrafficLightInstance::Init`, `Draw`, `DrawGlow` | ported | `World::create`, `AiRenderer::drawSignal` | First audit. | +| `aiTrafficLightInstance::Init`, `Draw`, `DrawGlow` | fixed | `World::create`, `AiRenderer::drawSignal`, `Signal::frame` | Init places the instance at the pole's base + R * CG (the model's dgBangerData CG) and Draw draws the CG-centred body at that frame; DrawGlow takes R * CG off again for the glow and WALK meshes, which are modelled from the base. OpenMM2 drew the body at the base, sinking the pole by its CG (3.9 m) under glows at the right height; the body, visibility, room and NoDraw now use the CG frame. Round 3 (frames): the instance is a dgUnhitYBangerInstance, a collidable prop in its CG's room that cars knock over into its BREAKnn parts; it now lives in the BangerSet and AiRenderer draws it from the prop's matrix while it stands (docs/parity/round3/frames.md). | | `aiTrafficLightInstance::SizeOf` | not needed | | Instance pool bookkeeping. | ## aiVehicleAmbient / aiVehicleSpline / aiVehicle / aiObstacle @@ -273,7 +273,7 @@ The ambient car's level instance (drawn, collided; no body until hit). | --- | --- | --- | --- | | `aiVehicleInstance::aiVehicleInstance` | ported | `Traffic::Traffic`, `AiRenderer::carModel` | Geometry parts; the arbitrary number (+0x18) for the blink phase (now `AmbientCar::blinkPhase`, a hash of the car index); SetColor; InitBreakable. | | `SetColor` | ported | `Traffic::Traffic`, `AiRenderer::draw` | trunc(frand x (paint jobs - 1)). | -| `Draw`, `DrawPart`, `DrawShadow` | ported | `AiRenderer::draw`, `VehicleRenderer` | Body, wheels by the rail's tyre rotation, shadow (rendering areas). | +| `Draw`, `DrawPart`, `DrawShadow` | ported (fixed) | `AiRenderer::draw`, `VehicleRenderer`, `TrafficBodies::wheelsOf`, `trafficShadowMatrix` | Body, wheels by the rail's tyre rotation, shadow (rendering areas). Round 3 (frames): a car with an aiVehicleActive draws WHL0-5 at its vehWheelCheaps' drawing matrices, and its shadow on the ground (DrawPhysics); a rail car's shadow goes to the ground when it is upside down (docs/parity/round3/frames.md). | | `DrawGlow` | ported | `AiRenderer::draw`, `VehicleRenderer::drawGlows` | Fixed: TLIGHT while the car decelerates or stands (acceleration below 0 or speed 0; was a 0.5 threshold on both); new: SLIGHT0 / SLIGHT1 for the indicators (bits 1 and 2) while bit 3 of the car's number plus aiVehicleManager's clock (time x 16) is set. Headlight glows as before. | | `DrawReflected`, `DrawShadowMap` | not needed | | Empty in build 3393. | | `GetBound`, `GetMatrix`, `GetPosition`, `GetEntity`, `AttachEntity`, `Detach` | ported | `TrafficBodies::RailCar` | First audit. | diff --git a/docs/parity/mm2/audio.md b/docs/parity/mm2/audio.md index 44d88d9..623e34e 100644 --- a/docs/parity/mm2/audio.md +++ b/docs/parity/mm2/audio.md @@ -27,9 +27,11 @@ What this audit found and ported: OpenMM2 kept the engines, skids and ambience sounding under the popup and the full-screen map. (`audio/game/AudioManager`.) * **The announcer's timing.** The speech container is updated by - `AudManager::Update` and again by `mmGame::Update`, so the queued delays - pass twice as fast while the game runs (the pre-race line starts 0.75 s - after it is queued) and keep counting while it is paused. + `AudManager::Update`, which runs twice a frame (GameLoop, then asRoot's + node), and again by `mmGame::Update`, so the queued delays pass three + times as fast while the game runs (the pre-race line starts 0.5 s after + it is queued; round 3's [order](../round3/order.md) audit) and keep + counting while it is paused. * **Resets.** `Aud3DObject::Reset` through the owners' Reset (cars, traffic, the world objects) takes the slot away and forgets the distance history; OpenMM2 had no such call. @@ -62,11 +64,12 @@ its `Update` once a frame before the game's update. OpenMM2's replace the DirectSound side; the per-frame behaviour is `audio/game/AudioManager` (new): while the game is paused every sound stops on the first two paused frames, and while it runs the announcer's queue is -updated here as well as in `mmGame::Update` (twice a frame). +updated here (twice a frame, GameLoop and asRoot) as well as in +`mmGame::Update` (three times a frame). | MM2 | Status | OpenMM2 | Notes | | --- | --- | --- | --- | -| `AudManager::Update` | ported (new) | `audio/game/AudioManager.cpp` `AudioManager::update`; `app/RaceScreen.cpp` at the start of the frame | Paused: the virtual UpdatePaused and nothing else. Running: the paused counter (+0x1e) back to 0, then `mmSpeechContainer::Update`, so with `mmGame::Update`'s own call the announcer's delays pass twice as fast as the clock (the pre-race line starts 0.75 s after it is queued). The `audManager::Update` and `AudMidi::MidiUpdate` calls are the mixer's job / never reached (no MIDI). | +| `AudManager::Update` | ported (new) | `audio/game/AudioManager.cpp` `AudioManager::update`; `app/RaceScreen.cpp` at the start of the frame | Paused: the virtual UpdatePaused and nothing else. Running: the paused counter (+0x1e) back to 0, then `mmSpeechContainer::Update`, called twice a frame (GameLoop, then asRoot's node: `AudioManager::updateFrame`), so with `mmGame::Update`'s own call the announcer's delays pass three times as fast as the clock (the pre-race line starts 0.5 s after it is queued; round 3 order audit). The `audManager::Update` and `AudMidi::MidiUpdate` calls are the mixer's job / never reached (no MIDI). | | `AudManagerBase::UpdatePaused`, `AudManagerBase::StopAllSounds` | ported (new) | `AudioManager::update`, `Mixer::stopAll` | While the counter is at most 1 (a constant): `audManager::StopAllSounds` for all five sound types, then count. So every wave sound (engines, skids, sirens, ambience, rain, voices, the announcer's line) stops on two paused frames; the DirectMusic music is not one of them. Test `AudioParityMm2.PausedGameStopsEverySoundOnTwoUpdates`. | | `AudManagerBase::Update` | not needed | - | The base class's Update; the only manager MM2 makes is an AudManager, whose own Update overrides it. Its extra `AudStreamingMusic::StreamingMusicUpdate` has nothing to play (see AudStreamingMusic). | | `AudManager::AudManager`, `AudManagerBase::AudManagerBase`, `AudManager::Init`, `AudManager::Enable`, `AudManager::Disable`, `AudManagerBase::Enable`, `AudManagerBase::Disable`, `AudManagerBase::IsEnabled`, `AudManager::DeviceValid`, `AudManager::~AudManager`, `AudManagerBase::~AudManagerBase`, `AudManager::`scalar_deleting_destructor'`, `AudManagerBase::`scalar_deleting_destructor'`, `InitAudioManager`, `KillAudioManager` | replaced | `app/App.cpp` (the Mixer), `audio/AudioDevice.cpp`, `audio/SoundBank.cpp` | Creating and opening the DirectSound device and Angel's audio library. What the game hears from it is kept: 32 concurrent sounds (`Mixer::kMaxVoices`, SetMaxConcurrent(1, 32)), the 22 kHz files (`SoundBank`, SetDefSubPath aud22 / .22K), the SOUND FX volume at full (AssignWaveVolume(1)). InitAudioManager's CD half is in the CD row below. `-noaudio` / `-nosoundfx` are the infrastructure audit's (command-line options). | @@ -338,8 +341,8 @@ mmSpeechContainer for the race (with COMMENTARY on): the race speech the crash course (mmCCSpeech), each an AudSpeech with groups of numbered lines streamed from `aud/aud11`, a small queue of delayed plays and the line playing. The game's events call the Play methods; the container is updated -by AudManager::Update and by mmGame::Update (twice a frame while running, -once while paused). OpenMM2: `Announcer` (Voices.cpp), first audit; the +by AudManager::Update (twice a frame) and by mmGame::Update (three times a +frame while running, once while paused). OpenMM2: `Announcer` (Voices.cpp), first audit; the update timing is new (`AudioManager`). | MM2 | Status | OpenMM2 | Notes | diff --git a/docs/parity/mm2/city-render.md b/docs/parity/mm2/city-render.md index 34aa5cd..7f8a3ec 100644 --- a/docs/parity/mm2/city-render.md +++ b/docs/parity/mm2/city-render.md @@ -279,7 +279,7 @@ the shadow pass. | `crSkeleton::Init`, `Update`, `Attach`, constructor | ported | `asset::posePed` | the bone-to-model matrices | | `crKinematics`, `crKinematicsBase` (`Init`, `Reset`, `Update`, `MatchPose`, `SetIKBlend`, `SetLimp`, destructors), `crLegData` (`Init`, `SolveIK`, `SolveLimpIK`, `MatchPose`, `Acosf`), `crSpineData` (`Init`, `SolveIK`, `MatchPose`, destructor), `crBodyData` and `crBodyDataBase` destructors, `pedActive::IsAsleep`, `pedActiveData` destructor | not needed | — | the pedestrian ragdoll's inverse kinematics: only `pedActive` builds them, and `pedRagdollMgr`'s constructor and `Init` have no caller, so no ragdoll exists (reachable only through vtables) | | `pedAnimation::Load` | ported (fixed) | `asset::parsePedAnimTable`, `asset::normalizePedRoots`, `Pedestrians::fwdSpeed`, `latSpeed` | each row's root x and z of frames 0..m lose frame 0's value and the straight line to frame m (m = last − first, at most frames − 1). OpenMM2 kept frame 0's root and removed the drift over the whole clip, so a pedestrian was drawn off its position by the clip's start: 2.2 m to the side in the dives, 3.1 m ahead in the run-to-walk, 0.28 m in the walk | -| `pedAnimation::Init`, `LookupSequence`, `DrawSkeleton` | ported | `AiRenderer::pedType` (the per-name type cache `Init` sets up), `PedAnimTable::find`, `AiRenderer::drawSkeleton` | | +| `pedAnimation::Init`, `LookupSequence`, `DrawSkeleton` | ported | `AiRenderer::pedType` (the per-name type cache `Init` sets up), `PedAnimTable::find`, `AiRenderer::drawSkeleton`, `skeletonWidthAxis` | Round 3 (frames): the quads widen along the modelview's first row in the pedestrian's space (fixed). | | `pedAnimationInstance::Draw` | ported (fixed) | `AiRenderer::drawPed` | the current frame posed from the adjusted data; the draw-time drift subtraction is gone | | `pedAnimationInstance::Load`, `Init`, `Reset` | ported | `AiRenderer::pedType`, `Pedestrians` | | | `pedAnimationInstance::PreUpdate`, `Update`, `Start`, `VerifySeq` | ported | `Pedestrians::animate`, `startSeq` | 30 frames per second; forward, single-frame and (no retail row) backward sequences; MM2 quits on a sequence out of range, OpenMM2's table lookups guard | diff --git a/docs/parity/mm2/game-flow.md b/docs/parity/mm2/game-flow.md index 44201e8..bf16629 100644 --- a/docs/parity/mm2/game-flow.md +++ b/docs/parity/mm2/game-flow.md @@ -4,7 +4,9 @@ Audited from MM2Recomp (midtown2.exe build 3393) on 2026-10-08. Summary: 608 reachable functions (overloads counted separately) in 38 classes and the free functions; ported 382 (of which newly ported 62), -replaced 66, not needed 151, open 9. Constructors, destructors and the +replaced 65, not needed 152, open 9 (mmMultiCR::Reset moved from replaced +to ported in round 3's cruise-spawn audit; mmGame::UpdatePaused from ported +to not needed in round 3's order audit). Constructors, destructors and the deleting destructors of a class share one row. Some behaviours inside ported or replaced functions are still open too; every open item is listed under Open items. @@ -40,9 +42,9 @@ them. | What | MM2 | OpenMM2 | | --- | --- | --- | | Player, Blitz / circuit / checkpoint race / crash course | the modes' `InitGameObjects`: `SetResetPos(GetStart)`, angle `GetStartAngle` x -0.017453292, `vehCar::Reset`; then `mmGame::InitOtherPlayers` (via `mmGameSingle` for the circuit): probe 2 m above the body to 10 m below (`dgPhysManager::Collide`, mask 0x20), reset place 0.9 m above the hit | `RaceSetup::playerPlace` / `playerDrop` (`StartDrop::OnGround`), `SimVehicle::setResetPos`, `reset`, `settleOnGround` | -| Player, cruise | `mmSingleRoam::InitOtherPlayers`: `RespawnXYZ(true, true, false)`, intersection centre + 2 m, angle 0, no probe | `randomIntersectionStart`, `StartDrop::None` | +| Player, cruise | `mmSingleRoam::InitGameObjects` at mmGame's (0, 10, 0); after `aiMap::Reset`, `mmSingleRoam::InitOtherPlayers`: `RespawnXYZ(true, true, false)` from the global stream as aiMap::Reset left it, intersection centre + 2 m, angle 0, no probe | `RaceSetup::respawnStart`, `Session::placeRespawnStart`, `ai::World::resetAndPopulate`, `StartDrop::None` (round 3: [cruise-spawn](../round3/cruise-spawn.md)) | | Player, multiplayer races | `mmMulti*::InitNetworkPlayers`: `StartXYZ(slot)` from the start (wide grid when the model radius > 6 or a trailer), `FindGroundPos` (7.5 m up, 15 m down), `SetResetPos`, `Reset`; slot = `NetStartArray` (the host's enumeration order at START) | `multiplayerGridOffset(startSlot)`, `findGroundPos`, `StartDrop::FindGround` | -| Player, multiplayer cruise / Cops and Robbers | `RespawnXYZ(true, true, true)`: seeded with the local player id, a counter of draws | `randomIntersectionStart`, seed 1 + player id (deviation: own random stream) | +| Player, multiplayer cruise / Cops and Robbers | `mmGameMulti::InitOtherPlayers`: the mode's `Reset` and then `InitNetworkPlayers` each call `RespawnXYZ(true, true, true)`, seeded with the local player id, counting one more draw a pick each time; the second pick is the start | `cruiseStart`, seed 1 + player id (deviation: no DirectPlay ids; round 3: [cruise-spawn](../round3/cruise-spawn.md)) | | Racers | `aiRouteRacer::Init` (first `.opp` row, its 4th column x 0.017444445), then `mmGame::CollideAIOpponents`: the same probe from the model origin, 0.9 m up | `RaceScreen::spawnOpponents` (vehicle audit's settle) | | Police | `aiPoliceOfficer::Reset`: their post and angle, no probe; how many: trunc(posts x cop density) | `spawnPolice` | | Multiplayer | no racers or police; the race modes load no AI map at all; cruise and Cops and Robbers keep only pedestrians (`mmGameMulti::Init`) | `loadAi`, `loadRaceSetup` (new) | @@ -114,14 +116,14 @@ movers, `aiMap::Update`), `Reset` (a restart). | `mmGame::InitOtherPlayers` | ported (new) | `RaceSetup::playerDrop`, `SimVehicle::settleOnGround` (vehicle audit) | from the body 2 m up to 10 m down, 0.9 m above the hit; OpenMM2 put the model origin on the ground (0.7 m lower) | | `mmGame::CollideAIOpponents` | ported (new) | `RaceScreen::spawnOpponents` | the same probe from each racer's model origin | | `mmGame::FindGroundPos` | ported (new) | `game::session::findGroundPos` | 7.5 m up to 15 m down, the hit itself or the point; only the multiplayer grids | -| `mmGame::RespawnXYZ` | ported | `randomIntersectionStart` | intersection `irand % (n - 1) + 1`; rejects rooms 0x24, and with its second argument rooms 0x0A and alley paths (0x2), with the first freeway paths (0x4); 2 m up, angle 0; the seeded form (multiplayer): the player id as seed and a counter of draws (deviation: OpenMM2's own stream; MM2's single-player start depends on every earlier draw of the seed-1 stream) | +| `mmGame::RespawnXYZ` | ported | `respawnXYZ`, `cruiseStart` | (counter + 1) draws `irand % (n - 1) + 1` a pick, the last kept, until one fits; rejects rooms (FindRoomId of the centre) 0x24, and with its second argument rooms 0x0A and alley paths (0x2), with the first freeway paths (0x4); 2 m up, angle 0; the seeded form (multiplayer): the player id as seed, the run-long counter advanced. Single player draws from the global stream as aiMap::Reset left it (round 3: [cruise-spawn](../round3/cruise-spawn.md)) | | `mmGame::Reset` | ported | `Session::restart`, RaceScreen `Restart` | props (props audit), cars to their reset places, water timer, race-over flag, pre-race speech, music restart (audio audit), elasticity cap; `aiMap::Reset` (ambient traffic, pedestrians, lights, seed 1) open: ai subsystem | | `mmGame::StartMusic`, `UpdateDMusic` | ported | `MusicDirector` | audio record | | `mmGame::Update` | ported | `RaceScreen::update`, `Session::update` | the water flag is the splash's latched active flag (new: was the depth each frame); water and fall checked after the ending too (new); one-step offset accepted | | `mmGame::DropThruCityHandler` | ported | `Session::dropThroughCity` | below y = -50: the mode's Reset (single player), the water handler (multiplayer); string 29 only goes to a debug print | | `mmGame::HitWaterHandler` | ported | RaceScreen `Respawn` | `mmPlayer::Reset`, back to the reset place | | `mmGame::UpdateDebugInput` | ported (new) | `RaceScreen::update`, `debugKeys` | Escape stops the announcer then `ProcessEscape`; F1 the keymap; F4 restart (single player); F6 roster (network); Ctrl+Alt+Shift+F7 the single-player chat line; F2's pause is not ported (open: OpenMM2's fly camera holds F2) | -| `mmGame::UpdatePaused` | ported | `RaceScreen::updateGameInput` | C / V camera keys; F1 while paused (new); F2 unpause open (above) | +| `mmGame::UpdatePaused` | not needed | | never called in build 3393: it sits in the game's UpdatePaused slot, and `asNode::UpdatePaused` is only reached from its own recursion and `AudManager::Update`; a paused game takes its keys through `mmGame::Update`'s `UpdateGameInput` (round 3 [order](../round3/order.md) audit, which removed the raw C / V keys OpenMM2 had taken from it). F2's unpause belongs with F2's pause (open, above) | | `mmGame::UpdateHorn` | ported | `PlayerCarAudio::updateHorn` | the player's siren lights (vpcop, vpsemi) are not drawn: open (rendering) | | `mmGame::UpdateGameInput` | ported | `RaceScreen::updateGameInput` | session record; the CD player events are the open `mmCDPlayer` item | | `mmGame::UpdateSteeringBrakes` | ported | `ArcadeControls`, `RaceScreen::updatePlayer` | inputs read back from the replay buffer as bytes (input audit's `replayQuantize`); multiplayer forward-gear throttle cap | @@ -171,7 +173,7 @@ movers, `aiMap::Update`), `Reset` (a restart). | `mmPlayer::IsMaxDamaged`, `ResetDamage`, `EnableRegen`, `UpdateRegen` | ported | `CarDamage::maxDamaged`, DamageReset, `m_regen`, `CarSim::regenerate` | | | `mmPlayer::UpdateHOG` | not needed | — | never fires (vehicle record) | | `mmPlayer::FFImpactCallback`, `UpdateFF`, `ResetFF` | ported | input audit | force feedback | -| `mmPlayer::FileIO` | ported | defaults | | +| `mmPlayer::FileIO` | ported | `GameInput::setPlayerTune` | reads tune/.asnode, which every retail car but vpcentury and vpdune ships (round 3, conventions: OpenMM2 kept the constructor values for every car) | | `mmPlayer::AfterLoad`, `BeforeSave` | not needed | — | empty | ## mmSingleRoam (cruise) @@ -357,7 +359,7 @@ movers, `aiMap::Update`), `Reset` (a restart). | `mmMultiRoam::InitNetworkPlayers`, `mmMultiRace::InitNetworkPlayers`, `mmMultiCircuit::InitNetworkPlayers`, `mmMultiBlitz::InitNetworkPlayers` | ported | `loadVehicle` (grid, ground), `updateRemoteCars` | the remote cars are replaced by snapshots | | `mmMultiRoam::InitGameObjects`, `mmMultiRace::InitGameObjects`, `mmMultiCircuit::InitGameObjects`, `mmMultiBlitz::InitGameObjects` | ported | `RaceSetup`, sounds | | | `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 | `RaceSetup` start | runs only at the start (a second seeded draw in MM2) | +| `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) | @@ -379,10 +381,10 @@ movers, `aiMap::Update`), `Reset` (a restart). | `mmMultiCR::InitHUD` | ported | `Hud` CR readouts | the per-player roster, the HUD gold indicator and the map's bank / hideout / gold markers are open (HUD) | | `mmMultiCR::InitGameObjects` | ported | `Hud::drawCrObjects` | | | `mmMultiCR::InitNetworkPlayers` | ported | `RaceSetup` start | team colours of the network players' icons (HUD audit) | -| `mmMultiCR::Reset` | replaced | the lobby | | +| `mmMultiCR::Reset` | ported | `cruiseStart` | at the start (from mmGameMulti::InitOtherPlayers) its RespawnXYZ is the first of the two seeded picks; the rest is the lobby's (round 3: [cruise-spawn](../round3/cruise-spawn.md)) | | `mmMultiCR::LoadCSV` | ported | `loadCrLocations` | fewer than three rows: every place an intersection (new; OpenMM2 turned the mode off) | | `mmMultiCR::LoadSets`, `GetRandomIndex`, `ResetPositions` | not needed | — | `multicopsets.csv` exists in no retail city (ResetPositions would give the bank the hideout's z) | -| `mmMultiCR::GetNewSet`, `GetRandomPoints` | ported | `CopsAndRobbers::newSet`, `randomPoint` | intersections never in rooms 0x24 (new); MM2's picker seeds its own stream from the clock (deviation: the shared seed) | +| `mmMultiCR::GetNewSet`, `GetRandomPoints` | ported | `CopsAndRobbers::newSet`, `randomPoint` | intersections from `respawnXYZ(false, false)`, never in rooms 0x24 by FindRoomId of the centre (round 3); MM2's coin seeds a second stream from the clock and its intersection draws come from the host's global stream (deviation: the shared seed, one draw a pick) | | `mmMultiCR::UpdateGame`, `SwitchState` | ported | `updateNetwork`, Session | | | `mmMultiCR::UpdateGold` | ported (new) | `updateNetwork` | the gold's room must be the car's, a host alone cannot take it, the carrier does not see its gold | | `mmMultiCR::UpdateBank`, `UpdateHideout` | ported (new) | `updateNetwork` | the base's room covered / underground or the car's | diff --git a/docs/parity/mm2/infrastructure.md b/docs/parity/mm2/infrastructure.md index ee3c966..8f1e795 100644 --- a/docs/parity/mm2/infrastructure.md +++ b/docs/parity/mm2/infrastructure.md @@ -133,8 +133,8 @@ MM2 has two generators: | MM2 | Status | OpenMM2 | Notes | | --- | --- | --- | --- | -| `irand`, `frand` | ported | `ai/Random.h`, `game/fx/Random.h`, `phys/World.h`, `phys/vehicle/Wheel.h` | Same LCG and scaling. Deviation (documented in `ai/Random.h`): each OpenMM2 subsystem has its own generator, so identical inputs replay identically per subsystem, while MM2's systems share one sequence and their interleaving changes every draw. OpenMM2's per-subsystem split plays the role of the second seed. | -| `ResetRandomSeed` | ported | `game/bangers/PropPlacement.cpp` (seed 1 before placing props); the AI generators are seeded per session | | +| `irand`, `frand` | ported | `ai/Random.h` (`game::fx::Rand` is the same class), `phys/World.h`, `phys/vehicle/Wheel.h` | Same LCG and scaling. Round 3 (`docs/parity/round3/random-streams.md`): the race's set-up and the AI world draw from one stream in MM2's order (the street props' last road, the cars' sirens and splashes, the gizmos, the racers, the traffic, the pedestrians, the cable cars), so what a race shows at its start is MM2's; the systems that draw only during play keep their own generators. | +| `ResetRandomSeed` | ported | `game/bangers/PropPlacement.cpp` (seed 1 before each road's props), `ai::World::reset` (aiMap::Reset) | | | `EnableGlobalSeed`, `DisableGlobalSeed` | ported | the effects' own generators (`game/fx`) | | | `Random::Seed`, `Random::Number` | ported | `audio/AngelRandom.cpp` | Audio record. | diff --git a/docs/parity/mm2/vehicle-physics.md b/docs/parity/mm2/vehicle-physics.md index d53b795..cae6088 100644 --- a/docs/parity/mm2/vehicle-physics.md +++ b/docs/parity/mm2/vehicle-physics.md @@ -108,7 +108,7 @@ renderer and audio objects. | `vehWheel::AddNormalLoad`, `vehWheel::SetNormalLoad`, `vehWheel::SetInputs`, `vehWheel::ComputeFriction`, `vehWheel::CalcSuspensionForce`, `vehWheel::GetBumpDisplacement`, `vehWheel::ComputeDwtdw`, `vehWheel::Update`, `vehWheel::GetVisualDispVert` | ported | `phys/vehicle/Wheel.cpp` | As vehicle.md records. | | `vehWheel::GetSurfaceSound` | ported | `audio/game/CarAudio` | The material's sound index (audio). | | `vehWheel::~vehWheel`, `` vehWheel::`scalar_deleting_destructor' ``, `vehWheel::GetClassName` | not needed | | Plumbing. | -| `vehWheelCheap::vehWheelCheap`, `vehWheelCheap::Init`, `vehWheelCheap::Reset`, `vehWheelCheap::Update` | ported | `game/TrafficBodies.cpp` | The traffic bodies' wheels (aiVehicleActive); constructor defaults as `TrafficBodies`' wheel constants. | +| `vehWheelCheap::vehWheelCheap`, `vehWheelCheap::Init`, `vehWheelCheap::Reset`, `vehWheelCheap::Update` | ported | `game/TrafficBodies.cpp` | The traffic bodies' wheels (aiVehicleActive); constructor defaults as `TrafficBodies`' wheel constants. Round 3 (frames): the drawing matrix Init and Update leave (+0x128) is kept for aiVehicleInstance::Draw. | | `vehWheelCheap::~vehWheelCheap`, `` vehWheelCheap::`scalar_deleting_destructor' `` | not needed | | Plumbing. | ## vehDrivetrain, vehEngine, vehTransmission, vehAero, vehAxle @@ -179,7 +179,7 @@ siren, shadow, and the collision bound (`InitBound`). Drawing is | `vehCarModel::GetTrailerHitch` | ported | `SimVehicle::load` (the `trailer_hitch` pivot) | | | `vehCarModel::Reset`, `vehCarModel::ClearDamage` | ported | `VehicleRenderer::reattachAll`, `resetDamage` | Breakables back on, texel damage cleared, the one-shot eject armed again, the wheels shown. | | `vehCarModel::EjectOneshot` | ported | `VehicleRenderer::wreckParts`, RaceScreen | | -| `vehCarModel::Draw`, `vehCarModel::DrawPart`, `vehCarModel::DrawShadow`, `vehCarModel::DrawGlow`, `vehCarModel::DrawHeadlights` | ported | `VehicleRenderer::drawCar`, `drawPart`, `drawShadow`, `drawGlows` | | +| `vehCarModel::Draw`, `vehCarModel::DrawPart`, `vehCarModel::DrawShadow`, `vehCarModel::DrawGlow`, `vehCarModel::DrawHeadlights` | ported | `VehicleRenderer::drawCar`, `drawPart`, `drawShadow`, `drawGlows` | Round 3 (frames): DrawHeadlights' sweep directions are kept in world space (fixed). | | `vehCarModel::DrawShadowMap`, `vehCarModel::DrawReflected` | not needed | | lvlInstance vtable slots 0x34 and 0x3c: no code of build 3393 calls through them (cityLevel draws shadows through slot 0x30). | | `vehCarModel::SetVisible`, `vehCarModel::GetVisible` | ported | RaceScreen `drawLevel` (`playerBody`) | `mmPlayer::Update` hides the car in the point-of-view cameras; `mmMirror::Cull` shows it for the mirror. | | `vehCarModel::GetPosition`, `vehCarModel::GetMatrix`, `vehCarModel::SetMatrix`, `vehCarModel::GetVelocity`, `vehCarModel::GetEntity`, `vehCarModel::AttachEntity` | ported | `VehicleBody::position`, `Body::bound` matrix, `Body::ics`, `Body::entity` | lvlInstance's virtuals for the car: the vehCarSim's world matrix and velocity, the vehCar as entity, no attach. | @@ -205,7 +205,7 @@ siren, shadow, and the collision bound (`InitBound`). Drawing is | `vehTrailer::Load`, `vehTrailer::Save` | not needed | | File loading and saving through the node interface; the tune is read by `loadTrailerParams`. | | `vehTrailer::~vehTrailer`, `` vehTrailer::`vector_deleting_destructor' ``, `` vehTrailer::`vector_deleting_destructor'`adjustor{180}' ``, `vehTrailer::GetClassName`, `vehTrailer::GetDirName` | not needed | | Plumbing. | | `vehTrailerInstance::Init` | ported | `SimVehicle::load` (the `_trailer` model), `VehicleRenderer` ("TRAILER" / "TWHL") | | -| `vehTrailerInstance::Draw`, `vehTrailerInstance::DrawShadow` | ported | `VehicleRenderer::drawCar`, `drawShadow`, `SimVehicle::trailerPose` | The trailer's "shadow" part on the ground; tail lights above 0.1 brake. | +| `vehTrailerInstance::Draw`, `vehTrailerInstance::DrawShadow` | ported (fixed) | `VehicleRenderer::drawTrailer`, `drawShadow`, `SimVehicle::trailerPose` | The trailer's "shadow" part on the ground; tail lights above 0.1 brake. Round 3 (frames): drawn as vehTrailerInstance does (body alone below H; TLIGHT in the object pass and TWHL0-3 only at H; none of vehCarModel's other parts; no glows), no longer as a car. | | `vehTrailerInstance::DrawShadowMap` | not needed | | No caller of the slot (as vehCarModel's). | | `vehTrailerInstance::GetMatrix`, `vehTrailerInstance::SetMatrix`, `vehTrailerInstance::GetPosition`, `vehTrailerInstance::GetVelocity`, `vehTrailerInstance::GetEntity`, `vehTrailerInstance::AttachEntity`, `vehTrailerInstance::GetTrailerHitch` | ported | `Trailer::modelMatrix`, `VehicleBody::position`, `Body`, `Trailer::init` | | | `vehTrailerInstance::SizeOf` | not needed | | Plumbing. | diff --git a/docs/parity/mm2/world-objects.md b/docs/parity/mm2/world-objects.md index cdd805a..f30d314 100644 --- a/docs/parity/mm2/world-objects.md +++ b/docs/parity/mm2/world-objects.md @@ -30,15 +30,16 @@ of mmGame (so they update before `dgPhysManager::Update` in | bridge | giz_bridge01_l | always (none without that model) | London 7 leaves (Tower Bridge and two Waterloo bridges two leaves each, the Tower's drawbridge open), SF 2 (the China Town gate, inactive) | | train | va_ug_l | always | London 8 tube trains of 3 cars | | ferry | giz_carferry01_f | single player only | London 6, SF 1 | -| parkedcar | giz_pcar0N_l | single player, and network races (not network cruise or Cops and Robbers) | London 492, SF 90 (seed 1) | +| parkedcar | giz_pcar0N_l | single player, and network races (not network cruise or Cops and Robbers) | London 488, SF 102 (from MM2's stream, round 3; 492 and 90 from seed 1) | In a network game in London every bridge is set open (type 3) and the bridge manager reset. `InitGizmos` also adds the player's car and each aiMap opponent as bridge proximity triggers, but aiMap has no opponents yet (`aiMap::Clean` zeroed them), so the player is the only trigger. The random draws (sailboat paint jobs and speeds, ferry speeds, parked cars) come from -MM2's global `rand()`; OpenMM2 gives them their own generator seeded with 1 -(inferred: the draw order is MM2's, the global state is not reproducible). +MM2's global `rand()`, which the street props leave at a known state and the +player's car advances by 312 draws; since round 3 OpenMM2 draws them from that +same stream (`docs/parity/round3/random-streams.md`). Then `aiMap::Init` creates the cable cars (see aiCableCar) unless the state pack's EnableCableCars is clear (`mmGameMulti::Init` clears it and @@ -234,7 +235,7 @@ network game), updates them after the AI drivers, draws them, plays their | MM2 | Status | OpenMM2 | Notes | | --- | --- | --- | --- | -| `aiCableCar::aiCableCar`, `~aiCableCar`, `Init` | ported (new) | `CableCars::create` | One frand per car, after the gizmos' draws (aiMap::Init follows InitGizmos). | +| `aiCableCar::aiCableCar`, `~aiCableCar`, `Init` | ported (new) | `CableCars::create` | One frand per car for the array's constructors (aiRailSet), then one per car in Init, after the pedestrians' draws (round 3). | | `aiCableCar::DetermineSister` | ported (new) | `CableCars::determineSister` | As coded, the direction followed only changes through IsCableCarStart's outputs and the road it last reported is looked up. | | `aiCableCar::Reset` | ported (new) | `CableCars::resetCar` | | | `aiCableCar::Update` | ported (new) | `CableCars::updateCar`, `updateAudio` | The knocked-loose branch (instance flag 1 cleared: the sister stops, the car resets once `aiMap::FindAmbAppRoad` finds no ambient car coming) cannot run: see ImpactCB. | diff --git a/docs/parity/rendering-fx.md b/docs/parity/rendering-fx.md index 681dc3b..5214bb9 100644 --- a/docs/parity/rendering-fx.md +++ b/docs/parity/rendering-fx.md @@ -123,7 +123,7 @@ GREATER 100, and that every retail room colour is white because | `AiRenderer::draw`: signals | `aiTrafficLightInstance::Draw` | fixed | `lvlInstance::IsVisible` with the Object Detail thresholds | | `AiRenderer::draw`: room visibility | `cityLevel::DrawRooms` | fixed | MM2 draws traffic, pedestrians and signals from the visible rooms' lists; ported in the MM2-side audit (`RoomVisibility`, see mm2/city-render.md) | | `AiRenderer::drawPed` | `pedAnimationInstance::Draw`, `modModel::Draw` | fixed | posed with the root drift taken out; the default culling (was none; the retail meshes face out counter-clockwise, tested) | -| `AiRenderer::drawSkeleton` | `aiPedestrianInstance::Draw`, `pedAnimation::DrawSkeleton`, the pedestrian type loader's .rays reading | fixed | second pass: beyond 35 m, for each bone with a start width its position raised by the offset, a quad to its parent across the camera's right axis, coloured trunc(255 x diffuse) of the variant's shader its row names; untextured, unlit, both sides | +| `AiRenderer::drawSkeleton` | `aiPedestrianInstance::Draw`, `pedAnimation::DrawSkeleton`, the pedestrian type loader's .rays reading | fixed | second pass: beyond 35 m, for each bone with a start width its position raised by the offset, a quad to its parent across the modelview's first row taken in the pedestrian's space (round 3, frames: was the camera's right axis), coloured trunc(255 x diffuse) of the variant's shader its row names; untextured, unlit, both sides | | `AiRenderer::drawSignal` | `aiTrafficLightInstance::Draw`, `DrawGlow` | fixed | the first shader set; the light's glow and the walk signal both or neither, added, unfogged, default alpha test, only within NoDraw | ## src/game/VehicleRenderer.h, VehicleRenderer.cpp @@ -145,13 +145,13 @@ GREATER 100, and that every retail room colour is white because | `VehicleRenderer::draw` | `vehCarModel::Draw`, `DrawShadow`, `DrawGlow` | verified | | | `VehicleRenderer::drawCar` | `vehCarModel::Draw`, `vehBreakableMgr::Draw` | fixed | VL body only with the stored shaders; panoz gt paint 4 alpha reference 0; the extra `_dmg` loop removed | | `VehicleRenderer::wheelMatrix` | `vehCarModel::Draw` | fixed | WHL4/WHL5 at WHL2/WHL3's matrix moved 2.2 wheel radii back along the body | -| `VehicleRenderer::drawTraffic` | `aiVehicleInstance::Draw` | fixed | the colour's shaders, BREAK0–3 at H, reflection and wheels only at H (LAME_WHEELS off), no steering | +| `VehicleRenderer::drawTraffic` | `aiVehicleInstance::Draw` | fixed | the colour's shaders, BREAK0–3 at H, reflection and wheels only at H (LAME_WHEELS off), no steering; with an aiVehicleActive the vehWheelCheaps' matrices (round 3, frames) | | `VehicleRenderer::shadowMatrix`, `drawShadow` | `vehCarModel::DrawShadow`, `lvlInstance::DrawPhysics` | fixed | default alpha test (was GREATER 100) | | `VehicleRenderer::addLightGlow`, `drawGlows` | `vehCarModel::DrawGlow`, `DrawHeadlights`, `ltLight::DrawGlow`, `aiVehicleInstance::DrawGlow` | fixed | default alpha test; traffic: TLIGHT while braking and with the light flag, one white headlight pair pulled 0.2 m to the camera | -| headlight sweep with the siren | `vehCarModel::DrawHeadlights` | deviation | ±42.4 rad/s sweep; the sweep's base direction is OpenMM2's | +| headlight sweep with the siren | `vehCarModel::DrawHeadlights` | fixed | ±42.4 rad/s sweep of the two world-space directions from wherever they point (round 3, frames); MM2 also turns them for the mirror's view (deviation: once a frame) | | suspension and engine parts | `vehSuspension::Update`, `vehCarModel::Init` (shock0–3, arm0–3, shaft2/3, axle0/1, engine) | deviation | not drawn; no retail vehicle model has any of these parts (scanned every geometry/v*.pkg) | | traffic turn signals | `aiVehicleInstance::DrawGlow` (SLIGHT0/1) | open | see Missing (ai-vehicles) | -| `VehicleRenderer::setLensFlareTarget`, siren flares in `drawGlows` | `vehSiren::Init`, `vehSiren::Draw` | fixed | second pass: one ltLensFlare(20) per car with sirens; each siren light's flares queued with ltLight::ComputeIntensity(eye, 0.05) | +| `VehicleRenderer::setLensFlareTarget`, siren flares in `drawGlows` | `vehSiren::Init`, `vehSiren::Draw` | fixed | second pass: one ltLensFlare(20) per car with sirens; each siren light's flares queued with ltLight::ComputeIntensity(eye, 0.05). Round 3: the flares are drawn from MM2's global stream at the car's vehCar::Init (`setSirenFlares`, `takeVehCarInitDraws`; `docs/parity/round3/random-streams.md`) | ## src/game/MeshDraw.h, MeshDraw.cpp @@ -330,7 +330,7 @@ GREATER 100, and that every retail room colour is white because | OpenMM2 | MM2 | Verdict | Notes | | --- | --- | --- | --- | -| `mesh.vert`: lighting | Direct3D 7 fixed-function lighting | inferred | ambient + Σ max(0, N · −L) × colour, clamped per vertex (DirectX behaviour, outside midtown2.exe) | +| `mesh.vert`: lighting | Direct3D 7 fixed-function lighting | inferred | ambient + Σ max(0, N · −L) × colour, clamped per vertex (DirectX behaviour, outside midtown2.exe). Round 3 (conventions): the normal goes through the inverse transpose of the world matrix and is not renormalised, as Direct3D does with NORMALIZENORMALS off (MM2 never sets it: gfxRenderState::Init zeroes the state block); OpenMM2 renormalised, which lit the 1,500-odd scaled .inst buildings and props at unit strength | | `mesh.vert`: environment map | `modShader::BeginEnvMap` (camera-space normals back to world space) | fixed | u = 0.5 + 0.5 x, v = 0.5 − 0.5 y of the world-space normal | | `mesh.vert`: fog | `gfxRenderState` fog vertex mode (offset 0x25, linear) | inferred | linear per-vertex fog by view depth (Direct3D's vertex fog) | | `mesh.frag`: alpha test, texture stages | `gfxRenderState::DoFlush` (ALPHAREF/ALPHAFUNC, texture stage modulate) | inferred | discard when alpha is below the reference: GREATER 100 as at least 101/255, the default NOTEQUAL 0 as at least 1/255; stages modulate as Direct3D's do | diff --git a/docs/parity/round3/conventions.md b/docs/parity/round3/conventions.md new file mode 100644 index 0000000..a39c6ab --- /dev/null +++ b/docs/parity/round3/conventions.md @@ -0,0 +1,191 @@ +# Round 3: conventions + +Audited against MM2Recomp (midtown2.exe build 3393) on 2026-10-08. + +Summary: 104 values; verified 83, fixed 8, deviation 6, inferred 3, +open 0, openmm2 4. + +The question for this theme: does every value that crosses from MM2's +data files or code into OpenMM2 keep its meaning at each step: units, +signs, axes, angle conventions (degrees or radians, which way is zero, +which way is positive), speeds (mph or m/s), times (seconds, frames or +milliseconds), colours (byte order, 0..1 or 0..255), densities (fractions +or counts) and the defaults MM2 uses when a value is missing? Each value +was followed from MM2's reader (the FileIO or Load function, decompiled +and, where the decompile loses values, read in the asm) to every place MM2 +uses it, and compared with OpenMM2's parser and every OpenMM2 use site, +looking at the seams: where OpenMM2 converts a value, hands it to another +module, keeps it in its own structure or uses it in its own glue. The +earlier records (`docs/parity/*.md` and `docs/parity/mm2/*.md`) compared +most formulas function by function; this record does not repeat them. + +Conventions used below: a heading h "faces (sin h, 0, -cos h)" means the +object's forward axis (its -Z, -m2) points that way; `Mat34::rotationY(a)` +is MM2's `Matrix34::MakeRotateY`, rows (cos a, 0, -sin a), (0, 1, 0), +(sin a, 0, cos a), so it faces (-sin a, 0, -cos a). + +Screenshots checked (kept under `local/shots/`, not in git): the San +Francisco shop awning on `nw_b4awn_ff01_8_f` before (a flat strip edge-on) +and after (a sloped awning over the shop window) the xref fix, and a +scaled London petrol station before and after the normals fix (7% brighter +walls, nothing else on screen changed). + +## Race files and spawns + +| Value | MM2: reader and meaning | OpenMM2 | Verdict | +| --- | --- | --- | --- | +| Waypoint columns | `mmPositions::Load`: x, y, z, heading by atof, radius by atoi (0 means 15), column 6 the hit flag | `city::parseWaypoints`, `buildCheckpoints` | verified | +| Waypoint heading | degrees; the users turn by h x -0.017453292, so h faces (sin h, 0, -cos h): 0 is -Z, 90 is +X | `headingDirection`, `Gate::calculateGatePoints` (gate across the heading) | verified | +| Zero heading | `mmWaypoints::LoadCSV` / `ReInit`: a waypoint after the first with heading exactly 0 takes atan2(x - x', z - z') x -57.295776 towards the next one | `headingTowards` | verified | +| Player start angle | the modes' InitGameObjects: `GetStartAngle` x -0.017453292 into vehCarSim +0x250 | `startPlace`, `spawnAt` | verified | +| vehCarSim +0x250 | `vehCarSim::Reset`: identity, the reset position, `Matrix34::Rotate` about +Y (MakeRotateY) by the angle | `CarSim::reset`, `age::makeRotate` | verified | +| Reset angle carried as a matrix | MM2 stores the angle | OpenMM2's spawns are `Mat34`s and `phys::resetRotationOf` takes atan2(m2.x, m2.z) back: the same turn, wrapped into (-pi, pi], within float rounding | openmm2 | +| `.opp` grid row | `aiRouteRacer::Init`: the first row's fourth column (headed `brake`) x 0.017444445 into +0x250, not negated (90 faces -X) | `loadRaceSetup` (`op.spawn`) | verified | +| `.opp` other rows | only x, y, z read; the middle rows become the first intersection of their room, the last the destination | `Opponent::routeFromPath` | verified | +| `[Police]` heading | `aiRaceData::aiRaceData`: heading x -0.017444445 (MM2's own degree factor); `aiPoliceOfficer::Reset` copies it to +0x250 | was -heading x pi / 180 | fixed | +| `[Police]` other columns | `%d %d %f %f`: a mode never read, the behaviour bits (default 15), the chance to pursue an opponent (default 0.5) and the range for it (default 50 m) | `PoliceSettings::fromData` | verified | +| `[Opponent]` numbers | `%f %d %f %f %d %d %d %d %d %f` with defaults 1, 0, 50, 0.7, 1, 1, 1, 1, 0, 1 | `OpponentSettings::fromData` | verified | +| `[CopChaseDistance]` | metres, default 250 | `PoliceSettings::chaseDistance` | verified | +| Race table `Cops` | a count (0 to 8) that `RaceMenuBase::SetStateRace` keeps in the cop density as it is; `aiMap::Init` clamps it when placing posts; `RegisterFinish` compares the setting with the count | was clamped to 0..1 when applied | fixed | +| Race table `Ambient`, `Peds` | floats used as the traffic and pedestrian densities, not clamped | `applyRaceTableDefaults` clamps to 0..1 (retail values are 0 to 0.9) | deviation | +| Race table `TimeofDay`, `Weather` | 0..3, the lighting table index time x 4 + weather | clamped to 0..3 (retail values are in range) | deviation | +| Race table `TimeLimit`, `NumLaps` | seconds (mmPlayer +0xd2c counts down); laps | `RaceSetup::timeLimit`, `laps` | verified | +| Crash course columns | `mmSingleStunt::LoadEventFile`: `cornerspeed` in mph against vehCarSim +0x24c (below 1 means 50), `chkflags`, `numopp`, `TimeLimit` in seconds, `AmbDensity` | `loadRaceSetup` (`LessonEvent`) | verified | +| Multiplayer grid | `mmGameMulti::StartXYZ`: the slot's offset (two tables, long vehicles above radius 6 or towing) turned about +Y by the start angle and added to the start, then `FindGroundPos` | `multiplayerGridOffset`, RaceScreen | verified | +| Cruise start angle | `mmGame::RespawnXYZ`: 0 (facing -Z), 2 m above the intersection | `randomIntersectionStart` (the pick is the random-streams theme's) | verified | +| Water respawn | `mmSingleCircuit` / `mmGameMulti::HitWaterHandler`: the last cleared waypoint, heading x -0.017453292 | `Session::respawnAtLastCheckpoint` | verified | +| Racers' finish line | `aiMap::SetWaypoints`: the point and (0, 0, 1) turned by `Vector3::RotateY`(heading x -0.017453292) | `Opponent::setFinishLine` | verified | +| Multiplayer finish camera | `mmGameMulti::SetFinishCam`: azimuth (heading + 180) x -0.017453292 | `startFinishCamera` | verified | +| Cops and Robbers places | `mmMultiCR::LoadCSV` of `multicopwaypoints.csv`: positions only | `loadCrLocations` | verified | +| Speed for display and rules | vehCarSim +0x24c = speed (m/s) x MetricFactor 2.2360249, the only use of that constant | `CarSim::speedMph` | verified | +| km/h speedometer | MM2 has none | `HudOptions::metric`, off by default | deviation | + +## City files + +| Value | MM2: reader and meaning | OpenMM2 | Verdict | +| --- | --- | --- | --- | +| `.inst` compact form | `lvlFixedRotY::GetMatrix`: m0 = (a, 0, b), m1 = Y, m2 = (-b, 0, a) | `city::parseInstances` | verified | +| `.inst` full form and flags | four rows; low byte the variant, 0x100 terrain-local, 0x200 banger, 0x2000 multi-room; a banger record keeps its full matrix unless compact | `Inst.cpp`, `CityLevel`, `placeCityProps` | verified | +| `.inst` scale | kept in the matrix, drawn through Direct3D (see the normals row under Renderer) | kept | verified | +| PKG xref bangers | `lvlLevel::LoadInstances`: xref x record, then `RequestBanger(name, 0)`, a dgUnhitYBangerInstance keeping only the turn about Y (the CG offset turned by the whole matrix) | was the full matrix: 68 of 177 retail xrefs are exported Z up and were tipped 90 degrees (SF's shop and Chinatown awnings, Harrods' awning, the Golden Gate, Bay Bridge and tower lights) | fixed | +| Path set type 1 (pairs) | `dgPath::Enumerate`: m0 from point i to i + 1 with y zeroed, m2 = m0 x Y, m1 = m2 x m0, Y-only bangers | `placePathSet` (sets m1 to Y exactly where MM2's cross product can be 1 ulp off) | verified | +| Path set type 2 (strip) | m0 normalised in 3D, m2 and m1 by cross products, not normalised; step L / floor(L / spacing); full matrix | `placePathSet` | verified | +| Path spacing | byte x 0.25 m, 0 means 5 m | `PathSet.cpp` | verified | +| Parked cars | the path matrix times MakeRotateY(pi / 2), position kept | `Gizmos.cpp` | verified | +| Bridge, ferry, train, sailboat frames | bridge Z from the other leaf, X = Y x Z; the movers' spline tangent into m2, then `Matrix34::Normalize` | `Gizmos.cpp` | verified | +| `.bai` section axes | xAxis points left, zAxis back (a left-handed set) | turned into right-handed frames at every use (`Traffic::solvePose`, `World.cpp` light poles) | verified | +| `[Speed Limit]` | m/s (15), freeways + 12.5 | `RoadNetwork::build` | verified | +| `.ltNN` lights | headings and pitches in radians; `SetLightDirection` = (cos h cos p, sin p, sin h cos p), the way the light travels; colours 0..1; `Ambient` an ARGB integer | `city::loadLighting`, `CityRenderer` `lightDirection`, `unpackRgb` | verified | +| `_fog.csv` | R, G, B by atoi (0..255), start and end as shorts; start = min(far - 30, start), end = min(far, end); row = time x 4 + weather | `makeEnvironment` | verified | +| `.sky` | `lvlSky::AutoInit`: model, then height offset a and camera-height scale b (`DrawHat`: camera y x b + a), turn rate c in rad/s (`Update`) | `CityRenderer::drawSky`, `update` | verified | +| `.water` | the level, then room ids flagged water of death | `parseWater`, `RoomInfo` | verified | +| `.lmap` | `cityLevel::Load` takes it only when its count equals lvlSDL's room count (read by `LoadBinary` just before, dummy room 0 included): London has 1341 for 1342, SF 1125 for 1172, so every room is white | every room white | verified | +| `.ext`, `.reset` | no reader in midtown2.exe | parsed, never used | openmm2 | +| PSDL street colours and UVs | room colour, half of it, the wall light table entry (earlier records); UVs sampled with D3D's v = 0 at the first stored row | `CityRenderer`, `TextureLibrary` (see Renderer) | verified | + +## Tune files + +| Value | MM2: reader and meaning | OpenMM2 | Verdict | +| --- | --- | --- | --- | +| `vehCarSim` and its parts | each class's FileIO and constructor: Mass 2000, InertiaBox (2, 1, 3) full extents, SteeringLimit 0.39 rad, OptimumSlip 0.14, gear speeds in mph turned into ratios with 1609.344 / 60, RPM x 0.10471976 | `phys::loadCarSimParams` | verified | +| `vehGyro`, `vehStuck` | floats; stuck defaults 1.57, 0.39, 0.1, 0.3, 1.25, 1.75 | `loadGyroParams`, `loadStuckParams` | verified | +| `vehCarDamage` | thresholds; SmokeOffset in the body (CG) frame; its rule fields feed one engine-smoke rule shared by every car | `loadCarDamageParams`, `VehicleEffects` | verified | +| `vehTrailer`, `dgTrailerJoint` | trailer mass 3000, box (3, 4, 9); joint defaults from `dgTrailerJoint::Init` | `loadTrailerParams`, `loadTrailerJointParams` | verified | +| `aiVehicleData` | Size a full box, CG the bound offset; the constructor leaves fields unset | `loadVehicleData`; a missing CG (three retail files) is 0 | inferred | +| `.info` | `mmVehInfo::Load` in field order; flag 0x40 right-hand drive moves the small map | `parseVehicleInfo`, `Hud` | verified | +| Camera files | CameraFOV vertical degrees; Pitch radians; AfterLoad's near planes; the track camera's speeds in m/s | `CamParams` | verified | +| CameraFar | one global every file overwrites, then replaced by the far-clip option at race start | each camera keeps its own, the renderer uses the option | verified | +| `.mmmirror` | Position (the frame's m3), Size (screen fractions), Fov vertical degrees, Aspect, NearClip, FarClip; defaults (0, 1.4, -1), (0.3, 0.16), 10, 2, 1.2, 100 | `MirrorParams` | verified | +| `tune/.asnode` | `mmPlayer::Init` names the player node after the car and loads it; `mmPlayer::FileIO` reads the speed bases, the keyboard and game pad rates and the mouse, joystick and wheel curves that `mmPlayer::Update` blends by speed | was never loaded: every car used the constructor's values (vpbug: SpeedBaseHi 44.6 not 100, keyboard DeltaOut 2.573 -> 0.8 not 3.5 -> 2.5) | fixed | +| `.asbirthrule` | named fields, defaults Gravity -9.8 and 1s; Color stored 0xAABBGGRR and turned into 0xAARRGGBB vertex colours | `BirthRule.cpp`, `Particles.cpp` | verified | +| `dgBangerData` fields | read by position in the retail order; NumGlows 1 when absent; Size (0.2, 0.5, 0.2), Mass 50, CollisionType 0x10 | `parseBangerData` (by name; every retail file has the expected order) | verified | +| `dgBangerData` birth rule | `asBirthRule::Load` skips the block's name and reads by position | only a block named `BirthRule` was read: `sp_tree1_s_break06` (`asBirthRule {`) lost its leaves | fixed | +| `dgBangerData` mass guard | none | a mass of 0 or less becomes 1 (no retail file) | deviation | +| `.mmhudmap` | Size and Pos as screen fractions (minus 10 px), ZoomIn; "Approach Rate" and "Ocean Color" contain spaces and are never read | `Hud` | verified | +| `_dash.asnode` | needles turned by -angle about Z, RotMin / RotMax in radians; speed against 160 mph, RPM against 8000 with an 800 floor | `loadDashParams`, `drawDash` | verified | +| `tune/widget.csv` | `WArray::Read`: X and W over the screen width, Y and H over its height (the menus run at 640x480); a table value replaces a nonzero code value | `MenuLayout` | verified | +| `widget.csv` quirks | MM2 keeps the code's X when all four values are within 0.0021 of it, and tests the code value after adding the dialog origin | not reproduced: at most about 1.3 px, and no page passes a zero coordinate inside an offset dialog | inferred | +| `tune/.movie` | `rate` in frames per second (default 30) | `TextureLibrary` | verified | + +## Audio + +| Value | MM2: reader and meaning | OpenMM2 | Verdict | +| --- | --- | --- | --- | +| Sound volume | `audSound::SetVolume`: (v - 1) x 10000 hundredths of a dB, after the class master and a 0..1 clamp | `ageVolumeToGain`, `Mixer`, `SoundSlot` | verified | +| Pan sign | `audSound::SetPan`: pan x 10000; positive attenuates the left channel | `agePanToMixer`, `Mixer` | verified | +| Pan source | `Aud3DObject::CalcSinglePlayerPan`: listener-space x / pseudo distance x 0.2; the listener is the camera's matrix | `Audio3D::pan` with `m_camera.transform` | verified | +| Pitch | the wave's own rate x ratio, clamped to 100..100000 Hz | `Mixer`, `SoundSlot::clampPitch` | verified | +| Master sliders | log10(200 s) / log10(200), 0 kept at 0 | `ageMasterVolume` | verified | +| Music and ambience volume | `DMusicWaveBuffer::SetVolume`: clamp, the log mapping, then (x - 1) x 10000 | `Mixer` | verified | +| Drop-off distances | squared min and max; attenuation 1 - p used as a volume | `Audio3D` | verified | +| Speeds into audio | m/s (vehCarSim +0x248, aiVehicleSpline's speed) against the tables' bands; RPM from vehCarSim +0x2c4 | RaceScreen | verified | +| Mixer's own 3D model | none | no game sound passes an emitter | openmm2 | +| Balance | the Windows mixer (outside midtown2.exe) | a linear far-channel cut | inferred | + +## HUD, frontend and options + +| Value | MM2: reader and meaning | OpenMM2 | Verdict | +| --- | --- | --- | --- | +| HUD map camera | `mmHudMap::Cull`: RotX(-90 degrees) then the heading; screen up is the car's forward when rotating, -Z otherwise; a proper rotation | `mapCamera` | verified | +| HUD map viewport | screen fractions from the top left, 60 degrees vertical, near 10, far 1600, aspect 1.25 (2.5 split) | `Hud` | verified | +| HUD map arrows | tip (0, 0, -1), base (±0.7, 0, 1); colours 0xAARRGGBB; icons upright, 15 m up, scaled | `Hud`, `argbColor`, `mapIconMatrix` | verified | +| Map on non-4:3 screens | fixed 4:3 | widened | deviation | +| Steering sign | left -1, right +1 from input to `vehWheel::SetInputs` (angle -steer x SteeringLimit), the dashboard wheel (-steer x WheelFact about Z) and the mouse arrow | `GameInput`, `Wheel`, `Hud` | verified | +| Clock and lap times | seconds, + 0.005, MM:SS:HH | `clockText`; network times in ms both ways | verified | +| Traffic and pedestrian density | floats 0..1 on 11-step sliders; cruise 0.25 / 0.5 / 1 | `RaceConfig`, `PagesRace` | verified | +| Slider stepping | `UISlider::Action` steps mmSlider's own value, which `SetValue` clamped when `UISlider::Update` took the variable | stepped from the unclamped variable (a count of 3 went to 1, not 1 - step) | fixed | +| Time of day and weather order | morning, noon, evening, night; clear, cloudy, fog (headlights on), rain | `TimeOfDay`, `Weather` | verified | +| Far clip, light quality | 100..1000 m into `gfxViewport::Perspective`; quality 0..3 | `PagesOptions`, RaceScreen | verified | +| Multiplayer densities | the pedestrian density sent as a byte / 255 | OpenMM2's own protocol sends percent | deviation | + +## Renderer + +| Value | MM2: meaning | OpenMM2 | Verdict | +| --- | --- | --- | --- | +| Camera FOV | vertical degrees (`gfxViewport::Perspective`: y scale 1 / tan(fov / 2), x scale y / aspect) | carried as the 4:3 horizontal FOV and widened (Hor+) | verified at 4:3; the widening is the any-resolution extra (deviation, as recorded in camera-props) | +| Other projections | wide view 70 degrees, dashboard near 0.01, mirror's own Fov / Aspect / clips, HUD map 60 degrees, showroom 0.6 rad turned into degrees | `CamPlayer`, RaceScreen, `Hud`, `Showroom` | verified | +| Depth and handedness | D3D 0..1 depth, a left-handed projection with a z-flipped view | right-handed -Z projection with zero-to-one depth | verified | +| Mirrored frames | `mmMirror::Init` negates m0 and `Cull` swaps the cull mode; no retail `.inst` matrix has a negative determinant | `RearViewMirror`, `setFrontFaceFlipped` | verified | +| Texture rows | v = 0 is the first stored row: `.tex` bottom first, TGA, BMP and JPEG top first | `TextureLibrary::readImage` flips TGA, BMP and JPEG | verified | +| Environment map | camera-space normal back to world space, u = 0.5 + 0.5 x, v = 0.5 - 0.5 y | `mesh.vert` | verified | +| Colour byte order | D3D ARGB; PKG vertex colours red and blue swapped on load | `argbToRgba` at every upload | verified | +| Fog | linear vertex fog on view depth, colour / 255 | `mesh.vert` | verified | +| Per-light ambient and specular | zeroed; never enabled | not modelled | verified | +| Normals of scaled instances | Direct3D 7 fixed function, NORMALIZENORMALS never set: the inverse transpose of the world matrix, not renormalised (Direct3D's rule) | was the forward matrix then normalised: the scaled buildings and props (about 1,500 in London, 2,400 in SF) were lit at unit strength | fixed (the Direct3D rule is inferred) | + +## Angles, frames and times derived in code + +| Value | MM2: meaning | OpenMM2 | Verdict | +| --- | --- | --- | --- | +| Drawn wheels | the player's from the simulation's wheel matrices; traffic tyres about X by -(distance driven) | `PlayerVehicle`, `AiRenderer` | verified | +| Remote cars' wheels | none (DirectPlay sent no wheel state) | steered -0.5 x steering, spun from the speed | openmm2 | +| Pedestrian facing | `aiPedestrian::Update`: m0 = (-cos h, 0, sin h), m2 = (-sin h, 0, -cos h) | `Pedestrians::frameOf` | verified | +| Traffic heading | `aiGoalAvoidPlayer::Reset`: atan2(m2.x, m2.z) | `Traffic.cpp` | verified | +| Traffic light poles | X towards the light axis, Z = X x Y | `World.cpp` | verified | +| Cable cars | side = (-dz, 0, dx) | `CableCars.cpp` | verified | +| AI backup steering | `aiVehiclePhysics::Backup`: atan2(d . m0, -d . m2), steer x -2.857143 | `Driving.cpp` | verified | +| Stand-in player car | none (OpenMM2's tool and tests) | `ai::PlayerCar::at` faced against its velocity (an extra half turn) | fixed | +| Time units | physics in 1/60 s samples; AI at 30 Hz with its per-frame counts; particles 1/60 s; pedestrian frames 1/30 s | `Constants.h`, `World.h`, `Particles.h`, `LineSparks.cpp`, `CamTrack.cpp` | verified (the fixed step is recorded in phys-core and ai-vehicles) | +| Light direction and wall shades | `cityLevel::SetLightDirection`, `sdlCommon::UpdateLighting` | `CityRenderer` | verified | +| Wheel effect index | materials' `ptxindex` into `vehWheelPtx`'s dirt, dust, grass, leaf, smoke, snow, splash, rock | `EffectLibrary` | verified | + +## Fixes + +| Commit | What | +| --- | --- | +| dc9f9cb | race police posts turned by heading x -0.017444445 (was pi / 180) | +| 67e02bc | the race table's cop count kept as the cop density; the slider steps from its clamped value | +| ffd80d6 | the police chase tests start the suspect 75 m behind the cop (the 70 m point is just off the road with the corrected heading) | +| 52b505e | `tune/.asnode` loaded as the player's steering tune | +| 3d11dff | PKG xref bangers keep only their turn about Y | +| cc92314 | a banger's birth-rule block read whatever it is called | +| 71c0608 | `ai::PlayerCar::at` faces along its velocity | +| dc95635 | scaled instances lit with Direct3D's unnormalised inverse-transpose normals | +| 186760f | the format docs: waypoint, `.opp` and `[Police]` angles, the cop count, `.sky`, `.lmap`, `.ext`, `.reset`, texture rows | + +## Open, and for other areas + +Nothing in this theme is left open. For the AI area: for crash courses +`mmSingleStunt::LoadEventFile` also writes clamp(AmbDensity, 0, 1) x 0.2 +into aiMap +0x3c, which nothing appears to read (likely dead; not checked +further). diff --git a/docs/parity/round3/cruise-spawn.md b/docs/parity/round3/cruise-spawn.md new file mode 100644 index 0000000..3f4c904 --- /dev/null +++ b/docs/parity/round3/cruise-spawn.md @@ -0,0 +1,173 @@ +# Round 3: cruise spawn + +Audited against MM2Recomp (midtown2.exe build 3393) on 2026-10-08. + +Summary: 22 rows; verified 5, fixed 11, deviation 4, inferred 1, open 0, +openmm2 1. Missing: 2 (1 not needed, 1 ported by the merge with +[random-streams](random-streams.md)). + +The maintainer asked: "when cruising, can you confirm that you spawn in the +correct location?" Before this audit, no: OpenMM2 picked a random AI +intersection at race setup with a glibc-style generator seeded from the +clock, so every cruise started somewhere else, and it judged rooms by the +`.bai` room field. MM2 starts a single-player cruise at the same +intersection every time in a city, because `mmGame::RespawnXYZ` draws it +from the one global random stream right after `aiMap::Reset` set that +stream to seed 1 and drew from it. OpenMM2 now reproduces the whole chain; +with the cruise menu's settings the starts are: + +| City | Intersection | Centre | Start (centre + 2 m), facing -Z | Where | +| --- | --- | --- | --- | --- | +| London | 19 | (-542.529, 4.934, -676.292) | (-542.529, 6.934, -676.292) | on the road along the north side of the Marble Arch island, facing across it towards the Underground station entrance; Marble Arch stands 47 m behind and to the left of the car, a red telephone box ahead to the right | +| San Francisco | 121 | (40.306, -0.082, 153.295) | (40.306, 1.918, 153.295) | on the Embarcadero at a light-controlled junction, facing the Ferry Building's clock tower about 160 m ahead, the palm-lined median on the left, the bay on the right, a parking garage 36 m to the left | + +`tests/game/test_parity_cruise_spawn.cpp` pins both (retail data). + +## How MM2 picks the start + +Traced in the asm where the decompile is split or loses values +(`mmGame::Init`, `aiMap::Reset` and its pieces, `mmGame::RespawnXYZ`), with +every call reachable from `aiMap::Reset` followed through the asm call graph +(virtual calls resolved from the vtables and the objects' types) to find +each `irand` / `frand` on the global stream. + +1. `mmGame::mmGame` sets the start position (+0x70) to (0, 10, 0); + `mmGameSingle::mmGameSingle` the start angle (+0x76e4) to 0. +2. `mmGame::Init`: ... `mmPlayer::Init`, `mmIcons::Init`, then the mode's + `InitGameObjects` (vtable +0x44). `mmSingleRoam::InitGameObjects` calls + `vehCarSim::SetResetPos((0, 10, 0))`, so the reset position (vehCarSim + +0x210) is (0, 10, 0) + CenterOfGravity, sets +0x250 to 0 and calls + `vehCar::Reset`. +3. `InitGizmos` and the rest, then `aiMap::Init` (its draws come before the + reset and do not matter) and `aiMap::Reset`: + - `ResetRandomSeed` (the seed becomes 1); + - `asNode::Reset` (aiVehicleManager), `aiPoliceForce::Reset`, + `aiIntersection::Reset`, `aiPath::Reset`, `aiVehiclePlayer::Reset`, + `mcHookman::Reset`, `aiRouteRacer::Reset`, `aiCTFRacer::Reset`, + `aiPedestrian::Reset()` with `AddPedestrian`, the ambient pool + (`AddAmbient`, `aiRailSet::Reset`): no draws; + - for the player: `cityLevel::FindRoomId(reset position, 0)`, then + `AdjustAmbients(0, room)` (one `ChooseNextLaneLink` per car placed, + plus one `frand` per car on an [Exceptions] road) and + `AdjustPedestrians(0, room)` (four `frand`s per pedestrian, + `aiPedestrian::Reset(path, side)`); + - `aiPoliceOfficer::Reset`, the cable cars, the subways: no draws. +4. The opponents' registration (`aiMap::Opponent`, `AngelReadString`, + `mmHudMap` / `mmIcons::RegisterOpponents`) and two + `dgBangerDataManager::AddBangerDataEntry`: no draws. +5. The mode's `InitOtherPlayers` (vtable +0x40): + `mmSingleRoam::InitOtherPlayers` calls `RespawnXYZ(start, angle, true, + true, false)`, `SetResetPos(start)`, +0x250 = angle, `vehCar::Reset`. It + does not call `mmGame::InitOtherPlayers`, so the car is not dropped onto + the road: it falls the 2 m. +6. `mmGame::RespawnXYZ`: with no AI map (+0x276 clear) (0, 20, 0), angle 0. + Otherwise, when in a network session (asNetwork +0x3c) and asked to + (fifth argument), `DisableGlobalSeed` and the seed set to the local + player's DirectPlay id (asNetwork +0x20). Then repeatedly: draw + (counter + 1) numbers `irand() % (intersections - 1) + 1` and keep the + last (the counter is a .bss global, 0 at start-up); a missing + intersection draws again; the intersection fails when its room + (`FindRoomId(centre, 0)`, lvlRoomInfo flags) has 0x24, or 0x0A with the + fourth argument, or one of its roads (aiPath +0xc) has 0x4 with the third + argument or 0x2 with the fourth; a failure draws again with no limit. The + start is the centre (aiIntersection +0x14) 2 m up, the angle 0. In the + seeded case `EnableGlobalSeed` and the counter becomes (counter + 1) % + 100. + +Since the merge with [random-streams](random-streams.md) the stream +RespawnXYZ draws from is OpenMM2's one model of MM2's global seed +(`RaceScreen::m_random`), which the race's set-up advances in mmGame::Init's +order before step 3. That does not move the start: step 3 begins with +`ResetRandomSeed` (`aiMap::Reset`'s first piece), and the number of draws +`AdjustAmbients` and `AdjustPedestrians` make depends on the room, the pool +sizes and the roads, not on the types or clothes the earlier draws chose. +After step 6, `mmGameManager::mmGameManager` ends with +`mmGameManager::Reset`, which reaches `mmGame::Reset` and so `aiMap::Reset` +again (mmGame +0x276 is set): the seed goes back to 1 and the traffic and +pedestrians are placed round the start (OpenMM2: `ai::World::reset`, then +the first step). + +The draws in step 3 depend on the city and the densities: London's room at +the origin is 65 (28 ambient roads, 13 pedestrian roads), San Francisco's +265 (3 ambient roads, none for pedestrians); room 0's lists are empty in +both. With the cruise menu's densities (`RaceMenuBase::SetStateRace`: +traffic 0.5, pedestrians 0.25, so 25 pedestrians) London draws 73 + 100 +numbers and San Francisco 21 before the start; any car's CG leaves the +reset position in the same room. The start therefore depends on the +traffic and pedestrian sliders (and the SHOW PEDESTRIANS option, which +creates no pedestrians), as in MM2: + +| Traffic | Pedestrians | London draws, start intersection | San Francisco draws, start intersection | +| --- | --- | --- | --- | +| 0 | 0 | 0, 42 | 0, 42 | +| 0 | 0.25 | 100, 256 | 0, 42 | +| 0 | 1 | 400, 129 | 0, 42 | +| 0.5 | 0 | 73, 190 | 21, 121 | +| 0.5 | 0.25 (menu) | 173, 19 | 21, 121 | +| 0.5 | 1 | 473, 42 | 21, 121 | +| 1 | 0 | 145, 81 | 42, 12 | +| 1 | 0.25 | 245, 306 | 42, 12 | +| 1 | 1 | 545, 47 | 42, 12 | + +(`--quickstart` uses RaceConfig's own defaults, pedestrians 0.5, and so +starts London elsewhere; the menus apply SetStateRace's.) + +Multiplayer cruise and Cops and Robbers: `mmGameMulti::InitOtherPlayers` +calls the mode's `Reset` (`mmMultiRoam::Reset` when its state is 0, +`mmMultiCR::Reset` unless its state is 4, 6 or 7, or 5 with +0xb254 +clear), which +calls `RespawnXYZ(true, true, true)`, and then `InitNetworkPlayers`, which +calls it again for the local player and keeps the result (its fall-back to +`StartXYZ` when the result is the origin cannot happen). Each call reseeds +from the player id and advances the counter, so the start is the second +pick, drawn (counter + 2) numbers at a time, and the counter, never reset, +also changes every later single-player start of the run. + +## Rows + +| OpenMM2 | MM2 | Verdict | Notes | +| --- | --- | --- | --- | +| `game::session::respawnXYZ` | `mmGame::RespawnXYZ` | fixed | was `randomIntersectionStart`: a glibc-constant LCG, one draw a pick, the `.bai` room field's flags, a 1000-pick cap and a scan fallback. Now (counter + 1) MSVC `irand` draws a pick, the room by `FindRoomId` of the centre (San Francisco's intersection 187 lists room 1078, an open road, but its centre lies in room 265, a terrain instance's: MM2 never starts there), the same flag rules, centre + 2 m, angle 0 | +| `respawnXYZ`: no fitting intersection | `RespawnXYZ`'s endless retry | deviation | MM2 hangs; OpenMM2 first checks that one fits (no other cap, so every pick MM2 returns is the same) and returns nothing | +| `respawnXYZ`: fewer than two intersections, no AI map | `irand() % 0`; (0, 20, 0) | deviation | nothing is returned and `loadRaceSetup` keeps OpenMM2's first-Blitz-start fallback; every retail city has an AI map | +| `respawnCounter` | mmGame's respawn counter (.bss global) | fixed | new: run-long, advanced (mod 100) by every seeded pick, (counter + 1) draws a pick for everyone | +| `cruiseStart` (single player) | `mmSingleRoam::InitOtherPlayers` | fixed | `RespawnXYZ(true, true, false)` on the global stream as `aiMap::Reset` left it | +| `cruiseStart` (multiplayer) | `mmGameMulti::InitOtherPlayers`, `mmMultiRoam::Reset`, `mmMultiCR::Reset`, `InitNetworkPlayers` | fixed | two seeded picks, the second kept, the counter advanced twice (was one pick with the counter ignored) | +| `SessionOptions::seed` | the DirectPlay player id (asNetwork +0x20) | deviation | OpenMM2's transport has no DirectPlay ids: 1 + its network id | +| `mmMultiRoam::Reset`'s state test at the start | +0x270 == 0 | inferred | the game object is fresh heap memory that no constructor clears before `mmGame::Init`; OpenMM2 takes it as 0 (both picks happen) | +| `loadRaceSetup`: the cruise place | `mmSingleRoam::InitGameObjects` ((0, 10, 0) from `mmGame::mmGame`, angle 0), `mmMultiRoam` / `mmMultiCR::InitGameObjects` (the origin) | fixed | `RaceSetup::respawnStart`; the car stands there while the AI resets | +| `Session::placeRespawnStart` | the InitOtherPlayers placement | fixed | new: the pick becomes the player's place, spawn and water respawn | +| `RaceScreen::placeRespawnStart` | `mmGame::Init`'s order | fixed | after `ai::World::reset` (and the racers and police), as InitOtherPlayers follows `aiMap::Reset`; `SimVehicle::setResetPos` + `reset`; the camera follows; `OPENMM2_DEBUG_SPAWN` still wins | +| `ai::World::resetAndPopulate` | `aiMap::Reset`'s draws (`ResetRandomSeed`, `AdjustAmbients`, `AdjustPedestrians`) | fixed | new: resets the world (its one stream, `RaceScreen::m_random`, to 1) and populates the traffic, then the pedestrians, for the room of the car's reset position at once; the stream is then where RespawnXYZ draws. First a replay on a separate stream (`globalSeedAfterReset`); since the merge with [random-streams](random-streams.md) it is the shared stream itself | +| `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) | +| 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)`) | +| `CarSim::reset` | `vehCar::Reset`, `vehCarSim::Reset` | verified | phInertialCS at the reset position, `Matrix34::Rotate` about Y by +0x250, `SetWorldMatrix` (model at body + R * CG); test `TheCarStandsOnTheStart` | +| `StartDrop::None` | `mmSingleRoam::InitOtherPlayers` without `mmGame::InitOtherPlayers` | verified | no probe, no 0.9 m settle; the car drops the 2 m | +| `log::info` "race: start at intersection" | none | openmm2 | names the pick in the log | + +## Missing + +| MM2 | What it does | Status | +| --- | --- | --- | +| `mmGameMulti::SystemMessageCB` (a player joining a running game) | places the newcomer's car with `RespawnXYZ(false, false, false)` on the global stream until its updates arrive | not needed: OpenMM2 has no joining in progress; remote cars appear at their snapshots | +| One random stream for every subsystem | `aiMap::Reset` fills the origin room's roads, `RespawnXYZ` draws; then `mmGameManager::mmGameManager` ends with `mmGameManager::Reset` -> `asNode::Reset` -> `mmSingleRoam::Reset` -> `mmGameSingle::Reset` -> `mmGame::Reset`, whose `aiMap::Reset` (mmGame +0x276 is set by Init) sets the seed to 1 again and fills the start's room | ported (round 3, [random-streams](random-streams.md)): the traffic and pedestrians share `RaceScreen::m_random`; `placeRespawnStart` resets the world after the pick (the second aiMap::Reset) and the first step populates the start's room from seed 1, as MM2's second reset does | + +## Screenshots checked + +Taken with `OPENMM2_FRONTEND_SCRIPT="profile:Frames;mode:cruise;city:;vehicle:vpbug;go"` +and `--frames 600 --screenshot` (not committed): + +- before (the previous build): London started on a street with a colonnade + and an Underground sign, San Francisco at a crossing by an office block + (a different random intersection every run); +- after: London at intersection 19, the Underground station entrance across + the road ahead, the Marble Arch island behind (a fly-over from + `OPENMM2_DEBUG_FLY` shows the arch at the island's corner and the car on + the road north of it); San Francisco at intersection 121 on the + Embarcadero, the Ferry Building's tower straight ahead, the bay to the + right. The same start every run. diff --git a/docs/parity/round3/frames.md b/docs/parity/round3/frames.md new file mode 100644 index 0000000..211cf99 --- /dev/null +++ b/docs/parity/round3/frames.md @@ -0,0 +1,165 @@ +# Parity round 3: frames + +Audited against MM2Recomp (midtown2.exe build 3393) on 2026-10-08. + +Theme: every model, part, glow, shadow, decal, particle emitter and light +OpenMM2 draws, and every object it positions, uses the frame MM2 does: +which matrix (an instance's `GetMatrix` / `GetPosition`, the CG frame or the +model origin, a part's pivot from its `.mtx`, an xref's matrix), which mesh +offset (CG-centred banger bodies, parts modelled from a pivot or from the +base, the PKG `offset` chunk), and which order of multiplication and signs +(row vectors, -Z forward, `Matrix34::Dot`, `MakeRotateY`, `Vector3::RotateY`, +`Rotate` against `RotateFull`). + +The maintainer's report ("the lights on the street lights do not line up +with where they are supposed to be") was the traffic lights' bodies standing +at the pole's base instead of its CG, fixed before this round (b9f0f7d). The +street lamps (banger glows) were already right; see the screenshots below. + +Summary: 68 cases; verified 56, fixed 6, deviation 2, inferred 1, open 0, +not applicable (no retail data) 3. Fixes: cd820fc, ba78c68, 5eb4cd1, +657657e, abf27b8 (and b9f0f7d, verified). + +Conventions checked once for all: `Mat34` rows are MM2's `Matrix34` rows +(m0 right, m1 up, m2 back, m3 position; row vectors), `Mat34::mul(a, b)` is +`Matrix34::Dot(a, b)` (a then b), `Mat34::rotationX/Y/Z` are +`Matrix34::MakeRotateX/Y/Z` (MakeRotateY: m0 = (c, 0, -s), m2 = (s, 0, c)), +`Mat34::rotationY(a).transformDir(v)` is `Vector3::RotateY(v, a)` (x' = c x + +s z, z' = c z - s x), `Matrix34::Rotate` turns the 3x3 part only and +`RotateFull` the position too, `Matrix44::Dot(r, a, b)` is r = b a and the +one-argument `Dot(this, a)` is this = this a, and MM2's view matrix is the +camera's `FastInverse` times diag(1, 1, -1) (`gfxRenderState::SetCamera`, +`sm_FullComposite`). The PKG `offset` chunk is read by neither game: the +only "offset" string in midtown2.exe is `crBoneData::Load`'s token. + +## Props (bangers) + +| OpenMM2 | MM2 | Verdict | Notes | +| --- | --- | --- | --- | +| `BangerSet::addOne` / `add` (frame) | `dgUnhitBangerInstance::Init`, `dgUnhitYBangerInstance::SetMatrix` / `GetMatrix`, `dgUnhitMtxBangerInstance::GetMatrix` | verified | the placement plus R * CG with the full placement matrix, then the Y form's rows (c, 0, s), (0, 1, 0), (-s, 0, c) or the whole matrix; `GetPosition` is that frame's m3. Banger bodies are centred on their CG (sp_lightstreet_f y -3.851..3.851 about CG y 3.862; sp_traflitsingle_f -3.892..3.892 about 3.892) | +| `BangerSet::draw` body | `dgBangerInstance::Draw` | verified | the LOD mesh at GetMatrix | +| `BangerSet::draw` trees | `dgBangerInstance::DrawTree`, `dgTreeRenderer::RenderTrees` | verified | the high LOD at GetMatrix | +| `BangerSet::draw` lamp glows | `dgBangerInstance::DrawGlow`, `dgBangerData::Load` | verified | each GlowOffset through GetMatrix (the CG frame), a 1.5 m card with a 1% flicker, while the prop stands (flag 1). The offsets are in the CG frame in the data: sp_lightstreet_f's (-3.25, 3.65, -0.03) is 7.51 m up at the lamp head 3.25 m along -X (night screenshot). NumGlows missing means one glow (kept) | +| no banger shadow | `dgBangerInstance::DrawShadow` (empty), `DrawShadowMap` (no caller draws it) | verified | no retail banger PKG has a SHADOW part either | +| `BangerSet::unhitImpact` parts | `dgUnhitBangerInstance::Impact`, `InitBreakables` | verified | each BREAKnn hit instance at the prop's ground point (GetMatrix less R * CG) plus R * the part's CG; the BREAKnn meshes are centred on their own CGs (sp_lightstreet_f BREAK02, the arm, -1.727..1.727) | +| hit instances follow their actives | `dgBangerActive::Update` (SetMatrix of the ICS matrix) | verified | the ICS frame is the CG frame | +| debris | `dgBangerActive::Attach`, `asParticles` | verified | born through the active's ICS matrix; stationary rules at the CG | +| `ejectPart` frame | `vehBreakableMgr::Eject` | verified | the breakable's identity-at-pivot matrix dotted with the car's (no CG of the part's data) | +| `placeXrefs` | `lvlLevel::LoadInstances` (xrefs), `dgUnhitBangerInstance::Init` | verified | Dot(xref, record), then + R * CG | +| glass props | `dgGlassInstance::Draw`, `ptxGlass` | not applicable | no retail banger sets BillFlags 0x100 | +| parked cars | `gizParkedCarMgr::Init` | verified | full-matrix props turned a quarter turn (world-objects) | + +## Traffic lights + +| OpenMM2 | MM2 | Verdict | Notes | +| --- | --- | --- | --- | +| `Signal::frame`, `AiRenderer::drawSignal` body | `aiTrafficLightInstance::Init`, `Draw` | verified | b9f0f7d: the pole's base + R * CG, X along the unit XZ direction to trafficLightAxis, Z = (-x.z, 0, x.x); the first shader set | +| `signalGlowFrame`, glows | `aiTrafficLightInstance::DrawGlow` | verified | b9f0f7d, now from the prop's matrix: GetMatrix less R * CG (summed y, z, then x of the CG); geometry slot id + 2 + NumParts (+5 at night) | +| no traffic light shadow | `dgBangerInstance::DrawShadow` | verified | Init adds a SHADOW set but nothing draws it (and no retail light has the part) | +| no banger glow cards | `dgBangerInstance::DrawGlow` (the end of DrawGlow) | verified | NumGlows 0 in the light data | +| `addTrafficLightProps`, `PlacedProp::ownerDrawn`, `AiRenderer::setSignalFrames` | `aiTrafficLightInstance` (a `dgUnhitYBangerInstance`), `aiTrafficLightSet::SetFourWay`, `dgUnhitBangerInstance::Impact` | fixed | abf27b8. The pole is an unhit Y banger of its model's data (flags 0x13, the data's bound: a hotdog of YRadius x Size.y at the CG) in the room of its CG; cars collide with it and break it into BREAK01/02, after which neither body nor glows are drawn (it leaves the rooms, flag 1 cleared) and the parts are ordinary hit bangers. OpenMM2 only drew the poles: cars drove through them. Placed where aiMap::Init places them (before the cable cars), so the pedestrians' obstacle list includes them, as aiPath::AddBangersToObsMap (after SetFourWay) does | + +## World objects + +| OpenMM2 | MM2 | Verdict | Notes | +| --- | --- | --- | --- | +| `Gizmos::repositionBridge`, draw | `gizBridge::Reposition`, `gizBridge::Cull` (`dgBangerInstance::Draw`) | verified | identity, z = -Size.z / 2, RotateFull about X, Dot(hinge frame), plus the offset; no CG term | +| train cars | `gizTrainCar::Update` | verified | spline frame, position y + CG.y only | +| ferries | `gizFerry::Update` | verified | spline frame + CG.y, Y banger | +| sailboats | `gizInstance::SetMatrix`, `Draw` | verified | the matrix with m3.y + CG.y | +| cable cars | `aiCableCarInstance::Draw`, `GetMatrix` | verified | the car's own matrix; its BODY is modelled from the base (y 0..3.29), the SHADOW and HLIGHT parts are never drawn | +| gizmo glows | `gizBridge::Init` (flag 1 cleared), `lvlInstance::DrawGlow`, `dgBangerInstance::DrawGlow` | verified | none drawn | + +## Checkpoints, powerups, Cops and Robbers + +| OpenMM2 | MM2 | Verdict | Notes | +| --- | --- | --- | --- | +| `hud::standMatrix` | `mmWaypointObject::mmWaypointObject`, `Move`, `mmCheckpointInstance::Draw` | verified | Rotate(Y, -heading) at y + height / 2, drawn Dot(Scale(r, 7.5, r), GetMatrix). `SetHeading` leaves a re-headed zero-heading stand 3.75 m low (game-flow's open row): of the retail waypoint files only race/sf/circuit10 has such stands, and SF lists circuits 0-9 only | +| C&R bases | `mmBillInstance::Draw` | verified | the view's transposed 3x3 (the camera's rotation) at GetPosition, scaled | +| C&R gold | `mmPowerupInstance::Draw`, `mmWaypointObject::Move` | verified | Rotate(Y, 3 t) dotted with the waypoint's matrix 1.5 m up; its heading only shifts the spin's phase | +| carried gold icon, arrow, dash | `mmCRHUD`, `mmArrow`, `mmDashView` | verified | camera frame (hud-views) | +| opponent icons, map blips | `mmIcons::RegisterOpponents`, `mmHudMap` | verified | the opponents' ICS matrices (`+0x54` of the entity's ICS) | + +## Vehicles + +| OpenMM2 | MM2 | Verdict | Notes | +| --- | --- | --- | --- | +| `CarSim::modelMatrix`, body | `vehCarSim::SetWorldMatrix`, `vehCarModel::Draw`, `GetMatrix` | verified | the ICS matrix moved by R * CenterOfGravity; bodies are modelled from the ground origin (vpdune y 0.256..1.596) | +| wheels, hubs | `vehCarModel::Draw`, `vehWheel` | verified | the wheels' drawing matrices (vehicle area) | +| WHL4/5 | `vehCarModel::Draw` | verified | WHL2/3's matrices moved (0.2 + 2) radii along m2 | +| fenders | `vehCarModel::Draw`, `Init` | verified | m0 of wheel 0/1, m1 of the body, m2 = m0 x m1; fndr0 pivot less wheel 0's + 2.5 cm, x mirrored | +| breakables, variant part | `vehBreakableMgr::Draw` | verified | identity at the pivot dotted with the car's matrix | +| DECAL, reflection | `vehCarModel::Draw`, `modShader::BeginEnvMap` | verified | at the car's matrix; the env map is indexed by camera-space normals through the camera's rotation with z flipped back: world-space normals | +| SHOCK, ARM, SHAFT, AXLE, ENGINE | `vehCarModel::Draw` (suspension and engine matrices) | not applicable | no retail car has these meshes | +| shadow | `vehCarModel::DrawShadow`, `lvlInstance::DrawPhysics` | verified | DrawPhysics of the car's matrix (carsim + 0x1d4) | +| TLIGHT, BLIGHT, RLIGHT | `vehCarModel::DrawGlow` | verified | at the car's matrix | +| headlight cards (positions) | `vehCarModel::DrawHeadlights` | verified | pivot through the car's matrix | +| headlight sweep | `vehCarModel::DrawHeadlights`, `Vector3::RotateY`, `ltLight::Default` | fixed | 657657e: world-space directions (start (0, 0, -1)), the car's forward axis without the siren, turned by +-42.411503 rad/s of frame time from wherever they point with it; OpenMM2 turned the car's forward axis by the siren's accumulated angle (followed the car, jumped when the angle wrapped) | +| headlight sweep in the mirror | `vehCarModel::DrawHeadlights` | deviation | MM2 turns the lights again when the mirror's view draws the glows; OpenMM2 once a frame | +| siren lights, lens flares | `vehSiren::AddLight`, `Update`, `Draw`, `vehCarModel::InitSirenLight` | verified | positions through the car's matrix, directions in world space turning about Y | +| trailers | `vehTrailerInstance::Init`, `Draw`, `DrawShadow` | fixed | ba78c68: the body alone below H; at H TLIGHT (object pass: its black, fully transparent material under the alpha test, inferred invisible) while the tow car brakes over 0.1 and TWHL0-3 at the wheel matrices; no reflection, decal, breakables, fenders, hubs, TWHL4/5, texel damage or glows. OpenMM2 drew trailers as cars: TWHL0/1's medium and low meshes (modelled away from their pivots, e.g. vpsemi_trailer TWHL0_M z -3.78..-2.83) at the front wheels' matrices, a reflection, and TLIGHT as an added glow | +| trailer shadow | `vehTrailerInstance::DrawShadow` | verified | DrawPhysics of the trailer's matrix | +| rear-view mirror | `mmMirror::Cull` | verified | Dot(mirror matrix, the player's carsim + 0x1d4) | +| car cameras | `camCarCS::Init` | verified | carsim + 0x1d4 (model origin) | +| exhaust and damage smoke | `vehCarDamage::Update`, `SpewSmoke` | verified | SmokeOffset and the exhaust pivots go through the ICS matrix (the CG frame, not the model origin), as OpenMM2 does | +| shards, sparks | `vehCarDamage::ApplyImpact`, `fxShardManager::EmitShards`, `asLineSparks::RadialBlast` | verified | the ICS matrix; the impact point | +| tracks | `vehCar::UpdateTrack`, `lvlTrackManager::Update` | verified | the wheel's contact (props-fx) | +| opponents, police | `vehCarModel` | verified | the same renderer and pose | +| network cars | `mmNetObject` | deviation | OpenMM2's networking: snapshots of the model matrix, wheels turned from the received speed and steering (MM2 simulates the remote car) | + +## Traffic cars + +| OpenMM2 | MM2 | Verdict | Notes | +| --- | --- | --- | --- | +| body | `aiVehicleInstance::Draw`, `GetMatrix` | verified | the spline's matrix (model origin); with a body the ICS matrix copied after the integration (`aiVehicleActive::Update`; the ICS frame is the model origin: `Attach` sets it to GetMatrix) | +| rooms, visibility | `aiVehicleInstance::GetPosition` | verified | m3 + m1 | +| rail wheels | `aiVehicleInstance::Draw` | verified | Dot(RotateX(-tyre rotation) at the data's pivot, GetMatrix), WHL4/5 likewise, only at H | +| wheels with a body | `aiVehicleInstance::Draw`, `vehWheelCheap::Init`, `Update` | fixed | cd820fc: WHL0-3 unturned at the vehWheelCheaps' drawing matrices (pivot + compression along m1, Limit below off the ground, less 0.2 x lateral and 0.3 x longitudinal deflection), WHL4/5 unturned at their pivots raised by WHL2/3's drawn height less the wheel radius | +| shadow | `aiVehicleInstance::DrawShadow`, `lvlInstance::DrawPhysics` | fixed | cd820fc: with a body on the ground (DrawPhysics of the active's matrix); otherwise at GetMatrix while upright and on the ground when upside down | +| shadow gate | `aiVehicleInstance::DrawShadow` (spline + 0xe8) | inferred | MM2 skips the shadow while a spline short is 0 (mm2hook: "solved stop orientation"); OpenMM2 always draws it | +| BREAK0-3 | `aiVehicleInstance::Draw`, `vehBreakableMgr::Draw` | verified | at their pivots through GetMatrix, always H | +| TLIGHT, SLIGHT0/1, headlight cards | `aiVehicleInstance::DrawGlow` | verified | at GetMatrix; the card at the headlight0 pivot (and its mirror) pulled 0.2 m towards the camera | + +## Pedestrians + +| OpenMM2 | MM2 | Verdict | Notes | +| --- | --- | --- | --- | +| skinned model | `aiPedestrianInstance::Draw`, `pedAnimationInstance::Draw`, `crSkeleton::Attach` | verified | bones through the pedestrian's matrix (aiPedestrian + 0x3c); 35 m from the camera's position | +| stick figures | `pedAnimation::DrawSkeleton`, `gfxRenderState::Regenerate`, `SetCamera` | fixed | 5eb4cd1: the quads are offset in the pedestrian's space by the modelview's first row (sm_Modelview [0][0..2]: the pedestrian's X axis in view space, z negated by sm_FullComposite); the camera's right axis while both only turn about Y, tilted under a pitched camera. OpenMM2 used the camera's right axis in world space | +| no shadow | `aiPedestrianInstance::DrawShadow`, `pedAnimationInstance::DrawShadow` | verified | empty | + +## City and the view + +| OpenMM2 | MM2 | Verdict | Notes | +| --- | --- | --- | --- | +| static objects | `lvlFixedAny::Draw`, `lvlFixedMatrix` / `lvlFixedRotY::GetMatrix` | verified | the record's matrix (compact Y form rebuilt); no retail city model has the mask, refl, nonrandom, opaque or shadow parts | +| city object shadows | `lvlFixedAny::DrawShadow` | not applicable | no retail city model has a SHADOW part | +| multi-room objects, landmarks | `lvlMultiRoomInstance::Draw`, `lvlLandmark` | verified | the object's matrix | +| sky | `lvlSky::DrawHat` | verified | Rotate(Y, angle) at (eye.x, eye.y x scale + offset, eye.z) | +| road decals | `dgRoadDecalInstance::DrawShadow` | verified | world-space strip (props-fx) | +| showroom | `mmVehicleForm::Cull` | verified | wheels and extra parts at their pivots through the turning node | +| lens flares, rain | `ltLensFlare::Draw`, weather particles | verified | at the lights; camera-relative | + +## Screenshots checked + +`OPENMM2_DEBUG_FLY` / `OPENMM2_DEBUG_CAMERA`, 1280x720: + +- SF at night, camera (-1478, 42, 290, yaw -0.61): the traffic lights' poles + under their glows, the street lamps' glows at the lamp heads (before and + after the traffic light props: identical, the props draw at the same frame). +- SF at night, camera (-1464, 41, 272, yaw -0.85): a street lamp's glow at + its head, traffic lights from behind (one-sided glows). +- SF Blitz 0 in vpsemi at night, from the side and behind the trailer, the + starting commit against round 3: the trailer's tail-light glows are gone + (vehTrailerInstance draws none). +- London Blitz 0 from above: traffic lights at the corners, pedestrians. + +## Noticed outside the theme + +- Traffic cars never eject their BREAK0-3 parts: `TrafficImpact::breaks` + (aiVehicleActive::Impact's vehBreakableMgr::Impact, threshold 2500) is + raised but nothing ejects (ai-vehicles). +- The aiIntersection obstacle maps are built before SetFourWay places the + traffic lights (so they leave the lights out) while the aiPath maps + include them; OpenMM2's pedestrians have one obstacle list of every prop + (ai-ambient-city, inferred). diff --git a/docs/parity/round3/order.md b/docs/parity/round3/order.md new file mode 100644 index 0000000..da7442c --- /dev/null +++ b/docs/parity/round3/order.md @@ -0,0 +1,234 @@ +# Round 3: order + +Audited against MM2Recomp (midtown2.exe build 3393) on 2026-10-08. + +The order in which MM2 creates, resets, updates and draws its systems, +compared with OpenMM2's race (`src/app/RaceScreen.cpp` and the session +code). MM2's side was read from the asm (`GameLoop`, `MainPhase`, +`BeginPhase`, `asRoot`, `asNode`, `mmReplayManager`, `mmGameManager`, +`mmGame` and the modes, `asCullManager`, `cityLevel`), with the split +pieces of `mmGame::Init` and `aiMap::Reset` read in place. + +Summary: 51 cases checked; verified 35, fixed 10 (six commits), deviation 5 +(one frame each, invisible), open 1 (F2's pause, already open in the +game-flow record). + +Fixed (one commit each): + +- 9d2c313 the menu's Restart and F4 restart the race at the start of the + next frame (mmReplayManager's reset flag); F4 ends a pause at once. +- d6310f6 the paused frame keeps the camera, the sky, the rain and snow, + the HUD message timer and the fall and water checks running. +- c124f3c the raw C and V keys of `mmGame::UpdatePaused` are gone: nothing + in build 3393 calls it. +- 6b444ac with the menu up and the game running, the car is driven by every + controller but the mouse, whose car keeps its last inputs. +- 7bcdb41 `AudManager::Update` runs twice a frame (GameLoop and asRoot). +- 3798bb8 the horn keeps the state `mmGame::UpdateHorn` last set while the + menu is up. + +Tests: `tests/game/test_parity_order.cpp`, +`tests/audio/test_parity_order.cpp`. + +## MM2's tree + +`BeginPhase` adds the audio manager (`AudManager`) to `asRoot`, and +`mmInput::AttachToPipe` inserts `mmInput`; the race phase of `MainPhase` +then builds `mmReplayManager`, `mmGameManager` (which builds and initialises +the mode, then resets it) as the replay manager's child, and adds the +replay manager to `asRoot`. `GameLoop(false)` runs the frames. + +`asNode::Update` updates every active child (node flag 1) whether or not +`asRoot` is paused; `asNode::UpdatePaused` is only reached from its own +recursion and from `AudManager::Update`, so the game's `UpdatePaused` +slot (`mmGame::UpdatePaused`, `mmHUD::UpdatePaused`, `aiMap::UpdatePaused`) +is never called. The pause is a flag (`asRoot` +0x48) that the nodes test +themselves. + +`mmGame`'s children, in update order: the gizmo managers +(`mmGame::InitGizmos`: sailboats, bridges, trains, ferries, parked cars), +then those the mode's `Init` adds: `mmPlayer`, `mmIcons`, the mode's +`mmWaypoints` (races, circuits, Blitz, lessons), `mmHudMap`, `mmHUD`, +`mmPopup` (the network modes add their net objects too). `mmPlayer`'s +camera view (`camViewCS`, +0xe2c) is a child with its active flag cleared: +`mmGameManager::Update` updates it itself, after the physics. + +## Load + +| Step | MM2 | OpenMM2 | Verdict | +| --- | --- | --- | --- | +| Progress 10 %, audio statics, positions | `mmGame::Init` start | `RaceScreen` ShowLoading state | verified | +| Strings, the driver's settings (controls, audio, view, graphics) | `InitGameStrings`, `PlayerSetState` | `Session::create` strings, `loadProfile` / view settings in `loadVehicle`, options in `loadLevelPart` | verified (the view settings are applied before the cameras are reset, as `PlayerSetState` precedes `mmPlayer::Init`) | +| The mode's player object, the transmission choice | `InitMyPlayer`, `vehTransmission::Automatic` | `loadVehicle` (`trans.automatic`) | verified | +| The city, level graphics | `lvlLevel::Load`, `SetLevelGraphics` | `loadCityPart`, `loadLevelPart` | verified | +| Physics, props, the player's car, cameras, HUD, map, car audio, speech | `dgPhysManager`, `dgBangerManager::Init`, `mmPlayer::Init` | `loadVehicle` (world, car, renderer, cameras, mirror), `loadAudio` | verified | +| Icons | `mmIcons::Init` | `Hud` (loadFinish) | verified (no state of the race depends on it) | +| The start place | the modes' `InitGameObjects` (`SetResetPos`, `vehCar::Reset`) | `RaceSetup::playerPlace`, `setResetPos`, `reset` | verified | +| Gizmos | `InitGizmos`, before `aiMap::Init`: the bridges' triggers loop over the opponent count before the AI map has set it, so only the player triggers them | `loadEffects` (after the AI), the player as the only trigger | verified (world-objects record) | +| The AI map | `aiMap::Init`, `aiMap::Reset`, the racers registered with the map and icons | `loadAi`, `m_ai->reset()`, `spawnOpponents`, `spawnPolice` | verified (ai records) | +| The physics sample (1/35 s, 3 samples), banger data | `mmGame::Init` | phys-core | verified | +| Settling the player, the racers | `InitOtherPlayers` (probe, 0.9 m up), `CollideAIOpponents` | `settleOnGround` in `loadVehicle` (before the AI is loaded), `spawnOpponents` | verified (the probe sees only the level in both: the racers are not on the player's spot) | +| HUD, light, event queue, popup, far clip, results titles, view manager, weather | `InitHUD`, ..., `mmViewMgr::Init`, `InitWeather` | `loadFinish` (HUD, view settings), `loadEffects` (weather) | verified | +| The mode's first Reset | `mmGameManager::mmGameManager` -> `mmGameManager::Reset` -> the mode's Reset (`mmGame::Reset`: props, pre-race speech, `aiMap::Reset` again, `mmPlayer::Reset`, music, elasticity cap; then the state, `SetPreRaceCam`) | `loadFinish`: elasticity cap, `Session::start` (pre-race speech), damage switch, pre-race camera, music | verified; `aiMap::Reset` runs twice at load in MM2 and once in OpenMM2, which only matters to the shared random stream (round-3 random streams) | +| The first frame's time step | `datTimeManager::Update`: the time since the last frame before the load, clamped to 0.1 s | the load's last frame, clamped to 0.1 s | verified | + +## Restart + +MM2: Restart Race (`mmPopup::Update`, PUMain id 10, and F4 over it) sets +`mmReplayManager` +0x19 and calls `DisablePU(0)`, whose unpause is a +request too (+0x1d); F4 in the game (`mmGame::UpdateDebugInput`) calls +`asRoot::Reset` (the pause ends at once) and sets +0x19. The next frame, +`mmReplayManager::Update` first resets (`mmReplayManager::Reset`: the +global seed back to 1, then `asNode::Reset` down to `mmGameManager::Reset` +and the mode's Reset), then grants the unpause, then records the inputs and +runs the game: the first rules, AI and physics after a restart all start +from the reset state. + +The mode's Reset, e.g. `mmSingleRace::Reset`: `DisableRacers`; +`mmGameSingle::Reset` (the audio manager's and `mmInput`'s Reset, the +racers' slots, the results); `mmGame::Reset` (`lvlLevel::ResetInstances`, +`Process3D(1)`, the water timer and race-over flag, the pre-race speech, +`aiMap::Reset`, then the children's Reset: gizmos, `mmPlayer::Reset`, +icons, waypoints, map, HUD, popup; PUMain's replay button, `StartMusic`, +the elasticity cap); then `SwitchState(0)`, `SetPreRaceCam`, `StopTimers`, +`SilenceEngine(0)`. The water and fall handlers call the mode's Reset +directly from `mmGame::Update`, before the AI and the physics of that +frame. + +| Case | MM2 | OpenMM2 | Verdict | +| --- | --- | --- | --- | +| When the menu's Restart and F4 act | at the start of the next frame, before anything updates | the session at once, the cars from the Restart event after that frame's physics, so the session's update in between saw the old car | **fixed** (9d2c313): `requestRestart`, `applyRestart` at the top of the next frame, after `mmInput::Update` as in asRoot's order; the frame of the request takes the paused path. Before: restarting with the car at Blitz's first checkpoint showed "Check: 1/4" in the new countdown (old binary, 3 of 4 runs); after: 0/4 in every run | +| F4 during the full-screen map's pause | `asRoot::Reset` ends the pause at once (the map stays up) | the session restarted, the game stayed paused and the cars were not reset until it ran on | **fixed** (9d2c313) | +| When the water and fall handlers restart | in `mmGame::Update`, before that frame's AI and physics | in the session's update after the physics; the Restart event resets everything before the next frame | verified (the next AI and physics both start from the reset) | +| Order inside the reset | props, speech, `aiMap::Reset` (seed 1, police, roads, cable cars, racers, pedestrians, ambient traffic around the player's room, officers), then `mmPlayer::Reset` | props, gizmos, cable cars, cap, player, racers, traffic bodies, ambient AI, police, music, cameras, input, force feedback | verified: no object reads another while resetting; MM2 populates the traffic around the room the car was in before it went back to the start, and its first `aiMap::Update` moves the population to the start's room with the same `AdjustAmbients`, which OpenMM2 does from room 0 at its first step (the draws differ only by stream) | +| `mmInput`'s Reset and the frame's inputs | `mmInput::Update` (asRoot's node) before the reset, the recording after it | `m_gameInput.update`, `applyRestart` (`m_gameInput.reset()`), then `updatePlayer` | verified | + +## The frame + +MM2, one frame of the race (asRoot not paused, no menu): + +1. `GameLoop`: `datTimeManager::Update`, `ioInput::Poll`, + `gfxPipeline::Manage`, the event handler, `AudManager::Update`. +2. `asRoot::Update`: `AudManager::Update` again (asRoot's first node), + `mmInput::Update`, `mmReplayManager::Update` (reset and pause requests, + then the recording of steering, throttle, brakes and handbrake). +3. `mmGameManager::Update`: declares `aiMap` and itself to the cull + manager, then the tree: `mmGame::Update` = `UpdateDebugInput` (Escape, + F1, F2, F4, F6, chat), `UpdateGameInput`, the replay data, + `UpdateSteeringBrakes`; the mode's `UpdateGame`, `mmAmbientAudio`, + `Aud3DObjectManager::Update`; `mmSpeechContainer::Update`; + `UpdateDMusic`; the fall and water checks; `DeclareMover` (player, trailer); + `aiMap::Update`; then the children (gizmos, `mmPlayer::Update`, icons, + waypoints, map, HUD, popup). +4. `cityLevel::Update` (`lvlSky::Update`, the cloud offset), then + `dgBangerActiveManager::Update` and `dgPhysManager::Update` (the cars' + `PostUpdate`: sirens, car audio), `cityLevel::PreDraw` (particles, + texture movies), the camera (`camViewCS::Update`), the dash, map and + mirror declared, `asCullManager::Update` (the draw), + `cityLevel::PostDraw`. + +| Case | MM2 | OpenMM2 (`RaceScreen::update`) | Verdict | +| --- | --- | --- | --- | +| The audio manager | `AudManager::Update` twice (GameLoop, asRoot's node): the announcer's queue three times a frame with `mmGame::Update`'s, both pause stops in the first paused frame | once | **fixed** (7bcdb41): `AudioManager::updateFrame` | +| Input before the game | `mmInput::Update`, the recording, `UpdateDebugInput`, `UpdateGameInput`, `UpdateSteeringBrakes` | `m_gameInput.update`, popup / Escape / debug keys, `updateGameInput`, `updatePlayer` | verified | +| The steering filter's speed | set by `mmPlayer::Update` after the recording | `setSpeed` after `steering(dt)` | verified | +| Rules before or after the physics | `UpdateGame` before the AI and the physics, seeing the last frame's physics | `updateSession` after the physics and the camera | verified (session record's one-step offset): OpenMM2's rules at the end of frame N are MM2's at the start of frame N+1, with no input, AI or physics in between; holds, releases ("Go!"), the water handler and the cameras they set take effect in the same physics step and camera update in both. Only the HUD shows a rule's result one frame sooner | +| AI seeing the player | `aiMap::Update` after `UpdateSteeringBrakes`, before the physics: the last step's position, this frame's inputs | `updateAmbient` / `updateAiDrivers` after `updatePlayer`, before the step | verified | +| Ambient, racers, police, cable cars, lights, gizmos | `aiMap::Update` (ambient, pedestrians, racers, police, cable cars, light sets), then the gizmo managers (nodes of `mmGame`) | the same order | verified | +| The finish hold (`mmPlayer` +0x2258) | `mmPlayer::Update`, after `aiMap::Update`, before the physics | `updatePlayer` before the AI | verified (the AI reads positions, not the hold) | +| Active props | `dgBangerActiveManager::Update` (rest check, movers) before the step | `BangerSet::update` after the step, declaring for the next | verified (props-fx record; the same step sees the same movers) | +| The camera | `camViewCS::Update` after the physics | `updateCarCamera` after the step | verified | +| The 3D listener | `Aud3DObjectManager` keeps a pointer to the camera's matrix; its update in `mmGame::Update` and the cars' audio in `PostUpdate` run before the camera moves: last frame's camera | `updateAudio` after the camera | **deviation**: one frame (a sixtieth of a second) of listener lag, inaudible | +| The rain emitter | `asParticles::Update` in `cityLevel::PreDraw`, before the camera | `Weather::update` after the camera | **deviation**: one frame of emitter lag, invisible | +| HUD readouts | the gauges read the car when drawn; the clock digits in `mmHUD::Update`, before its timers update | `m_playerState` after the step | **deviation**: OpenMM2's clock is at most one frame ahead, invisible | +| The map's player arrow | `mmHudMap::Update` in the tree, before the physics | drawn from the state after the step | **deviation**: one frame, invisible | +| Pause and unpause requests (Escape, closing the menu, the full-screen map) | `mmReplayManager` +0x1c / +0x1d, granted at the start of the next frame: the frame of Escape still runs, the frame of closing still pauses | `m_paused` set at once | **deviation**: one frame each way, invisible | + +## The paused frame + +MM2 with asRoot paused (the menu in single player, the full-screen map, +F2): `AudManager::Update` twice (`AudManagerBase::UpdatePaused`), `mmInput`, +then `mmReplayManager::Update` runs the tree without recording. +`mmGame::Update` still runs its input (without the menu: all of +`UpdateDebugInput` and `UpdateGameInput`), `mmSpeechContainer::Update`, +the fall and water checks (the water timer counts `Seconds`) and its +children: `mmHUD::Update` counts the message and the chat down; the gizmo +managers, `mmTimer` and `mmPlayer`'s rain audio test the pause themselves. +`mmGameManager::Update` skips the props and physics but runs +`cityLevel::Update` (the sky turns), `cityLevel::PreDraw` (the rain and +snow fall, the texture movies run) and the camera. + +| Case | MM2 | OpenMM2 before | Verdict | +| --- | --- | --- | --- | +| The camera | updated every frame | frozen; a camera key in the full-screen map changed nothing until the game ran | **fixed** (d6310f6) | +| The sky, the rain and snow | turn and fall | frozen | **fixed** (d6310f6) | +| The HUD message | counts down through the pause | kept its time | **fixed** (d6310f6), `Session::updatePaused`; test `PausedFrameCountsTheMessageDownNotTheRace` | +| The fall and water checks | run (five seconds in the water run out under the menu) | stopped | **fixed** (d6310f6); test `PausedFrameRunsTheWaterAndFallChecks` | +| C and V while paused | `mmGame::UpdatePaused` is never called | taken as raw keys | **fixed** (c124f3c) | +| The rules, clocks, AI, physics, props, gizmos, ambient and 3D audio, music direction | stopped (`UpdateDMusic` tests the pause) | stopped | verified | +| The announcer's queue | `mmGame::Update`'s update only | `m_announcer.update` | verified | +| The texture movies, the chat | run | run | verified | + +Checked in the game: a rainy Blitz paused under the menu for 200 frames; +with the fix the camera settles behind the stopped car as camViewCS keeps +updating, where the old build froze it at the moment of the pause. + +## The menu up, the game running + +A network game, a lost race (`ProcessEscape(0)`) or the chat line. +`mmGame::Update` skips `UpdateDebugInput` and `UpdateGameInput`; while the +game runs and `inputDevice` is above 0 it still calls +`UpdateSteeringBrakes`; it handles F1 and updates the map itself. + +| Case | MM2 | OpenMM2 before | Verdict | +| --- | --- | --- | --- | +| The car's inputs | the recorded inputs for the keyboard, joystick, game pad and wheel; the mouse (`inputDevice` 0) leaves the car's last inputs | every pedal and the wheel let go | **fixed** (6b444ac) | +| The game keys | off | off | verified | +| The horn | `UpdateHorn` opens `UpdateGameInput`: the horn's state (+0x274) stays as it was | read every frame | **fixed** (3798bb8): `m_hornHeld`, set in `updateGameInput` | + +## Draw order + +`asCullManager::Update` clears, draws the 2D background cullables, then for +each camera the 3D cullables in the order they were declared, then the 2D +foreground ones. Declared: `aiMap` (its `Cull` is empty) and +`mmGameManager` (`mmGameManager::Cull`: the full-screen map in map mode 3, +else the letterbox bars; `mmHUD::PostUpdate`; `lvlLevel::Draw`) at the +start of the frame; during the tree the bridges and ferries, the icons +(`mmIcons::Update`), the far checkpoint stands (`mmWaypoints::Update`) and +the HUD's forms (the arrow); after the physics the dash, the small map and +the mirror; the HUD's 2D parts and the popups in the foreground. + +| Case | MM2 | OpenMM2 (`drawScene`, `drawOverlay`) | Verdict | +| --- | --- | --- | --- | +| Full-screen map, letterbox, level | `mmGameManager::Cull` first | `drawMap` (full), the band clear, `drawLevel` | verified (hud-views record) | +| Bridges and ferries | after the level | inside `drawLevel` | verified: opaque and depth-tested, the order changes nothing on screen | +| Glows, shadows, rain | inside `lvlLevel::Draw` | inside `drawLevel`, the rain last | verified (rendering-fx record) | +| Stands, icons, arrow, dash | near stands in the level, icons, far stands, arrow, then the dash | stands, icons, arrow, dash (`Hud::drawWorld`) | verified; the far stands' LOD pass is the open `mmWaypoints::Cull` (rendering) | +| Small map, mirror | after the dash | after the 3D view | verified | +| HUD 2D, popup, pointer | foreground | `drawOverlay`: HUD, popup, pointer last | verified | + +## End of a race + +MM2's modes (e.g. `mmSingleRace::UpdateGame`): the finish stops the timer, +sets the post-race camera, the finish message and standings, stops the +music, registers the finish and saves the driver; a later state locks the +menu and shows the results popup (`mmPopup::ShowResults`) or opens the +menu (`ProcessEscape(0)`) over the running race; leaving it ends the +`GameLoop` (`BeDone`). OpenMM2 does the same up to the results, which are a +frontend page (`leaveRace`): the session record's deviation. Verified +against the session record; nothing new. + +## Open + +| Item | What it needs | +| --- | --- | +| F2's pause | `mmGame::UpdateDebugInput` F2 pauses through `mmReplayManager` with the HUD off; OpenMM2's F2 is the fly camera (game-flow record) | + +## Notes for other areas + +- audio: `AudManager::Update` runs twice a frame (`docs/audio.md` and + `AudioManager.h` now say so; `docs/parity/mm2/audio.md` still says + "twice as fast"). The audio test `AnnouncerQueueCountsTwiceAFrameWhileRunning` + checks one `AudioManager::update` per frame, which is still right for + that function. +- game-flow: `mmGame::UpdatePaused` is unreachable (its row says ported). diff --git a/docs/parity/round3/random-streams.md b/docs/parity/round3/random-streams.md new file mode 100644 index 0000000..b346bdb --- /dev/null +++ b/docs/parity/round3/random-streams.md @@ -0,0 +1,231 @@ +# Round 3: random streams + +Audited against MM2Recomp (midtown2.exe build 3393) on 2026-10-08. + +Theme: where MM2's randomness decides something the player sees, OpenMM2 +should get the same outcome. MM2 draws almost everything from one global +generator and sets it back to 1 at fixed points, so much of what a race +shows at its start is the same every time in MM2; OpenMM2 had given each +subsystem its own stream, so none of it matched. + +Summary: 41 consumers checked; verified 7, fixed 16, deviation 12, open 0, +not needed 6. Commits: bf32be5 (the AI's one stream), abed91c (the +set-up order), e50754a and the merge after it (integration's cruise spawn, +frames and order audits on the one stream). Tests: +`tests/ai/test_parity_random_streams.cpp`, +`tests/game/test_parity_random_streams.cpp`. + +## MM2's generators + +- `irand()` / `frand()`: the MSVC rand() LCG on the global `gRandSeed` + (seed × 214013 + 2531011, bits 16..30; frand = irand × 2^-15). The seed + starts at 0 when the program starts. `ResetRandomSeed` sets it to 1: + before every road of `cityLevel::Load`'s street-prop loop, at the start of + `aiMap::Reset`, and in `mmReplayManager` (its constructor and Reset; + StartReplay and SetReplayInfo save and restore it). +- `DisableGlobalSeed` / `EnableGlobalSeed` swap in `secondarySeed` (also 0 at + start, never reset) around draws that must not disturb the shared + sequence: `asBirthRule::InitSpark`, `asLineSparks::RadialBlast`, + `fxShardManager::EmitShard` (with `fxShard::AddShard`), + `fxTexelDamage::ApplyDamage`, `vehCarDamage::Update`, the network form of + `mmGame::RespawnXYZ` and the Cops and Robbers point picker (which seeds + it from the clock). +- `irand(int)` / `frand(int)`: one LCG step of the argument, no state + (`aiVehicleInstance::aiVehicleInstance`, on its own address). +- `Random` (Knuth's subtractive generator): the audio, seeded from the + clock each time (audio record). + +OpenMM2: one class for the generator, `ai::Random` (`game::fx::Rand` is the +same class). `RaceScreen` keeps MM2's global stream (`m_random`) through a +race's set-up and hands it to `ai::World`, which draws the traffic and the +pedestrians from it and sets it to 1 at every `reset()`. + +## The order of draws + +### A race start (single player) + +`mmGameManager::mmGameManager` runs the game's `Init` and then +`mmGameManager::Reset`. In `mmGame::Init` (asm order; the decompile is cut +into pieces) the draws on the global stream are: + +1. `cityLevel::Load` (the level's vtable slot 2): for every road of + `lvlAiMap`, `ResetRandomSeed`, then, when the road's first room has a prop + rule, `cityPropulator::Propulate` with rules `nNNleft` / `nNNright`: + `lvlSDL::Propulate` walks both sidewalks once per road strip, two frand + per point (the fraction across the sidewalk, the step along), and the + placement callback draws irand for the variant when a prop stands. The + stream is left as the last road's walk leaves it: 1 in London (its last + road has no props), 2380015889 in San Francisco. The rest of the level + (instances, path sets, race props) draws nothing. +2. `mmPlayer::Init` -> `vehCar::Init`, 312 draws: `vehCarModel::Init` -> + `vehSiren::Init` builds the siren's lens flare for every car (the vehSiren + is created by every `vehCar::Init`, its flare pointer null), twenty + `ltFlare::Random` of six frand each; then `vehSplash::Init` fills its 4 x 4 + x 4 points with random directions (three frand each), which it overwrites + at once. A trailer (vehTrailer) draws nothing. +3. `InitGameObjects` (vtable slot 0x44): nothing. +4. `mmGame::InitGizmos`, managers in this order: sailboats + (`gizSailboatMgr::Init`: per boat `gizInstance::Init`'s irand for the + paint job, then frand for the speed), bridges and trains (nothing), + ferries in single player (`gizFerryMgr::ApplyTuning` after all of them: + one frand each), parked cars in single player and network races + (`gizParkedCarMgr_EnumeratePath` per point: irand % 3 for the model or + none, then irand for the paint job when a car stands). +5. `aiMap::Init`: `AddPlayer` (no ambients or pedestrians yet, no draws); + each racer's `aiRouteRacer::Init` -> `aiVehiclePhysics::Init` -> + `vehCar::Init` (312 each); hookmen and CTF racers (none in retail + single-player data); the ambient array of the state's vehicle count + (`aiVehicleAmbient` -> `aiVehicleSpline` constructor: its `aiRailSet` + frand, then its own frand), built before the density is looked at, so it + draws at density 0 too; per car (density above 0) the type (frand against + the race's, else the city's cumulative list), then `aiVehicleAmbient::Init`: + the `aiVehicleInstance` constructor's `SetColor`, `SetColor` again (the + paint kept) and the `aiGoalRandomDrive` constructor (four frand); per + pedestrian the type (frand x the name count) and `aiPedestrian::Init`'s + variant (frand); the cable cars (`aiCableCar` array constructors: one + `aiRailSet` frand each, then `aiCableCar::Init`: one frand each); the + subways (none: both cities' `[Subway]` is commented out); the police + force and each officer's `aiPoliceOfficer::Init` -> `vehCar::Init` (312 + each); `aiVehicleManager::Init` (nothing). +6. `aiMap::Reset` (R1): `ResetRandomSeed`; the resets of every list (no + draws); per player `AdjustAmbients` then `AdjustPedestrians` from room 0 + to the room of the player's reset position at that moment. +7. `InitOtherPlayers` (slot 0x40): in cruise `mmSingleRoam::InitOtherPlayers` + calls `mmGame::RespawnXYZ`, whose irand picks the start intersection from + the stream R1 left; the other single-player modes draw nothing here. +8. `mmGameManager::Reset` -> `mmGameSingle::Reset` -> `mmGame::Reset`: + `lvlLevel::ResetInstances` (nothing), `aiMap::Reset` (R2): seed 1, then + `AdjustAmbients` and `AdjustPedestrians` round the player's reset position + (the start); the game's children reset (nothing draws). + +Everything the race shows when it starts is decided by steps 1-5 (models, +paints, clothes, parked cars, boats, cable cars, siren flares) and step 8 +(where the traffic and pedestrians stand), and none of it depends on the +player. + +### A restart + +`mmGame::Reset` repeats step 8 only: seed 1, then the same population round +the start. The set-up's outcomes (steps 1-5) stay. + +### Where reproducibility ends + +From the first frame after R2, `aiMap::Update` draws for the traffic +(`ChooseNext*Link`, `aiGoalAvoidPlayer`), the pedestrians (`Update`, +`WaitCrossStreet`, `Anticipate`, `PickNextRdSeg`) and the police +(`DetectPerpetrator`), interleaved with the player's wheels +(`vehWheel::GetBumpDisplacement` via `vehDrivetrain::Update`), the glows' +flicker as they are drawn (`dgBangerInstance::DrawGlow`, also for the +traffic lights), ejected parts and force feedback. The number of frames and +what is in view depend on the machine and the player, so nothing after R2 +is reproducible in practice (MM2's own replays save and restore the seed for +this reason). + +## Consumers + +### The set-up (steps 1-5) + +| MM2 | Draws | OpenMM2 | Verdict | Notes | +| --- | --- | --- | --- | --- | +| `cityLevel::Load` propulator loop, `cityPropulator::Propulate`, `lvlSDL::Propulate`, its callback | seed 1 per road; 2 frand per point, irand per prop | `bangers::placeStreetProps` | verified | Per-road seed 1 and the draw order were already MM2's (camera-props record). | +| (the same) | the stream after the loop | `placeStreetProps` / `placeCityProps` `randomState` | fixed | New: the state the last road leaves (1 when it has no props). Roads without sidewalks are now walked too: MM2 walks them and draws, nothing stands (the X row is zero); still 10,184 street props. | +| `vehCar::Init` -> `vehSiren::Init` -> `ltLensFlare(20)` -> `ltFlare::Random` | 120 frand | `takeVehCarInitDraws`, `VehicleRenderer::setSirenFlares` | fixed | Every car draws them; OpenMM2 drew siren flares from a stream of its own (0x5A1E) and only for cars with sirens. Now the player's, each racer's and each police car's flares come from their place in the stream. | +| `vehCar::Init` -> `vehSplash::Init` | 192 frand | `takeVehCarInitDraws` | fixed | `Splash::init` still skips the overwritten directions (vehicle record); the draws are taken where the car is set up. | +| `gizInstance::Init`, `gizSailboatMgr::Init` | irand, frand per boat | `Gizmos::loadSailboats` | fixed | Draw order was MM2's; the stream started at 1 instead of after the street props and the player's car. | +| `gizFerryMgr::ApplyTuning` | frand per ferry | `Gizmos::loadFerries` | fixed | As above. | +| `gizParkedCarMgr_EnumeratePath` | irand per point, irand per car | `placeParkedCars` | fixed | As above. London now has 488 parked cars (492 from seed 1), San Francisco 102 (90). | +| `aiRouteRacer::Init` -> `vehCar::Init` | 312 per racer | `RaceScreen::loadAi` (the racers' share), `spawnOpponents` (their flares) | fixed | Not taken before, so the traffic started too early in the stream in races. | +| `mcHookman::Init`, `aiCTFRacer::Init` (irand) | — | — | not needed | No hookmen in retail races; the CTF racer count is the state's, always 0. | +| `aiRailSet::aiRailSet`, `aiVehicleSpline::aiVehicleSpline` (the ambient array) | 2 frand per car | `Traffic::init` | fixed | Order was MM2's; now from the shared stream, and at traffic density 0 too (MM2 builds the array before it reads the density; multiplayer cruise sets the density to 0). | +| `aiMap::Init` type pick, `aiVehicleInstance::SetColor` x2, `aiGoalRandomDrive::aiGoalRandomDrive` | 4 frand per car | `Traffic::init`, `pickType` | fixed | Order was MM2's (ai record); now from the shared stream after the gizmos and racers. MM2 skips a car's Init (three draws) when no type's cumulative value exceeds the draw; every retail list ends at 1.0, so it never happens (OpenMM2 keeps the last type). | +| `aiMap::Init` pedestrian type, `aiPedestrian::Init` | 2 frand per pedestrian | `Pedestrians::init` | fixed | Was a stream of its own (seed x 7919 + 1); now after the traffic's. | +| `aiCableCar::aiCableCar` (`aiRailSet`), `aiCableCar::Init` | 1 + 1 frand per car | `CableCars::create` | fixed | The constructors' draws were missing; now taken first, after the pedestrians'. | +| `aiSubway::Init` | frand | — | not needed | No subways in retail data (`[Subway]` commented out in both cities' .aimap); OpenMM2 has no aiSubway. | +| `aiPoliceOfficer::Init` -> `vehCar::Init` | 312 per officer | `RaceScreen::loadEffects` (state), `spawnPolice` (flares) | fixed | Their draws decide nothing but their own siren flares (R1 follows). | +| `aiTrafficLightSet::SetFourWay`, `aiTrafficLightInstance::Init` (the poles, now props: frames audit, `RaceScreen::addTrafficLightProps`) | none | `addTrafficLightProps` (`BangerSet::addOne`) | verified | Nothing in the light sets' set-up reaches irand or frand (asm call graph); only `aiTrafficLightInstance::DrawGlow` does, through `dgBangerInstance::DrawGlow`, as the glows are drawn (play time). Their place among aiMap::Init's steps does not move the stream. | +| `aiGoalRandomDrive::aiGoalRandomDrive`'s exceed counter | (not random) | `Traffic::init` | verified | A static that MM2 never resets, but it cycles with period 5 and the pool is 300 cars, so each race starts it at 0 as OpenMM2 does. | +| `aiVehicleInstance::aiVehicleInstance`'s `irand(int)` | stateless | `Traffic::init` (blink phase) | deviation | Already recorded: of the instance's address in MM2, of a stand-in address in OpenMM2. | + +### The resets (steps 6-8) + +| MM2 | Draws | OpenMM2 | Verdict | Notes | +| --- | --- | --- | --- | --- | +| `aiMap::Reset` `ResetRandomSeed` | seed 1 | `World::reset` | fixed | One seeding of the shared stream (Traffic and Pedestrians seeded their own streams separately, the pedestrians' to 7920). | +| `aiMap::AdjustAmbients` (+ `ChooseNext*Link` per car) at R1/R2 | from 1 | `Traffic::populate` | verified | Same draws from 1 (ai-vehicles record). OpenMM2 populates on the first step after a reset with the player's room then, which is the room of the reset position MM2 uses; now explicitly before any update. | +| `aiMap::AdjustPedestrians` -> `aiPedestrian::Reset(path, side)` | 4 frand per pedestrian placed | `Pedestrians::populate` | fixed | Now continues the stream the traffic's population left, before the traffic or pedestrians update. | +| `mmGame::RespawnXYZ` (single player) | irand per attempt | `RaceSetup` `respawnXYZ` / `cruiseStart`, `RaceScreen::placeRespawnStart` | fixed | The cruise-spawn work ([cruise-spawn](cruise-spawn.md)) ported the pick; merged here it draws from the shared stream itself: `ai::World::resetAndPopulate` (R1: seed 1, the traffic then the pedestrians placed at once round the car's InitGameObjects place, mmGame's (0, 10, 0)), the pick from `m_random` as R1 left it, then `ai::World::reset` (R2). Its first version replayed R1 on a stream of its own (`World::globalSeedAfterReset`, `Traffic` / `Pedestrians::replayResetPopulation`); that replay is gone. Starts: London 19, San Francisco 121 (see below). | +| `mmGameManager::mmGameManager` -> `mmGameManager::Reset` -> `mmGame::Reset` -> `aiMap::Reset` (R2) at the start | seed 1, population round the start | `placeRespawnStart`'s `ai::World::reset`, the first step | fixed | The constructor's last call is `mmGameManager::Reset` (asm), whose `asNode::Reset` reaches the mode's Reset and `mmGame::Reset`; mmGame +0x276, set by Init, makes it call `aiMap::Reset`. The cruise-spawn record had the first `aiMap::Update` moving the traffic to the start's room from the stream as RespawnXYZ left it; MM2 resets first, so the start's traffic and pedestrians come from seed 1, as OpenMM2's now do. | +| A restart: `mmReplayManager::Update` -> `mmReplayManager::Reset` (`ResetRandomSeed`), the tree's Reset -> `aiMap::Reset` | seed 1 twice, then the population | `RaceScreen::applyRestart` (the next frame's start, order audit) -> the Restart event -> `ai::World::reset` | verified | Once per restart; nothing draws on `m_random` before the next AI step populates round the start. | +| `mmReplayManager` (`ResetRandomSeed`, save/restore) | — | — | not needed | OpenMM2 has no replays. | +| Network players' `mmNetObject::Init` -> `vehCar::Init` | 312 each | — | not needed | `mmGameMulti::InitOtherPlayers` runs after R1 and R2 reseeds: no effect on anything shown; their flares come from OpenMM2's own flare stream. | + +### During play (not reproducible) + +| MM2 | OpenMM2 | Verdict | Notes | +| --- | --- | --- | --- | +| `aiMap::ChooseNextRandomLink`, `ChooseNextLeftStraightLink`, `ChooseNextRightStraightLink`, `ChooseNextRightStraightFreewayLink`, `AdjustAmbients` on a room change, `aiGoalAvoidPlayer::AvoidPlayer`, `aiGoalRandomDrive::Reset`, `aiGoalRegainRail::Reset` | `ai/AmbientRoute`, `Traffic` on the AI world's stream | verified | Shared with the pedestrians as in MM2; MM2 interleaves the draws below too, so play-time sequences cannot match. | +| `aiPedestrian::Update`, `WaitCrossStreet`, `Anticipate`, `PickNextRdSeg`, `Wander`, `SolveRoadSegment` | `Pedestrians` on the AI world's stream | verified | As above. | +| `aiPoliceOfficer::DetectPerpetrator`, `ApprehendPerpetrator` | `PoliceSquad` (a stream per officer) | deviation | Play-time; kept apart so a chase does not depend on what the traffic drew. | +| `dgBangerInstance::DrawGlow` (props, traffic lights) | `BangerSet` glow stream | deviation | Drawn per frame for what is in view. | +| `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). | +| `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). | + +### The secondary seed + +| MM2 | OpenMM2 | Verdict | Notes | +| --- | --- | --- | --- | +| `asBirthRule::InitSpark` | `fx::Particles` (stream per emitter, 0x2A) | deviation | MM2's secondary seed is one stream for all of these, never reset in a session, so its draws depend on every effect since the program started. | +| `asLineSparks::RadialBlast` | `fx::LineSparks` | deviation | As above. | +| `fxShardManager::EmitShard`, `fxShard::AddShard` | `fx::Shards` | deviation | As above. | +| `fxTexelDamage::ApplyDamage` | `TexelDamage` | deviation | As above. | +| `vehCarDamage::Update` | (the damage smoke's particles) | deviation | As above. | + +## What changed on screen + +London, the same fixed spawn and camera (`OPENMM2_DEBUG_SPAWN`, +`OPENMM2_DEBUG_CAMERA`): the traffic stands in the same places (its +population from seed 1 was already MM2's) but with other models and paints +(the pool's draws now start where MM2's do), the pedestrians stand elsewhere +in other clothes, and the parked cars along the streets are other models in +other paints, some places empty that were taken and the other way round. +The values are pinned by `ParityRandomStreamsRetail.LondonCruiseSetUp` +(the first cars placed round the busiest intersection: 198 va_eurocargo_l, +199 va_eurocargo_l, 200 va_2sitersport_l, with their paints; the first +pedestrians' types and clothing variants; a restart placing them all +again identically). + +## The cruise start on the shared stream + +The draws before R1 (the street props, the player's 312, the gizmos, +aiMap::Init's racers, pool, pedestrians, cable cars and police) cannot reach +the cruise start: `aiMap::Reset`'s first act is `ResetRandomSeed` (its first +piece, before any list is reset), and `mmSingleRoam::InitOtherPlayers` +(vtable +0x40) runs after it in `mmGame::Init`. Between them R1 draws only +for `AdjustAmbients` and `AdjustPedestrians`, whose number of draws depends +on the room, the pool sizes and the road network, not on the cars' types or +the pedestrians' clothes that the earlier draws decide (`placeCar` uses a +car's bumper for the distance only; `aiPedestrian::Reset(path, side)` always +draws four). So the shared stream gives the cruise-spawn record's starts +unchanged: London intersection 19 (-542.529, 6.934, -676.292), San +Francisco 121 (40.306, 1.918, 153.295), with the cruise menu's densities +(traffic 0.5, pedestrians 0.25). `ParityCruiseSpawn.ResetDrawsBeforeTheStart` +(173 draws in London, 21 in San Francisco) and `LondonAndSanFranciscoStarts` +pin them on `ai::World::resetAndPopulate`, and +`ParityRandomStreamsRetail.CruiseStartIgnoresTheDrawsBeforeTheReset` gets +them after the whole set-up on the shared stream (and the same stream state +from a world whose stream began elsewhere); the game's own load (smoke runs +of both cities, log line "race: start at intersection") picks the same. + +## For the README (coordinator) + +The results table's ai-ambient-city row needs fixed 63 and deviation 11 +(was 61 and 13): its `Random::Random` and `World::create` (seeds) rows moved +from deviation to fixed with this work. + +## Open + +None. diff --git a/docs/parity/session.md b/docs/parity/session.md index 4e3cc0d..fc1c5d7 100644 --- a/docs/parity/session.md +++ b/docs/parity/session.md @@ -3,7 +3,8 @@ Audited against MM2Recomp (midtown2.exe build 3393) on 2026-10-07. Summary: 205 entries (a row may group closely related functions); verified -89, fixed 86, deviation 6, inferred 4, open 1, openmm2 19. Missing: 17 MM2 +87, fixed 89, deviation 5, inferred 4, open 1, openmm2 19 (with round 3's +conventions and cruise-spawn changes). Missing: 17 MM2 features (11 open, 6 deviation). A second pass (2026-10-08) wired the race-side items the first pass and @@ -51,8 +52,8 @@ Behaviour is documented in `docs/gamemodes.md`. Tests: | `loadCheckpoints` | `mmPositions::Load` | verified | x, y, z, heading, radius (atoi), hit flag | | `headingDirection` | `mmWaypoints::GetStartAngle` users | verified | (sin h, 0, −cos h) | | `spawnAt` | `mmWaypoints::GetStart`, `GetStartAngle` | verified | rotation by −heading with MM2's degree factor | -| `randomIntersectionStart` | `mmGame::RespawnXYZ` | verified | random intersection other than the first, rejecting underground, road and building rooms and freeway/alley paths, 2 m up, facing −Z; the bounded retry and the scan fallback are OpenMM2's guard against a city with no valid intersection | -| `nextRandom` | `irand` | deviation | an LCG of the same form; MM2's global random stream is shared with every subsystem, so exact draws cannot match | +| `respawnXYZ` (was `randomIntersectionStart`) | `mmGame::RespawnXYZ` | fixed | round 3 ([cruise-spawn](round3/cruise-spawn.md)): (counter + 1) draws a pick, rooms by FindRoomId of the centre, no retry cap (OpenMM2 only checks that some intersection fits) | +| `cruiseStart`, `respawnCounter` (was `nextRandom`) | `irand`, `mmSingleRoam` / `mmMultiRoam` / `mmMultiCR` start, mmGame's respawn counter | fixed | round 3: MM2's generator; single player from the global stream as aiMap::Reset left it, multiplayer two seeded picks | | `loadRaceSetup`: race lookup, settings by difficulty | `mmRaceData`, `mmGameSingle::Init` | verified | amateur / professional rows | | `loadRaceSetup`: finish stand | `mmWaypoints::LoadCSV` | fixed | `pt_finish` is waypoint 0 of a circuit and the last waypoint of a checkpoint race only; Blitz and lessons use `pt_check` everywhere (OpenMM2 marked the last waypoint in every mode) | | `loadRaceSetup`: time limit and laps | `mmSingleBlitz::InitHUD`, `mmSingleCircuit::Init` | verified | clock in single-player Blitz only; laps from the menu or the table, at least 1 | @@ -60,8 +61,8 @@ Behaviour is documented in `docs/gamemodes.md`. Tests: | `loadRaceSetup`: lesson events | `mmSingleStunt::LoadEventFile`, `InitHUD`, `InitNewEvent` | fixed | chkflags now counts when not 0 (MM2 passes chkflags != 0 to `mmWaypoints::ReInit`; OpenMM2 tested bit 0); cornerspeed below 1 → 50 verified | | `loadRaceSetup`: opponents | `aiMap::Init`, `CrashCourse::SetEnvironment` | fixed | min(table count, OpponentDensity) racers; the crash course sets OpponentDensity to 8, so lessons load at most eight cars (OpenMM2 loaded every row) | | `loadRaceSetup`: opponent grid place | `aiRouteRacer::Init` | verified | first `.opp` row, its fourth column in degrees × 0.017444445, not negated | -| `loadRaceSetup`: police | `aiPoliceOfficer::Init` via `aiMap::Init` | verified | posts with −heading; how many are placed is RaceScreen's (cop density) | -| `loadRaceSetup`: player start | `mmWaypoints::GetStart`, `mmSingleRoam::InitOtherPlayers` | verified | first waypoint; cruise at a random intersection; the first Blitz start without an AI map is an OpenMM2 fallback | +| `loadRaceSetup`: police | `aiRaceData::aiRaceData`, `aiPoliceOfficer::Reset` | fixed | posts turned by heading x −0.017444445, MM2's own degree factor (round 3, conventions: OpenMM2 used pi / 180); how many are placed is RaceScreen's (cop density) | +| `loadRaceSetup`: player start | `mmWaypoints::GetStart`, the cruise modes' `InitGameObjects` | verified | first waypoint; cruise at the InitGameObjects place until `Session::placeRespawnStart` (round 3); the first Blitz start without an AI map is an OpenMM2 fallback | ## Types, race configuration and strings (`src/game/session/Types.h`, `src/game/RaceConfig.h`, `src/game/Strings.{h,cpp}`) @@ -86,7 +87,7 @@ Behaviour is documented in `docs/gamemodes.md`. Tests: | `modeText` | the modes' `UpdateGame` string ids | verified | every id compared per mode (single and multiplayer) | | `lessonText` | `mmSingleStunt::Update*` countdown strings | verified | | | `formatTime` | `GetLocTime` | verified | M:SS:HH, +0.005 then truncated, " --- " for none | -| `Session::create` | `mmGameManager` / the mode's `Init` | openmm2 | wiring; seeds the cruise start | +| `Session::create` | `mmGameManager` / the mode's `Init` | openmm2 | wiring | | `str`, `setMessage`, `setMessage2` | `AngelReadString`, `mmHUD::SetMessage`, `SetMessage2` | verified | SetMessage clears the second line | | `currentLesson`, `lessonOpponentOffset` | `mmSingleStunt` event index, `GetOpponentIndex` | verified | | | `wantsMap` | `mmSingleStunt::UpdateEvade` | fixed | new: the Evade event turns the map on during its first countdown line | @@ -263,7 +264,7 @@ Behaviour is documented in `docs/gamemodes.md`. Tests: | `RaceScreen::startFinishCamera` | `mmGameMulti::SetFinishCam`, `mmPlayer::SetMPPostCam` | fixed | the orbit camera on the finish (last waypoint, a circuit's first) at (heading + 180) x -0.017453292; Blitz the post-race camera | | `RaceScreen::updateCarCamera` (orbit keys, probe) | `camPolarCS::Update`, `dgPhysManager::Collide` (0x20) | fixed | Delete / Page Down / End / Home / Page Up / Insert / Shift; the probe is the wheels' (city and flagged instances, not the player's car) | | `RaceScreen::updateGameInput` (mirror) | `mmViewMgr::SetViewSetting(9)` (event 0x1E), `mmViewMgr::Init` | fixed | toggles `RearViewMirror`, which `drawMirror` (rendering-fx) draws; off unless the driver's profile had it on | -| `RaceScreen::updateGameInput` (XCam) | `mmGame::UpdateGameInput` event 0x0C, `mmGame::UpdatePaused` keys 0x2E / 0x2F, `mmViewMgr::SetViewSetting(0, 2)` | fixed | Thrill Cam calls `PlayerCameras::toggleXCam` (steered by `CameraInput::orbit`); while paused the C and V keys change the camera and the XCam whatever the actions are bound to | +| `RaceScreen::updateGameInput` (XCam) | `mmGame::UpdateGameInput` event 0x0C, `mmViewMgr::SetViewSetting(0, 2)` | fixed | Thrill Cam calls `PlayerCameras::toggleXCam` (steered by `CameraInput::orbit`); the raw C and V keys taken from `mmGame::UpdatePaused` were removed in round 3's [order](round3/order.md) audit, as nothing in build 3393 calls it (a paused game reads its bound actions through `UpdateGameInput`) | | `CityData::levelRoomFlags` (ai-ambient-city's) | `cityLevel::Load`, `lvlLevel::LoadInstances` | fixed | lvlRoomInfo's own flags; the session's own copy was dropped at the merge and every reader below uses this one | | `RaceScreen::cameraTarget` (room flags), `levelRoomFlagsAt`, `m_tunnel` | `mmPlayer::Update` | fixed | the level flags, not the PSDL bytes (the tunnel echo reads `LevelRoomFlag::Subterranean`) | | `RaceScreen::rainVisible` | `cityLevel::DrawRooms` | fixed | no rain with the camera (main view or mirror, from `drawLevel`) in a subterranean or covered room, nor in a terrain-instance room under geometry (probe from 100 m up) | diff --git a/docs/parity/vehicle.md b/docs/parity/vehicle.md index 6518388..8ef952f 100644 --- a/docs/parity/vehicle.md +++ b/docs/parity/vehicle.md @@ -130,7 +130,7 @@ park brake in vehCarSim, MetricFactor 2.2360249, WeatherFriction 0.8 / 0.75. | OpenMM2 | MM2 | Verdict | Notes | | --- | --- | --- | --- | -| `Splash::init` | `vehSplash::Init` | deviation | grid verified (asm); MM2 first fills the points with random directions (192 `frand` calls on the game's random stream) and overwrites them, which OpenMM2 skips | +| `Splash::init` | `vehSplash::Init` | deviation | grid verified (asm); MM2 first fills the points with random directions (192 `frand` calls on the game's random stream) and overwrites them, which `Splash::init` skips; the race's set-up takes those draws (`game::takeVehCarInitDraws`, round 3: `docs/parity/round3/random-streams.md`) | | `Splash::reset` | `vehSplash::Reset` | verified | drag 0.08, buoyancy 0.7 | | `Splash::activate` | `vehSplash::Activate` | verified | | | `Splash::deactivate` | `vehCar::Reset` | fixed | vehCar::Reset clears only the active flag; CarSim::reset used to restore the buoyancy too (vehSplash::Reset runs once, from the constructor) | @@ -229,7 +229,7 @@ park brake in vehCarSim, MetricFactor 2.2360249, WeatherFriction 0.8 / 0.75. | `SimVehicle::load` | `vehCar::Init`, `vehCarModel::InitBound`, `vehTrailer::Init` | deviation | tunes, bound and trailer verified. `tuneSuffix` is an OpenMM2 option no caller uses (MM2 never loads a variant); the trailer needs both tune files (MM2 builds it from the hitch alone, with defaults) | | `SimVehicle::addTo` / `removeFrom` | | openmm2 | | | `SimVehicle::reset` | `vehCar::Reset`, `vehTrailer::Reset` | inferred | also clears the pedal swap (see ArcadeControls::reset) | -| `SimVehicle::trailerPose` | `vehTrailerInstance::Draw` | verified | tail lights above 0.1 brake; TWHL0-3 only | +| `SimVehicle::trailerPose` | `vehTrailerInstance::Draw` | verified | tail lights above 0.1 brake; TWHL0-3 only (drawn by VehicleRenderer::drawTrailer since round 3, frames) | | `SimVehicle::hold` | `vehCar::SetDrivable(0, 1)`, `vehCar::PreUpdate` | fixed | brake on and neutral every frame, throttle (free revving), steering and handbrake the player's; OpenMM2 held the car in drive with throttle 0 and the handbrake. RaceScreen passes the pedals | | `SimVehicle::drive` | `vehCar::SetDrivable(1, ...)`, `mmGame::UpdateSteeringBrakes` | fixed | releasing the hold selects first gear (SetForward) | | `SimVehicle::reversing` | gear 0 (`vehCarModel::DrawGlow`'s reverse light) | verified | | diff --git a/docs/physics.md b/docs/physics.md index ad89b2d..65b1042 100644 --- a/docs/physics.md +++ b/docs/physics.md @@ -374,8 +374,12 @@ wheel pivots, with vpcop's body, bound, damage, gyro and stuck tunes `mmPlayer::Update` sets the three from the player's tune by speed: f = clamp(speed, 5, 100) / 95 (the clamped speed over the range), value = Lo + (Hi - Lo) f, with DeltaIn 2.5 → 1.5, DeltaOut 3.5 → 2.5, Filter 2 → - 1 (the constructor's values; the retail game has no `mmPlayer` tune). - Mouse and wheel steering (`mmPlayer::FilterSteering`) are not ported. + 1 by default (the constructor's values). The player node is named after + the car and loads `tune/.asnode` (`mmPlayer::FileIO`), which every + retail car but vpcentury and vpdune ships: vpbug's, for example, has + SpeedBaseHi 44.6, DeltaOut 2.573 → 0.8, DeltaIn 5 → 5 and Filter 1.2 → + 1.2, and the same file sets the mouse, joystick and wheel curves of + `mmPlayer::FilterSteering` (`GameInput::setPlayerTune`). * After `mmGame`'s input, `mmPlayer::Update`: once the race is over the car brakes with the wheel turned full left; a wreck loses throttle, steering and brake; below 4 mph without throttle the handbrake holds the car. diff --git a/docs/rendering.md b/docs/rendering.md index 5f104d8..afbab36 100644 --- a/docs/rendering.md +++ b/docs/rendering.md @@ -31,7 +31,7 @@ decisions: levels of detail, distances, textures, states and order. | Lighting tables | `city/.ltNN` (time × 4 + weather): key, fill1 and fill2 lights (heading, pitch, colour) and an ambient colour, read over the table's previous contents: the 16 tables live for the session, a missing file or field keeps what the table held (the constructor's: key along x 30° down, white; fills at ±120°, 22.5° down, 0.75 and 0.5 grey; ambient 0xFF101010, for the first city) | MM2 (`cityTimeWeatherLighting`, `LoadCityTimeWeatherLighting`, `datParser::Load`); `city::loadCity` keeps the tables | | Light direction | the direction a light travels: (cos h cos p, sin p, sin h cos p); vertices take N · −L | MM2 (`cityLevel::SetLightDirection`; the old inferred convention was 90° off) | | Light quality | the Lighting Quality option (an integer 0–3, default 3; `mmGame::SetLevelGraphics`: sm_LightQuality = trunc(gxLightQuality)) turns on the key light from quality 1, fill1 from 2, fill2 from 3; the ambient is the file's at quality 3, white at 0, and at 2 / 1 33% / 66% of the way to white from the ambient the table held before the file loaded: the 16 tables live for the whole session and the reduced levels are computed just before each .ltNN loads, so the first city of a session uses the constructor's 0xFF101010 and later ones the previously loaded city's | MM2 (`cityLevel::SetupLighting`, `LoadCityTimeWeatherLighting`, `ComputeAmbientLightLevels`); `city::loadCity` keeps the history | -| Instance lighting | Direct3D 7-style per-vertex lighting with those lights: the material's emissive colour plus (ambient + Σ lights) × the material colour (its ambient is its diffuse), clamped per vertex | MM2. MM2 also tints the directional lights of each room's instances by the room colour (`SetupPerRoomLighting`); every room is white in retail (below), so this has no effect | +| Instance lighting | Direct3D 7-style per-vertex lighting with those lights: the material's emissive colour plus (ambient + Σ lights) × the material colour (its ambient is its diffuse), clamped per vertex. Normals go through the inverse transpose of the instance's matrix and are not renormalised (MM2 never turns on NORMALIZENORMALS), so a scaled building or prop is lit in proportion to 1 / its scale along each axis (inferred from Direct3D's rules) | MM2. MM2 also tints the directional lights of each room's instances by the room colour (`SetupPerRoomLighting`); every room is white in retail (below), so this has no effect | | Street geometry | unlit: roads, sidewalks, roofs, fans and ground take the room colour, curb faces and curb caps half of it, facades and slivers the wall light table entry (ambient + Σ max(0, n · −L) × light colour for 64 horizontal directions) shaded by the room colour | MM2 (`sdlPage16::Draw`, `GetShadedColor`, `sdlCommon::UpdateLighting`): a facade or sliver takes the table index the room's last FacadeBound attribute stores (the one retail wall without one uses entry 0, as Draw starts each room with it) | | Room colours | `city/.lmap` holds one per room, but cityLevel::Load rejects it unless its count equals the PSDL room count including room 0 (London 1340 vs 1341, SF 1125 vs 1171), so in retail every room is white | MM2 (`cityLevel::Load`) | | Fog | `city/_fog.csv` row (time × 4 + weather): linear fog colour, start = min(far − 30, start), end = min(far, end); the clear colour is the fog colour | MM2 (`lvlSky::SetupFog`, `cityLevel::DrawRooms`) | @@ -71,7 +71,7 @@ are not drawn. | Topic | Behaviour | Evidence | |---|---|---| | Near | within 35 m of the camera the posed model (`modModel::Draw`, default culling), lit and fogged | MM2 (`aiPedestrianInstance::Draw`, `pedAnimationInstance::Draw`) | -| Far | beyond 35 m a stick figure from `anim/.rays`: for each bone with a start width, its position raised by the bone's offset (its children see the raised position), a quad to its parent across the camera's right axis with the start and end half widths, coloured trunc(255 x diffuse) of the variant's shader the variant row names; untextured, unlit, both sides, fogged. A type without a .rays file draws nothing there | MM2 (`pedAnimation::DrawSkeleton`; the .rays reading in the pedestrian type's loader: per bone start width, end width, offset, parent and an unused byte, then one shader index per bone for each variant) | +| Far | beyond 35 m a stick figure from `anim/.rays`: for each bone with a start width, its position raised by the bone's offset (its children see the raised position), a quad to its parent with the start and end half widths across the first row of the modelview matrix taken in the pedestrian's own space (the pedestrian's X axis in view space, MM2's view matrix negating z: the camera's right axis while pedestrian and camera turn only about Y, tilted under a pitched camera), coloured trunc(255 x diffuse) of the variant's shader the variant row names; untextured, unlit, both sides, fogged. A type without a .rays file draws nothing there | MM2 (`pedAnimation::DrawSkeleton`; the .rays reading in the pedestrian type's loader: per bone start width, end width, offset, parent and an unused byte, then one shader index per bone for each variant) | ## Wide angle @@ -95,10 +95,11 @@ are not drawn. | Car reflections | the high LOD body again with `texture/refl_dc` mapped from the world-space normals (u = 0.5 + 0.5 x, v = 0.5 − 0.5 y), lit by an ambient grey of ftol(power × 255) (power 0.5 for car paint), added (ONE/ONE) and fogged, "Vehicle Reflections" option | MM2 (`modStatic::DrawEnvMapped`, `modShader::BeginEnvMap`, `cityLevel::GetEnvMap`) | | Car shadow | the high LOD SHADOW mesh turned onto the ground found 1 m above to 1 m (else 5 m) below the car, none on ground steeper than normal.y 0.7; alpha blended, depth tested, no depth writes, pulled forward | MM2 (`vehCarModel::DrawShadow`, `lvlInstance::DrawPhysics`) | | Car lights | glow pass, unlit (pre-lit parts: white vertices, the black material ignored), added ONE/ONE, no depth writes: TLIGHT and BLIGHT while the brake input is not zero, TLIGHT again with the light flag (evening, night or fog), RLIGHT in reverse | MM2 (`vehCarModel::DrawGlow`, `mmGame::InitWeather`) | -| Headlights | two ltLights at the headlight0/1 pivots in the HEADLIGHT0/1 colours shining forward; each draws `lt_glow` facing the camera, half size 0.2 sqrt(25 cos^3 theta) = cos^1.5 theta metres, colour 0.95 x the light's (the scales are globals: aiVehicleManager::Init sets 0.2/0.95 after the single-player cars exist, a vehSiren constructed later sets 0.2/0.6); with the light flag, or sweeping +-42.4 rad/s while the siren is on | MM2 (`vehCarModel::DrawHeadlights`, `ltLight::DrawGlow`) | +| Headlights | two ltLights at the headlight0/1 pivots in the HEADLIGHT0/1 colours shining forward; each draws `lt_glow` facing the camera, half size 0.2 sqrt(25 cos^3 theta) = cos^1.5 theta metres, colour 0.95 x the light's (the scales are globals: aiVehicleManager::Init sets 0.2/0.95 after the single-player cars exist, a vehSiren constructed later sets 0.2/0.6); with the light flag, or sweeping +-42.4 rad/s while the siren is on: the two directions are kept in world space, the car's forward axis without the siren and turned about Y by the frame's share of the sweep from wherever they point with it, so the sweep does not turn with the car (MM2 also turns them again for the mirror's view; OpenMM2 once a frame) | MM2 (`vehCarModel::DrawHeadlights`, `ltLight::DrawGlow`) | | Police lights | an ltLight per SRN0-3 part (pivot, part colour) whose world-space direction turns 2.5 pi rad/s about Y, a quarter turn apart, drawn as above, and each light's lens flares: twenty `lt_flare` cards per siren (ltFlare::Random colours, sizes and places along the line through the screen's centre; the first on the light, 0.3 across, the second mirrored through the centre, 0.25), drawn when the light's intensity (25 / d^2 x cos^3, less 0.05) x 0.5 reaches 0.05 and it is within twice the half height of the centre (fading from half of it), added over the scene without depth. OpenMM2 keeps the cards square at any aspect (MM2's flare viewport is 4:3) and draws none in the mirror | MM2 (`vehSiren`, `vehSiren::Draw`, `ltLensFlare`, `ltFlare::Random`, `ltLight::ComputeIntensity`) | +| Semi trailers | `vehTrailerInstance::Draw`: the TRAILER body at its LOD with the paint job's shaders as stored (no texel damage); only at H, TLIGHT while the tow car brakes harder than 0.1, as an ordinary lit draw of the object pass (its black, fully transparent fxltglowred material leaves it invisible under the pass's alpha test; inferred from the material, as OpenMM2 draws it), and TWHL0-3 at the trailer's wheel matrices. No reflection, decal, breakables, fenders, hubs or TWHL4/5, so TWHL0/1's medium and low meshes, modelled away from their pivots, are never drawn; no glows (lvlInstance's empty DrawGlow). The shadow as Car shadow | MM2 (`vehTrailerInstance::Init`, `Draw`, `DrawShadow`) | | Car LOD | as objects (above), culled beyond NoDraw | MM2 (`lvlInstance::IsVisible`); the radius is the body set's farthest vertex from the model origin (`modGetStatic`) | -| Traffic cars | `aiVehicleInstance::Draw`: the body at its LOD with the colour's shaders as stored (no texel damage), BREAK0-3 always at H, and only at H the reflection and the wheels (turning about their axles on the rails, no steering; no wheels below H since LAME_WHEELS is off). Glows: TLIGHT while braking or stopped and again with the light flag; with the light flag one white spot light (the manager's) at the headlight0 pivot and its mirror image when the car has HEADLIGHT1, pulled 0.2 m towards the camera | MM2 (`aiVehicleInstance::Draw`, `DrawGlow`, `aiVehicleManager::Init`) | +| Traffic cars | `aiVehicleInstance::Draw`: the body at its LOD with the colour's shaders as stored (no texel damage), BREAK0-3 always at H, and only at H the reflection and the wheels (turning about their axles on the rails, no steering; no wheels below H since LAME_WHEELS is off). A car knocked off its rail (with an aiVehicleActive) draws WHL0-3 unturned at its vehWheelCheaps' drawing matrices (the pivot moved by the spring's travel along the car's up axis, Limit below it off the ground, and back by 0.2 / 0.3 of the sideways / forward tyre deflection) and WHL4/5 unturned at their pivots raised by WHL2/3's drawn height less the wheel radius. Shadow: such a car's on the ground under it (as Car shadow), otherwise at the car's matrix while upright and on the ground when upside down. Glows: TLIGHT while braking or stopped and again with the light flag; with the light flag one white spot light (the manager's) at the headlight0 pivot and its mirror image when the car has HEADLIGHT1, pulled 0.2 m towards the camera | MM2 (`aiVehicleInstance::Draw`, `DrawGlow`, `DrawShadow`, `vehWheelCheap::Update`, `aiVehicleManager::Init`) | 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, diff --git a/src/ai/Pedestrians.cpp b/src/ai/Pedestrians.cpp index 901dbcb..da979c7 100644 --- a/src/ai/Pedestrians.cpp +++ b/src/ai/Pedestrians.cpp @@ -63,7 +63,18 @@ float turnTowards(float heading, float angle) { Pedestrians::Pedestrians(const RoadNetwork& network, std::vector types, const PedSettings& settings, std::uint64_t seed) - : m_net(network), m_types(std::move(types)), m_settings(settings), m_seed(seed), m_rng(seed) { + : m_net(network), m_types(std::move(types)), m_settings(settings), m_seed(seed), m_ownRng(seed), + m_rng(&m_ownRng) { + init(); +} + +Pedestrians::Pedestrians(const RoadNetwork& network, std::vector types, + const PedSettings& settings, Random& random) + : m_net(network), m_types(std::move(types)), m_settings(settings), m_rng(&random) { + init(); +} + +void Pedestrians::init() { // The sequences the AI asks for, by name (aiPedestrian::Init). LDIVE and // RDIVE are looked up too but no retail table has them. for (const auto& t : m_types) { @@ -170,9 +181,9 @@ Pedestrians::Pedestrians(const RoadNetwork& network, std::vector ty for (std::size_t t = 0; t < m_types.size(); ++t) allowed.push_back(static_cast(t)); for (auto& p : m_peds) { - p.type = allowed[static_cast(m_rng.frand() * static_cast(allowed.size()))]; + p.type = allowed[static_cast(m_rng->frand() * static_cast(allowed.size()))]; const int variants = m_types[static_cast(p.type)].variants; - p.variant = static_cast(m_rng.frand() * static_cast(variants - 1)); + p.variant = static_cast(m_rng->frand() * static_cast(variants - 1)); } // aiMap::Reset: every pedestrian into the pool in index order, so the // last one is taken first. @@ -541,7 +552,7 @@ int Pedestrians::pickNextRoad(Ped& p) { } int choice = 0; if (m_lights && m_lights->hasLights(node) && m_lights->cycleAt(node) != LightCycle::Rotate) - choice = m_rng.irand() % 3; + choice = m_rng->irand() % 3; // aiPedestrian::UpcomingAccident: a car out of normal driving there. if (m_accident && m_accident(node, -1, 0)) choice = 0; @@ -612,11 +623,11 @@ void Pedestrians::reset(int idx, int path, int side) { p.wall = false; p.side = p.prevSide = side; const int lanes = static_cast(w.rowCum.size()) - 1; - const float dist = m_rng.frand() * subLength(path, side, 0, sections(path) - 1); + const float dist = m_rng->frand() * subLength(path, side, 0, sections(path) - 1); p.dist = dist; - p.lateral = ((w.outer - w.inner) * 0.5f - 0.5f) * std::sin(m_rng.frand() * 6.2831f); + p.lateral = ((w.outer - w.inner) * 0.5f - 0.5f) * std::sin(m_rng->frand() * 6.2831f); p.idx = sidewalkIndex(path, side, dist); - p.dir = p.prevDir = m_rng.frand() < 0.5f ? 1 : -1; + p.dir = p.prevDir = m_rng->frand() < 0.5f ? 1 : -1; // (MM2 turns the old heading round here for direction +1; it is // overwritten at once.) p.heading = getHeading(path, dist, lanes, p.dir); @@ -640,7 +651,7 @@ void Pedestrians::reset(int idx, int path, int side) { pathAdd(path, idx); p.frameHeading = p.heading; startSeq(p, seqs(p).walk); - p.frame = static_cast(m_rng.frand() * static_cast(frameCount(p, seqs(p).walk))); + p.frame = static_cast(m_rng->frand() * static_cast(frameCount(p, seqs(p).walk))); solveTargetPoint(p, static_cast(p.dir) * kPedLookAhead + dist); p.reversingAtDive = false; p.sideDist0 = 0.0f; @@ -722,8 +733,10 @@ void Pedestrians::populateAll() { } void Pedestrians::reset() { - // aiMap::Reset: ResetRandomSeed first (OpenMM2: this stream's own seed). - m_rng.seed(static_cast(m_seed)); + // aiMap::Reset: ResetRandomSeed first. A shared stream is the owner's + // to seed (ai::World::reset); the pedestrians' own goes back to its seed. + if (m_rng == &m_ownRng) + m_rng->seed(static_cast(m_seed)); // aiPath::Reset: each road's pedestrian list (+0x20), its players' mask // and its link in the populated list (+0x34); aiMap +0x180 emptied. std::ranges::fill(m_pathHead, -1); @@ -1162,7 +1175,7 @@ void Pedestrians::anticipate(Ped& p, const PlayerCar& c) { backupAt(p); } } else if (p.seq == s.walk) { - if (m_rng.frand() >= 0.5f) { + if (m_rng->frand() >= 0.5f) { // Run along the road the way the car is going. startSeq(p, s.run); const auto& centre = m_net.source()->paths[static_cast(p.path)].center; @@ -1352,7 +1365,7 @@ void Pedestrians::waitCross(Ped& p, const PlayerCar& c) { Vec3 nearSide, farSide; crossTargets(p, nearSide, farSide); p.target = farSide; - queueSeq(p, m_rng.frand() < 0.5f ? seqs(p).stand : seqs(p).stand2); + queueSeq(p, m_rng->frand() < 0.5f ? seqs(p).stand : seqs(p).stand2); p.lastReaction = p.reaction; p.lastCross = p.cross; } @@ -1416,7 +1429,7 @@ void Pedestrians::update(int idx, float dt, const PlayerCar& c) { const float r = c.radius; if ((p.position - c.transform.m3).dot(m) < 0.0f && (r + r) * (r + r) < d2) { p.reaction = 0; - p.lateral = std::sin(m_rng.frand() * 6.2831f) * kPedMaxLateral; + p.lateral = std::sin(m_rng->frand() * 6.2831f) * kPedMaxLateral; calcCurve(p, p.idx - 1, p.idx, p.lateral); } } @@ -1547,22 +1560,7 @@ void Pedestrians::step(float dt, const PlayerCar& player, int room) { return kNone; return map->roomPathsIn[static_cast(r)]; }; - for (Ped& p : m_peds) - p.placed = false; - if (!m_started) { - // aiMap::Reset: the player's first room. - m_started = true; - m_room = room; - if (m_populateAll) { - std::vector all; - for (std::size_t p = 0; p < m_net.paths().size(); ++p) - all.push_back(static_cast(p)); - adjust(kNone, all); - m_populateAll = false; - } else { - adjust(list(0), list(room)); - } - } else if (room != 0 && room != m_room) { + if (!populate(room) && room != 0 && room != m_room) { // aiMap::Update: the player entered another room. adjust(list(m_room), list(room)); m_room = room; @@ -1570,6 +1568,35 @@ void Pedestrians::step(float dt, const PlayerCar& player, int room) { for (int path = m_activeHead; path >= 0; path = m_activeNext[static_cast(path)]) updateRoad(path, dt, player); publish(); + // A pedestrian placed (AudCreatureContainer::Reset) shows so in one + // step's list only. + for (Ped& p : m_peds) + p.placed = false; +} + +bool Pedestrians::populate(int room) { + if (m_started) + return false; + // aiMap::Reset: the player's first room. + m_started = true; + m_room = room; + const city::AiMap* map = m_net.source(); + static const std::vector kNone; + if (m_populateAll) { + std::vector all; + for (std::size_t p = 0; p < m_net.paths().size(); ++p) + all.push_back(static_cast(p)); + adjust(kNone, all); + m_populateAll = false; + } else if (map) { + auto list = [&](int r) -> const std::vector& { + if (r < 0 || static_cast(r) >= map->roomPathsIn.size()) + return kNone; + return map->roomPathsIn[static_cast(r)]; + }; + adjust(list(0), list(room)); + } + return true; } void Pedestrians::publish() { diff --git a/src/ai/Pedestrians.h b/src/ai/Pedestrians.h index 92afd53..47be2c6 100644 --- a/src/ai/Pedestrians.h +++ b/src/ai/Pedestrians.h @@ -99,8 +99,16 @@ class Pedestrians { // Whether a vehicle out of normal driving is at `intersection` or on `path`. using AccidentQuery = std::function; + // The pedestrians' own random stream, set to `seed` again by reset(). Pedestrians(const RoadNetwork& network, std::vector types, const PedSettings& settings, std::uint64_t seed); + // MM2's global stream, shared with the traffic (ai::World): aiMap::Init's + // draws for the pedestrians come from it as it stands (after the + // traffic's), and its owner sets it back to 1 at a reset. + Pedestrians(const RoadNetwork& network, std::vector types, const PedSettings& settings, + Random& random); + Pedestrians(const Pedestrians&) = delete; + Pedestrians& operator=(const Pedestrians&) = delete; void setLights(const TrafficLights* lights) { m_lights = lights; } void setProbe(Probe probe) { m_probe = std::move(probe); } @@ -116,14 +124,20 @@ class Pedestrians { // One update; `room` is the player's PSDL room (0: outside, no change). void step(float dt, const PlayerCar& player, int room); // aiMap::Reset's pedestrian part (mmGame::Init after the AI map loads, - // and mmGame::Reset when a race restarts): the random seed back to its - // start (ResetRandomSeed), every road's list and populated flag cleared + // and mmGame::Reset when a race restarts): the own random stream back to + // its seed (ResetRandomSeed; a shared one is its owner's to set), every + // road's list and populated flag cleared // (aiPath::Reset), the intersections' prop lists emptied // (aiIntersection::Reset clears what AddBangersToObsMap listed at load, // so pedestrians never see an intersection's props in play), every // pedestrian reset and back in the pool in index order. The next step // populates the roads round the player's room. void reset(); + // aiMap::Reset's AdjustPedestrians from room 0 to the player's room, + // made on the first step after a reset (step() calls it; ai::World calls + // it after the traffic's population and before either updates). False + // when already populated. + bool populate(int room); const std::vector& peds() const { return m_public; } // The props the roads and intersections list (aiPath / aiIntersection @@ -261,13 +275,15 @@ class Pedestrians { void crossStreet(Ped& p, const PlayerCar& c); void publish(); + void init(); // aiMap::Init's part for the pedestrians const RoadNetwork& m_net; std::vector m_types; std::vector m_seqs; PedSettings m_settings; - std::uint64_t m_seed = 1; // ResetRandomSeed's value for this stream - Random m_rng; + std::uint64_t m_seed = 1; // ResetRandomSeed's value for the own stream + Random m_ownRng; // the stream when none is shared + Random* m_rng; // the stream drawn from std::vector m_peds; int m_poolHead = -1; // aiMap +0x88 std::vector m_pathHead; // first pedestrian of each road (aiPath +0x20) diff --git a/src/ai/Random.h b/src/ai/Random.h index 19873c7..dabe935 100644 --- a/src/ai/Random.h +++ b/src/ai/Random.h @@ -6,16 +6,22 @@ namespace mm2::ai { // MM2's irand() and frand() (build 3393): the MSVC rand() linear // congruential generator, seed = seed * 214013 + 2531011, returning bits 16..30 -// (0 .. 32767); frand() is irand() * 2^-15, so [0, 32767/32768]. The original -// has one global seed for everything (traffic, pedestrians, police, audio, -// effects), set to 1 by aiMap::Reset (ResetRandomSeed). OpenMM2 gives each AI -// subsystem its own generator so identical seeds and inputs replay -// identically; the draws and their scaling are MM2's. +// (0 .. 32767); frand() is irand() * 2^-15, so [0, 32767/32768]. +// +// The original has one global seed, gRandSeed, for everything. ResetRandomSeed +// sets it to 1 before each road's street props (cityLevel::Load) and in +// aiMap::Reset, and a race's set-up draws from it in a fixed order (the cars' +// sirens and splashes, the gizmos, the traffic, the pedestrians, the cable +// cars; docs/parity/round3/random-streams.md). OpenMM2 passes one Random +// through the systems that draw in that window, so their outcomes are MM2's; +// the systems that draw only during play keep their own instances. class Random { public: explicit Random(std::uint64_t seed = 1) : m_seed(static_cast(seed)) {} void seed(std::uint32_t s) { m_seed = s; } + // The seed the next draw starts from (MM2's gRandSeed). + std::uint32_t state() const { return m_seed; } // irand(): 0 .. 32767. int irand() { @@ -24,6 +30,11 @@ class Random { } // frand(): irand() times 3.0517578e-05. float frand() { return static_cast(irand()) * 3.0517578125e-05f; } + // `n` draws whose values are not used. + void discard(int n) { + for (int i = 0; i < n; ++i) + m_seed = m_seed * 214013u + 2531011u; + } private: std::uint32_t m_seed; diff --git a/src/ai/Traffic.cpp b/src/ai/Traffic.cpp index e8188c4..11dfd26 100644 --- a/src/ai/Traffic.cpp +++ b/src/ai/Traffic.cpp @@ -87,7 +87,9 @@ PlayerCar PlayerCar::at(const Vec3& pos, const Vec3& vel) { PlayerCar p; Vec3 f{vel.x, 0.0f, vel.z}; const float yaw = f.mag2() > 0.01f ? std::atan2(-f.x, -f.z) : 0.0f; - p.transform = Mat34::rotationY(yaw + kPi); // forward (-Z) along the velocity + // Mat34::rotationY(a) faces (-sin a, 0, -cos a): forward (-Z) along the + // velocity (still, it faces -Z). + p.transform = Mat34::rotationY(yaw); p.transform.m3 = pos; p.velocity = vel; return p; @@ -98,7 +100,17 @@ PlayerCar PlayerCar::at(const Vec3& pos, const Vec3& vel) { Traffic::Traffic(const RoadNetwork& network, TrafficLights& lights, std::vector types, const TrafficSettings& settings, std::uint64_t seed) : m_net(network), m_lights(lights), m_types(std::move(types)), m_settings(settings), m_seed(seed), - m_rng(seed) { + m_ownRng(seed), m_rng(&m_ownRng) { + init(); +} + +Traffic::Traffic(const RoadNetwork& network, TrafficLights& lights, std::vector types, + const TrafficSettings& settings, Random& random) + : m_net(network), m_lights(lights), m_types(std::move(types)), m_settings(settings), m_rng(&random) { + init(); +} + +void Traffic::init() { if (m_types.empty()) { VehicleData d; d.model = "va_sedans_s"; @@ -108,13 +120,22 @@ Traffic::Traffic(const RoadNetwork& network, TrafficLights& lights, std::vector< // all when the density is 0. const float density = clampf(m_settings.density, 0.0f, 1.0f); m_density = density * kAmbientDensityScale; - const int count = density == 0.0f ? 0 : std::max(0, m_settings.poolSize); + const int pool = std::max(0, m_settings.poolSize); + const int count = density == 0.0f ? 0 : pool; m_cars.resize(static_cast(count)); // Constructors of the vehicle array: aiRailSet (lane randomness), then - // aiVehicleSpline (reaction ticks), for every car. - for (auto& c : m_cars) { - c.laneRandomness = std::sin(m_rng.frand() * 6.2831f) * 0.5f; - c.totReactTicks = 8 - static_cast(m_rng.frand() * -17.0f); + // aiVehicleSpline (reaction ticks), for every car. MM2 builds the array + // of the state's vehicle count before it looks at the density, so at + // density 0 the constructors still draw (aiMap::Init sets the count to 0 + // after them). + for (int i = 0; i < pool; ++i) { + const float lane = m_rng->frand(); + const float react = m_rng->frand(); + if (i >= count) + continue; + Car& c = m_cars[static_cast(i)]; + c.laneRandomness = std::sin(lane * 6.2831f) * 0.5f; + c.totReactTicks = 8 - static_cast(react * -17.0f); } // Then per car: its type, aiVehicleAmbient::Init: aiVehicleSpline::Init // builds the aiVehicleInstance, whose ctor keeps an arbitrary number @@ -130,13 +151,13 @@ Traffic::Traffic(const RoadNetwork& network, TrafficLights& lights, std::vector< // stand-in for its address (inferred). const auto index = static_cast(&c - m_cars.data()); c.blinkPhase = static_cast(((index * 0x40u) * 214013u + 2531011u) >> 16 & 0x7FFFu); - (void)m_rng.frand(); // aiVehicleInstance ctor's SetColor, overwritten below - c.paint = m_rng.frand(); + (void)m_rng->frand(); // aiVehicleInstance ctor's SetColor, overwritten below + c.paint = m_rng->frand(); c.exceedLimit = exceedCounter + exceedCounter; exceedCounter -= 1.0f; if (exceedCounter < 0.0f) exceedCounter = 4.0f; - const float f = m_rng.frand(); // one draw for both + const float f = m_rng->frand(); // one draw for both c.accelFactor = f * 3.0f + 5.0f; c.separation = f * 2.5f + 0.5f; // aiVehicleSpline::Init: bumper and side distances from the box bound @@ -170,7 +191,7 @@ Traffic::Traffic(const RoadNetwork& network, TrafficLights& lights, std::vector< int Traffic::pickType() { // aiMap::Init: the first type whose cumulative probability exceeds frand. - const float r = m_rng.frand(); + const float r = m_rng->frand(); for (const auto& t : m_settings.types) { if (r < t.cumulative) { for (std::size_t i = 0; i < m_types.size(); ++i) @@ -654,7 +675,7 @@ void Traffic::adjustAmbients(int oldRoom, int newRoom) { for (int l = 0; l < lanes; ++l) { const float space = laneLength(p, dir, l) / static_cast(n + 1); for (int i = 0; i < n && !m_pool.empty(); ++i) { - const float jitter = std::sin(m_rng.frand() * 6.28f); + const float jitter = std::sin(m_rng->frand() * 6.28f); const float dist = (static_cast(i + 1) * space - space * 0.5f) + (space - 10.0f) * jitter * 0.5f; if (!placeCar(m_pool.back(), p, dir, l, dist)) @@ -693,8 +714,10 @@ int Traffic::poolFree() const { } void Traffic::reset() { - // aiMap::Reset: ResetRandomSeed first (OpenMM2: this stream's own seed). - m_rng.seed(static_cast(m_seed)); + // aiMap::Reset: ResetRandomSeed first. A shared stream is the owner's + // to seed (ai::World::reset); the traffic's own goes back to its seed. + if (m_rng == &m_ownRng) + m_rng->seed(static_cast(m_seed)); // aiPath::Reset: the lane lists, the section obstacle lists // (ResetObstacles), the populated flag and list link, the always // stop / go flags. @@ -748,7 +771,7 @@ void Traffic::reset() { bool Traffic::chooseNext(Car& c) { RailLink next; - if (chooseNextLaneLink(m_net, {c.path, c.dir, c.drawLane}, m_rng, next)) { + if (chooseNextLaneLink(m_net, {c.path, c.dir, c.drawLane}, *m_rng, next)) { c.nextPath = next.path; c.nextDir = next.dir; c.nextLane = next.lane; @@ -1753,7 +1776,7 @@ void Traffic::updateAvoidPlayer(int idx, float dt, const PlayerCar& p) { const Vec3 d = p.transform.m3 - c.transform.m3; const float lat = c.transform.m0.dot(d); const float fwd = (-c.transform.m2).dot(d); - const float f = m_rng.frand(); + const float f = m_rng->frand(); const float off = c.passOffset; float beta; if ((info.flags & 0x1) && c.drawLane == 0) { @@ -2239,15 +2262,20 @@ void Traffic::step(float dt, const Vec3& pos, const Vec3& vel, int playerRoom) { step(dt, PlayerCar::at(pos, vel), playerRoom); } +bool Traffic::populate(int playerRoom) { + if (m_started) + return false; + m_started = true; + m_room = playerRoom; + adjustAmbients(0, playerRoom); + m_populateAll = false; + return true; +} + void Traffic::step(float dt, const PlayerCar& player, int playerRoom) { m_player = player; // Population: aiMap::Reset, then whenever the player enters a new room. - if (!m_started) { - m_started = true; - m_room = playerRoom; - adjustAmbients(0, playerRoom); - m_populateAll = false; - } else if (playerRoom != 0 && playerRoom != m_room) { + if (!populate(playerRoom) && playerRoom != 0 && playerRoom != m_room) { adjustAmbients(m_room, playerRoom); m_room = playerRoom; } diff --git a/src/ai/Traffic.h b/src/ai/Traffic.h index 16a6f60..2d5603b 100644 --- a/src/ai/Traffic.h +++ b/src/ai/Traffic.h @@ -109,20 +109,34 @@ class Traffic { // Vertical ground probe from `from` down to `to`; returns the hit point. using GroundProbe = std::function; + // The traffic's own random stream, set to `seed` again by reset(). Traffic(const RoadNetwork& network, TrafficLights& lights, std::vector types, const TrafficSettings& settings, std::uint64_t seed); + // MM2's global stream, shared with the pedestrians (ai::World): + // aiMap::Init's draws for the pool come from it as it stands, and its + // owner sets it back to 1 at a reset (aiMap::Reset's ResetRandomSeed). + Traffic(const RoadNetwork& network, TrafficLights& lights, std::vector types, + const TrafficSettings& settings, Random& random); + Traffic(const Traffic&) = delete; + Traffic& operator=(const Traffic&) = delete; // One update (aiMap::Update's ambient part). `playerRoom` is the PSDL room // the player is in (0: outside every room, nothing changes). void step(float dt, const PlayerCar& player, int playerRoom); // aiMap::Reset's ambient part (mmGame::Init after the AI map loads, and - // mmGame::Reset when a race restarts): the random seed back to its start - // (ResetRandomSeed), every road and intersection list emptied + // mmGame::Reset when a race restarts): the own random stream back to its + // seed (ResetRandomSeed; a shared one is its owner's to set), every road + // and intersection list emptied // (aiPath::Reset, aiIntersection::Reset), every car back in the pool in // index order with its rail reset (aiMap::AddAmbient, aiRailSet::Reset). // The next step populates the roads around the player's room. A car's // wreck flag (aiVehicleInstance flag 2) survives, as in MM2. void reset(); + // aiMap::Reset's AdjustAmbients from room 0 to the player's room, which + // OpenMM2 makes on the first step after a reset (step() calls it; ai::World + // calls it before the pedestrians' population, as aiMap::Reset orders + // them, and before either updates). False when already populated. + bool populate(int playerRoom); // Convenience for tools: a player of default size at `pos` moving at `vel`. void step(float dt, const Vec3& pos, const Vec3& vel, int playerRoom); @@ -234,6 +248,8 @@ class Traffic { private: enum class Rail : std::uint8_t { Lane = 0, Turn = 1, LaneChange = 2, Regain = 3 }; + void init(); // aiMap::Init's part for the ambient pool + struct Car { // Pool slot, fixed for the session (aiVehicleAmbient ctor / Init). int type = 0; @@ -393,8 +409,9 @@ class Traffic { std::vector m_types; TrafficSettings m_settings; float m_density = 0.0f; // aiMap +0x3c - std::uint64_t m_seed = 1; // ResetRandomSeed's value for this stream - Random m_rng; + std::uint64_t m_seed = 1; // ResetRandomSeed's value for the own stream + Random m_ownRng; // the stream when none is shared + Random* m_rng; // the stream drawn from std::vector m_cars; std::vector m_pool; // free cars, last = next to use (aiMap +0x44) std::vector m_public; diff --git a/src/ai/World.cpp b/src/ai/World.cpp index 302b37c..77f735c 100644 --- a/src/ai/World.cpp +++ b/src/ai/World.cpp @@ -6,11 +6,13 @@ #include "asset/Ped.h" #include "core/Log.h" #include "core/StringUtil.h" +#include "data/DatFile.h" #include "data/TextTables.h" #include #include #include +#include namespace mm2::ai { namespace { @@ -117,6 +119,11 @@ std::unique_ptr World::create(const city::CityData& city, const vfs::Vfs& const std::optional cityConfig = loadCityAiConfig(vfs, city); std::unique_ptr world(new World()); + // One stream for the traffic and the pedestrians (MM2's global seed). + world->m_resetSeed = static_cast(settings.seed); + world->m_ownRandom.seed(world->m_resetSeed); + if (settings.random) + world->m_random = settings.random; NetworkOptions net; if (cityConfig && cityConfig->driveOnLeft) { net.driveOnLeft = *cityConfig->driveOnLeft != 0; @@ -156,8 +163,9 @@ std::unique_ptr World::create(const city::CityData& city, const vfs::Vfs& else log::warn("ai: {}", err); } + // aiMap::Init draws for the ambient pool first, then for the pedestrians. world->m_traffic = std::make_unique(*world->m_network, world->m_lights, std::move(data), traffic, - settings.seed); + *world->m_random); // The drivers' map: the rooms' components by the level's room lookup // (a city without PSDL rooms keeps MapView's own), and the traffic's // obstacle lists. @@ -184,7 +192,7 @@ std::unique_ptr World::create(const city::CityData& city, const vfs::Vfs& for (const auto& n : names) peds.names.push_back(settings.winterPeds ? n.second : n.first); world->m_peds = - std::make_unique(*world->m_network, loadPedTypes(vfs), peds, settings.seed * 7919u + 1u); + std::make_unique(*world->m_network, loadPedTypes(vfs), peds, *world->m_random); world->m_peds->setLights(&world->m_lights); Traffic* ambient = world->m_traffic.get(); world->m_map->setProps(world->m_peds.get()); @@ -199,9 +207,23 @@ std::unique_ptr World::create(const city::CityData& city, const vfs::Vfs& single = str::lower(cityConfig->trafficLights[0]); dual = str::lower(cityConfig->trafficLights[1]); } + // Each model's CG from tune/banger/.dgbangerdata + // (dgBangerDataManager::AddBangerDataEntry); none without the file. + std::map cgs; + auto cgOf = [&](const std::string& model) { + auto [it, added] = cgs.try_emplace(model); + if (added) + if (auto bytes = vfs.readAll(std::format("tune/banger/{}.dgbangerdata", model))) + if (auto dat = data::parseDat( + std::string_view(reinterpret_cast(bytes->data()), bytes->size())); + dat && dat->top()) + dat->top()->read("CG", it->second); + return it->second; + }; for (const auto& site : world->m_network->lights()) { Signal s; s.model = site.arrivingLanes >= 2 ? dual : single; + s.cg = cgOf(s.model); // X along the unit direction from the pole to trafficLightAxis // (away from the road on retail data), Z = (-x.z, 0, x.x): the glows // on +Z face the approaching traffic. @@ -229,6 +251,10 @@ void World::step(const PlayerCar& player) { const int room = roomAt(player.transform.m3, m_playerRoom); if (room != 0) m_playerRoom = room; + // After a reset: aiMap::Reset's population, the traffic's then the + // pedestrians', before the updates draw from the same stream. + m_traffic->populate(room); + m_peds->populate(room); m_traffic->step(kAiStepSeconds, player, room); m_peds->step(kAiStepSeconds, player, room); if (m_lightsDeferred) { @@ -240,9 +266,11 @@ void World::step(const PlayerCar& player) { } void World::reset() { - // aiMap::Reset: the light sets are children of aiMap (asNode::Reset, - // then aiIntersection::Reset resets each set again); the roads, - // intersections and ambient cars (Traffic::reset); the pedestrians. + // aiMap::Reset: ResetRandomSeed; the light sets are children of aiMap + // (asNode::Reset, then aiIntersection::Reset resets each set again); the + // roads, intersections and ambient cars (Traffic::reset); the + // pedestrians. + m_random->seed(m_resetSeed); m_lights.reset(); m_traffic->reset(); m_peds->reset(); @@ -253,6 +281,18 @@ void World::reset() { updateSignals(); } +void World::resetAndPopulate(const Vec3& playerResetPos) { + reset(); + // aiMap::Reset: per player, AdjustAmbients then AdjustPedestrians from + // room 0 (with no ambient cars or pedestrians MM2 skips them; OpenMM2's + // draw nothing then either). + const int room = roomAt(playerResetPos, 0); + m_traffic->populate(room); + m_peds->populate(room); + if (room != 0) + m_playerRoom = room; +} + void World::updateLights() { if (m_pendingLightSteps == 0) return; diff --git a/src/ai/World.h b/src/ai/World.h index 4818a99..32c2582 100644 --- a/src/ai/World.h +++ b/src/ai/World.h @@ -11,6 +11,7 @@ // then the traffic light sets (children of aiMap, updated last). #include "ai/Pedestrians.h" +#include "ai/Random.h" #include "ai/RoadNetwork.h" #include "ai/Traffic.h" #include "ai/TrafficLights.h" @@ -34,6 +35,14 @@ struct Settings { int maxCars = kAmbientPoolSize; int maxPeds = -1; // pedestrian pool; -1: the city's [Ped Pool] (default 100) bool winterPeds = false; // snow: the bad-weather pedestrian models (MM2: weather > 2) + // The traffic and the pedestrians draw from one stream, as from MM2's + // global seed: aiMap::Init's draws (the ambient pool, then the + // pedestrians), aiMap::Reset's population and their updates. `random` + // is that stream when the caller reproduces MM2's set-up order (MM2's + // global stream as aiMap::Init finds it); null gives the world its own, + // starting at `seed`. reset() sets it to `seed` (aiMap::Reset's + // ResetRandomSeed: 1). + Random* random = nullptr; std::uint64_t seed = 1; }; @@ -42,8 +51,22 @@ struct Settings { // as MM2's DrawGlow does. struct Signal { std::string model; // e.g. "sp_traflitsingle_f" - Mat34 transform; // model origin; +Z (the glows) faces the approaching traffic + Mat34 transform; // model origin (the pole's base); +Z (the glows) faces the approaching traffic + // The model's dgBangerData CG. aiTrafficLightInstance::Init places the + // instance at the base + R * CG (its GetMatrix and GetPosition), and the + // body mesh is centred on that point; the glow meshes are modelled from + // the base, so DrawGlow takes R * CG off again. + Vec3 cg; LightState state = LightState::Red; + + // GetMatrix: the instance's frame, at the CG. + Mat34 frame() const { + Mat34 m = transform; + m.m3 = transform.transform(cg); + return m; + } + // GetPosition: the CG in world space. + Vec3 position() const { return transform.transform(cg); } }; class World { @@ -63,12 +86,26 @@ class World { // One fixed step. void step(const PlayerCar& player); // aiMap::Reset (mmGame::Init calls it once the AI map has loaded and - // mmGame::Reset when a race restarts): the ambient traffic, the - // pedestrians and the light sets as at the start; the next step - // populates the roads round the player. (aiMap::Reset also resets the - // police force and officers and the racers, which the race owns here, - // and aiVehicleManager, game::TrafficBodies::reset.) + // mmGame::Reset when a race restarts): the random stream back to its + // seed (ResetRandomSeed), the ambient traffic, the pedestrians and the + // light sets as at the start; the next step populates the roads round + // the player, the traffic first, then the pedestrians, before either + // updates (aiMap::Reset's AdjustAmbients and AdjustPedestrians). + // (aiMap::Reset also resets the police force and officers and the + // racers, which the race owns here, and aiVehicleManager, + // game::TrafficBodies::reset.) void reset(); + // aiMap::Reset as mmGame::Init makes it before the mode's + // InitOtherPlayers: reset(), then the population at once rather than on + // the next step, round `playerResetPos` (cityLevel::FindRoomId with no + // hint of the player car's reset position, vehCarSim +0x210): the + // traffic's AdjustAmbients, then the pedestrians' AdjustPedestrians. + // random() is then where mmGame::RespawnXYZ draws the cruise start from; + // mmGame::Reset's aiMap::Reset (reset()) follows before the race runs. + void resetAndPopulate(const Vec3& playerResetPos); + // The stream the traffic and the pedestrians draw from (MM2's global + // seed; Settings::random when the caller gave one). + Random& random() { return *m_random; } // aiMap::Update runs the light sets after the racers and the police. A // race loop that drives those between update() and the lights sets this // and calls updateLights() after them: update() then leaves the lights' @@ -115,6 +152,9 @@ class World { World() = default; void updateSignals(); + Random m_ownRandom; // the stream when the caller shares none + Random* m_random = &m_ownRandom; // MM2's global seed for the traffic and pedestrians + std::uint32_t m_resetSeed = 1; // aiMap::Reset's ResetRandomSeed std::unique_ptr m_network; std::unique_ptr m_rooms; TrafficLights m_lights; diff --git a/src/app/Controls.h b/src/app/Controls.h index d75c767..39da226 100644 --- a/src/app/Controls.h +++ b/src/app/Controls.h @@ -234,7 +234,8 @@ struct Options { // default) a rate-limited approach. Its parameters are blended by speed in // mmPlayer::Update with f = clamp(speed, SpeedBaseLow, SpeedBaseHi) / // (SpeedBaseHi - SpeedBaseLow); the defaults are mmPlayer's constructor's -// (the names are mmPlayer::FileIO's). +// (the names are mmPlayer::FileIO's), which tune/.asnode replaces +// (GameInput::setPlayerTune). class AnalogSteering { public: struct DeviceParams { diff --git a/src/app/ForceFeedback.h b/src/app/ForceFeedback.h index 68e1a97..9e90d8a 100644 --- a/src/app/ForceFeedback.h +++ b/src/app/ForceFeedback.h @@ -108,7 +108,7 @@ class ForceFeedback { float m_bumpHeight = 0.0f, m_bumpWidth = 0.0f, m_speedSeen = 0.0f; float m_lastFriction = 0.0f, m_lastSpring = 0.0f, m_lastImpact = 0.0f; float m_elapsed = 0.0f; // datTimeManager::ElapsedTime - game::fx::Rand m_rand; // frand (MM2 shares one stream; inferred own) + game::fx::Rand m_rand{0x2Au}; // frand (MM2 shares one stream; inferred own) }; } // namespace mm2::app::controls diff --git a/src/app/GameInput.cpp b/src/app/GameInput.cpp index ae59279..c488d56 100644 --- a/src/app/GameInput.cpp +++ b/src/app/GameInput.cpp @@ -265,6 +265,51 @@ void GameInput::load(const IniFile& ini, const JoystickFrame& joy) { } } +void GameInput::setPlayerTune(const data::DatNode& n) { + // mmPlayer::FileIO's fields; a field the file lacks keeps the + // constructor's value. The discrete rates go to mmInput for both the + // keyboard and the game pad filter (mmPlayer::Update). + for (phys::SteeringFilter* f : {&m_keyFilter, &m_padFilter}) { + phys::SteeringFilter::Params& p = f->params; + n.read("SpeedSensitive", p.speedSensitive); + n.read("SpeedBaseLow", p.speedBaseLow); + n.read("SpeedBaseHi", p.speedBaseHi); + n.read("DiscreteSteeringDeltaOutLo", p.deltaOutLo); + n.read("DiscreteSteeringDeltaInLo", p.deltaInLo); + n.read("DiscreteSteeringFilterLo", p.filterLo); + n.read("DiscreteSteeringDeltaOutHi", p.deltaOutHi); + n.read("DiscreteSteeringDeltaInHi", p.deltaInHi); + n.read("DiscreteSteeringFilterHi", p.filterHi); + } + AnalogSteering& a = m_analog; + n.read("SpeedSensitive", a.speedSensitive); + n.read("SpeedBaseLow", a.speedBaseLow); + n.read("SpeedBaseHi", a.speedBaseHi); + n.read("MouseSensitivityLow", a.mouseSensitivityLow); + n.read("MouseSteerFilterLow", a.mouseFilterLow); + n.read("MouseSensitivityHi", a.mouseSensitivityHi); + n.read("MouseSteerFilterHi", a.mouseFilterHi); + // JoyApp / WheelApp are shorts: the approach is on when not 0. + const auto device = [&n](std::string_view prefix, AnalogSteering::DeviceParams& d) { + const std::string k(prefix); + n.read(k + "SensitivityLow", d.sensitivityLow); + n.read(k + "SteerFilterLow", d.filterLow); + n.read(k + "SensitivityHi", d.sensitivityHi); + n.read(k + "SteerFilterHi", d.filterHi); + int app = d.approach ? 1 : 0; + if (n.read(k + "App", app)) + d.approach = app != 0; + n.read(k + "SteerApproachOutLo", d.approachOutLow); + n.read(k + "SteerApproachInLo", d.approachInLow); + n.read(k + "SteerApproachOutHi", d.approachOutHi); + n.read(k + "SteerApproachInHi", d.approachInHi); + n.read(k + "SteerAppApp", d.approachGrowth); + }; + device("Joy", a.joystick); + device("Wheel", a.wheel); + // ScoreWeight is read too, and nothing uses it. +} + void GameInput::reset() { // mmInput::Reset. m_throttle = m_brakes = m_handbrake = 0.0f; diff --git a/src/app/GameInput.h b/src/app/GameInput.h index 239f56c..7bc3413 100644 --- a/src/app/GameInput.h +++ b/src/app/GameInput.h @@ -10,6 +10,7 @@ // first joystick is read the way DirectInput showed it to mmJoystick. #include "app/Controls.h" +#include "data/DatFile.h" #include "phys/vehicle/Controls.h" #include @@ -153,6 +154,16 @@ class GameInput { // mmPlayer::Update: the filters' parameters for the car's forward // speed (m/s), used from the next frame. void setSpeed(float speed); + // mmPlayer::Init loads the player node named after the car, which reads + // tune/.asnode (mmPlayer::FileIO: the speed bases, the keyboard and + // game pad rates, and the mouse, joystick and wheel curves) over + // mmPlayer's constructor values. Every retail car but vpcentury and vpdune + // ships one. + void setPlayerTune(const data::DatNode& node); + // The keyboard and game pad rates, and the analog devices' curves. + const phys::SteeringFilter::Params& discreteSteering() const { return m_keyFilter.params; } + const phys::SteeringFilter::Params& padSteering() const { return m_padFilter.params; } + const AnalogSteering& analogSteering() const { return m_analog; } // mmInput::GetCamPan: the joystick's POV hat (joystick controller), else // the look buttons (OpenMM2 may add its own look inputs). float camPan(bool left = false, bool right = false, bool back = false, bool forward = false) const; diff --git a/src/app/RaceScreen.cpp b/src/app/RaceScreen.cpp index 588d6f2..5c40cf3 100644 --- a/src/app/RaceScreen.cpp +++ b/src/app/RaceScreen.cpp @@ -177,11 +177,12 @@ class RaceScreen final : public Screen { loadStep(ctx); return; } - // GameLoop: AudManager::Update before the game's update, with the - // pause state the frame starts with (every sound stops while paused). + // GameLoop: AudManager::Update before the game's update, then again + // as asRoot's first node, with the pause state the frame starts with + // (every sound stops while paused). if (ctx.mixer) - m_audioManager.update(m_paused, *ctx.mixer, m_announcerOk ? &m_announcer : nullptr, - static_cast(dt)); + m_audioManager.updateFrame(m_paused, *ctx.mixer, m_announcerOk ? &m_announcer : nullptr, + static_cast(dt)); // mmInput::Update: the controller's bindings against the devices. { const auto size = ctx.window().size(); @@ -189,6 +190,12 @@ class RaceScreen final : public Screen { static_cast(size.width), static_cast(size.height)), static_cast(dt)); } + // mmReplayManager::Update, the next node of asRoot: a restart asked + // for last frame resets the whole game before anything of this frame + // updates (mmReplayManager::Reset -> mmGameManager -> the mode's + // Reset). + if (m_restartPending) + applyRestart(ctx); // mmPopup: Escape opens the main menu (pausing a single-player game, // mmPopup::ProcessEscape(1)); while it is up the game's keys are off. stepPopupScript(ctx); // OPENMM2_POPUP_SCRIPT automation (its keys reach the popup this frame) @@ -201,11 +208,9 @@ class RaceScreen final : public Screen { // MenuManager::ScanGlobalKeys: F4 in the popup sets the menu's state // to 6, which mmPopup::Update acts on in PUMain only: the race // restarts as with Restart Race (OpenMM2: not in network games). - if (m_popup == Popup::Main && ctx.input.keyPressed(platform::Key::F4) && m_session && !multiplayer(ctx)) { - closePopup(ctx, false); - m_resultsShown = false; - m_session->restart(); - } + if (m_popup == Popup::Main && ctx.input.keyPressed(platform::Key::F4) && m_session && + !multiplayer(ctx)) + restartFromMenu(ctx); if (m_popup != Popup::None) { updatePopup(ctx, dt); if (ctx.nextScreen) @@ -239,15 +244,35 @@ class RaceScreen final : public Screen { m_flyCamera = !m_flyCamera; if (!m_flyCamera && m_popup == Popup::None) updateGameInput(ctx); - if (m_paused) { - // asRoot paused (the full-screen map in single player): the - // game, the physics and the clocks stand still. mmGame::Update - // still updates the announcer's queue (mmSpeechContainer::Update). + // A restart asked for this frame: nothing of the old race moves on + // before the reset (the menu's Restart leaves the game paused until + // mmReplayManager has reset it). + if (m_paused || m_restartPending) { + // asRoot paused (the menu or the full-screen map in single + // player): the rules, the AI, the physics and the clocks stand + // still. mmGame::Update still updates the announcer's queue + // (mmSpeechContainer::Update) and runs its fall and water checks, + // and mmHUD::Update counts the message down. if (m_announcerOk) m_announcer.update(static_cast(dt)); + if (m_session && m_player && !m_restartPending) { + m_playerState = playerState(); + m_session->updatePaused(static_cast(dt), m_playerState); + handleSessionEvents(ctx); + } + // mmGameManager::Update runs cityLevel::Update (the sky turns, + // lvlSky::Update), cityLevel::PreDraw (the rain and snow, + // asParticles::Update, and the texture movies) and the camera + // (camViewCS::Update) whether or not the game is paused. if (m_flyCamera || !m_player) updateFlyCamera(ctx, static_cast(dt)); + else + updateCarCamera(ctx, static_cast(dt)); + if (m_weather) + m_weather->update(static_cast(dt), m_camera.transform); m_textures->update(m_time); + if (m_cityRenderer) + m_cityRenderer->update(m_frameDt); updateForceFeedback(ctx, static_cast(dt), true); return; } @@ -510,7 +535,7 @@ class RaceScreen final : public Screen { m_roadDecals.draw(dev, *m_textures); if (m_ai && m_aiRenderer) m_aiRenderer->draw(*m_ai, camera, frustum, m_result.config.timeOfDay, carLights(), m_detail.objects, - [this](int id) { return m_trafficBodies ? m_trafficBodies->transformOf(id) : nullptr; }); + [this](int id) { return physicalTrafficCar(id); }); drawRemoteCars(ctx, dt, camera); const bool night = m_result.config.timeOfDay == game::TimeOfDay::Night; if (m_bangers) @@ -557,6 +582,52 @@ class RaceScreen final : public Screen { m_weather->draw(dev, *m_textures, m_cards, camera.transform); } + // aiTrafficLightInstance is an unhit Y banger of its model's banger data + // (Init: the pole's base + R * CG, SetMatrix keeping the Y rotation; + // SetFourWay moves it to the room of its CG, GetPosition): a prop the + // cars collide with and knock over into its BREAKnn parts. AiRenderer + // draws it with its signal while it stands (BangerSet leaves it out). + void addTrafficLightProps() { + m_signalProps.clear(); + if (!m_ai || !m_bangers) + return; + for (const ai::Signal& s : m_ai->signals()) { + game::bangers::PlacedProp p; + p.model = s.model; + p.transform = s.transform; + p.room = m_cityLevel ? m_cityLevel->findRoom(s.position(), 0) : 0; + p.ownerDrawn = true; + m_signalProps.push_back(m_bangers->addOne(p)); + } + if (m_aiRenderer) + m_aiRenderer->setSignalFrames([this](int i) -> std::optional { + const auto& signals = m_ai->signals(); + const auto k = static_cast(i); + if (k >= m_signalProps.size() || !m_signalProps[k] || !m_bangers) + return k < signals.size() ? std::optional(signals[k].frame()) : std::nullopt; + const std::size_t prop = *m_signalProps[k]; + if (!m_bangers->standing(prop)) + return std::nullopt; + return m_bangers->instances()[prop].matrix; + }); + } + + // A traffic car TrafficBodies holds: where it is, and its wheels while + // it has a body (aiVehicleInstance::Draw). + std::optional physicalTrafficCar(int id) const { + 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)) { + car.active = true; + car.wheels = wheels->matrix; + car.wheelValid = wheels->valid; + } + return car; + } + // mmMirror::Cull: the rear-view mirror's inset at the top right, cleared // to black, the level drawn from the mirror's frame on the player's car // with its fixed-aspect projection, the winding swapped (the frame is @@ -639,17 +710,22 @@ class RaceScreen final : public Screen { m_loadPercent = 100; return; case 3: + loadWorldObjects(ctx); loadAi(ctx); m_loadPercent = 50; return; case 4: loadEffects(ctx); loadPedestrianProps(ctx); - // mmGame::Init: aiMap::Reset right after aiMap::Init. + // mmGame::Init: aiMap::Reset right after aiMap::Init (its + // population waits for the first step, except where the cruise + // start needs it: placeRespawnStart). if (m_ai) m_ai->reset(); spawnOpponents(ctx); spawnPolice(ctx); + // Then the mode's InitOtherPlayers. + placeRespawnStart(ctx); m_loadPercent = 100; return; default: loadFinish(ctx); return; @@ -895,6 +971,13 @@ class RaceScreen final : public Screen { m_cams.load(ctx.game->vfs, m_result.config.vehicle, &missing, aspect); // mmMirror::Init: the defaults, then tune/.mmmirror. m_mirror.load(ctx.game->vfs, m_result.config.vehicle); + // mmPlayer::Init: the player node, named after the car, loads + // tune/.asnode (its steering tuning, mmPlayer::FileIO). + if (auto bytes = ctx.game->vfs.readAll("tune/" + str::lower(m_result.config.vehicle) + ".asnode")) { + const std::string_view text(reinterpret_cast(bytes->data()), bytes->size()); + if (auto f = data::parseDat(text); f && f->top()) + m_gameInput.setPlayerTune(*f->top()); + } if (const auto* info = ctx.game->catalog.vehicle(m_result.config.vehicle)) m_cams.setVehicleFlags(static_cast(info->flags)); for (const auto& m : missing) @@ -913,6 +996,40 @@ class RaceScreen final : public Screen { m_yaw = heading; } + // mmSingleRoam / mmGameMulti::InitOtherPlayers (cruise, Cops and + // Robbers): the car moves from its InitGameObjects place to + // mmGame::RespawnXYZ's start, SetResetPos and vehCar::Reset, and stays + // there 2 m above the intersection. The single-player start is drawn from + // MM2's one random stream (m_random) as mmGame::Init's aiMap::Reset left + // it: set to 1, then the traffic and pedestrians placed round the car's + // InitGameObjects place (ai::World::resetAndPopulate). mmGame::Reset's + // aiMap::Reset then sets it to 1 again, and the first step places them + // round the start. + void placeRespawnStart(Context& ctx) { + if (!m_session || !m_player || !m_session->setup().respawnStart || std::getenv("OPENMM2_DEBUG_SPAWN")) + return; + std::uint32_t seed = 1; + if (m_ai && !multiplayer(ctx)) { + m_ai->resetAndPopulate(m_player->sim().resetPos()); + seed = m_random.state(); + } + const auto pick = m_session->placeRespawnStart(*m_city, seed, [this](const Vec3& p) { + return m_cityRenderer ? m_cityRenderer->roomAt(p) : 0; + }); + if (m_ai) + m_ai->reset(); // mmGame::Reset (mmGameManager::Reset) + if (!pick) + 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); + 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; + } + void createSession(Context& ctx) { std::string error; game::session::SessionOptions opts; @@ -920,8 +1037,9 @@ class RaceScreen final : public Screen { opts.scoringBias = info->scoringBias; opts.playerName = ctx.settings.playerName; opts.netHost = multiplayer(ctx) && ctx.netGame->isHost(); - // mmMultiRoam: RespawnXYZ draws the cruise start from a random stream - // seeded with the player's id, so every player starts elsewhere. + // mmMultiRoam / mmMultiCR: RespawnXYZ draws the start from a random + // stream seeded with the player's id (MM2: its DirectPlay id), so + // every player starts elsewhere. if (multiplayer(ctx)) opts.seed = 1u + ctx.netGame->localId(); // Crash course pursuit checks look through the level (the world is @@ -1033,8 +1151,12 @@ class RaceScreen final : public Screen { if (!m_session || !m_world) return; const auto& setups = m_session->opponents(); + // Each racer's vehCar::Init drew on MM2's global stream in turn + // (loadAi): its siren flares come from there. + game::fx::Rand initDraws(m_racerInitState); for (std::size_t i = 0; i < setups.size(); ++i) { const auto& s = setups[i]; + const std::uint32_t flares = game::takeVehCarInitDraws(initDraws); Opponent opp; opp.sessionIndex = i; // aiRouteRacer::Init places the racer on its first .opp row. @@ -1053,6 +1175,7 @@ class RaceScreen final : public Screen { // id (its index) & 3. opp.renderer = std::make_unique(ctx.device(), *m_textures, *m_models, opp.sim->model(), static_cast(i) & 3); + opp.renderer->setSirenFlares(flares); setupVehicleRenderer(ctx, *opp.renderer); opp.audio = loadAiCarAudio(ctx, s.vehicle, false); opp.fx = loadVehicleFx(ctx, s.vehicle, opp.sim->model(), *opp.renderer); @@ -1107,8 +1230,13 @@ class RaceScreen final : public Screen { // The game's room flags (lvlRoomInfo): a cop in a "water of death" // room drops out (aiPoliceOfficer::Update). m_police->setRoomFlags(m_city->levelRoomFlags); + // Each police car's vehCar::Init drew on MM2's global stream in turn + // at the end of aiMap::Init (loadEffects): its siren flares come + // from there. + game::fx::Rand initDraws(m_policeInitState); for (std::size_t i = 0; i < count; ++i) { const auto& p = posts[i]; + const std::uint32_t flares = game::takeVehCarInitDraws(initDraws); Cop cop; Mat34 post = p.spawn; // aiVehiclePhysics::Init: vehCar::Init(), the car's own tune @@ -1130,6 +1258,7 @@ class RaceScreen final : public Screen { livery = str::iequals(m_result.config.city, "sf") ? 0 : 1; cop.renderer = std::make_unique(ctx.device(), *m_textures, *m_models, cop.sim->model(), livery); + cop.renderer->setSirenFlares(flares); setupVehicleRenderer(ctx, *cop.renderer); cop.audio = loadAiCarAudio(ctx, p.vehicle, true); cop.fx = loadVehicleFx(ctx, p.vehicle, cop.sim->model(), *cop.renderer); @@ -1517,6 +1646,58 @@ class RaceScreen final : public Screen { if (phaseBefore != game::session::Phase::PostRace && m_session->phase() == game::session::Phase::PostRace && m_result.config.mode != game::GameMode::Cruise && m_session->postRaceCamera()) startFinishCamera(ctx); + handleSessionEvents(ctx); + if (auto* music = ctx.music(); music && m_musicDirector) { + using game::session::Phase; + auto& director = *m_musicDirector; + const Phase phase = m_session->phase(); + if (phase != Phase::Countdown) + director.raceStarted(); + if (phase == Phase::PostRace && !m_musicFinished) { + // The single-player race modes stop the music at the finish + // (StopSegment(0)), a wreck with an ending on the next beat + // (StopSegment(1)); the water, a late Blitz, the crash course + // and the multiplayer modes leave it playing. + if (m_session->damagedOut()) + director.damagedOut(); + else if (m_session->musicStopped()) + director.finish(); + m_musicFinished = true; + } + // mmPopup::ShowResults (a finish); a loss opens the main menu, + // whose pause music openPopup starts. + if (phase == Phase::Done && !m_musicResults && m_session->raceOver()) { + director.results(); + m_musicResults = true; + } + director.update(dt, m_player->sim().speed(), audio::game::SirenPlayer::copsPursuingPlayer(), + m_carAudio.airborne()); + for (const auto& c : director.takeCommands()) + music->setState(c.state, c.timing); + if (director.takeBigAir()) + music->triggerBigAir(); + } + if (m_session->finished() && !m_resultsShown) { + m_resultsShown = true; + m_result = m_session->result(); + // A lost race or lesson (state 4) opens the main menu without + // pausing (mmPopup::ProcessEscape(0)); a finished one shows the + // results (mmPopup::ShowResults), which OpenMM2 shows as the + // first page of the menus. + if (!m_result.finished && !multiplayer(ctx)) + openPopup(ctx, false); + else + leaveRace(ctx, m_result); + } + } + + // The session's events: what the rules ask of the race (the cameras, the + // cars, sounds and speech, the network). updateSession hands them over + // after the rules, applyRestart after a restart, the paused frame after + // its checks. + void handleSessionEvents(Context& ctx) { + if (!m_session || !m_player) + return; for (const auto& e : m_session->takeEvents()) { using game::session::EventType; if (e.type == EventType::HitWater) @@ -1646,48 +1827,6 @@ class RaceScreen final : public Screen { // aiRouteRacer::Finished (OpponentState::finished), and the car // drives on to its destination. } - if (auto* music = ctx.music(); music && m_musicDirector) { - using game::session::Phase; - auto& director = *m_musicDirector; - const Phase phase = m_session->phase(); - if (phase != Phase::Countdown) - director.raceStarted(); - if (phase == Phase::PostRace && !m_musicFinished) { - // The single-player race modes stop the music at the finish - // (StopSegment(0)), a wreck with an ending on the next beat - // (StopSegment(1)); the water, a late Blitz, the crash course - // and the multiplayer modes leave it playing. - if (m_session->damagedOut()) - director.damagedOut(); - else if (m_session->musicStopped()) - director.finish(); - m_musicFinished = true; - } - // mmPopup::ShowResults (a finish); a loss opens the main menu, - // whose pause music openPopup starts. - if (phase == Phase::Done && !m_musicResults && m_session->raceOver()) { - director.results(); - m_musicResults = true; - } - director.update(dt, m_player->sim().speed(), audio::game::SirenPlayer::copsPursuingPlayer(), - m_carAudio.airborne()); - for (const auto& c : director.takeCommands()) - music->setState(c.state, c.timing); - if (director.takeBigAir()) - music->triggerBigAir(); - } - if (m_session->finished() && !m_resultsShown) { - m_resultsShown = true; - m_result = m_session->result(); - // A lost race or lesson (state 4) opens the main menu without - // pausing (mmPopup::ProcessEscape(0)); a finished one shows the - // results (mmPopup::ShowResults), which OpenMM2 shows as the - // first page of the menus. - if (!m_result.finished && !multiplayer(ctx)) - openPopup(ctx, false); - else - leaveRace(ctx, m_result); - } } // The camera at the end of a race: mmPlayer::SetPostRaceCam, or in a @@ -1912,21 +2051,19 @@ class RaceScreen final : public Screen { st.seed = std::max(1u, ctx.netGame->raceStartTime()); m_crRng = st.seed; // GetRandomPoints' picker: mmGame::RespawnXYZ(false, false, false) - // less its 2 m, which still never takes an intersection in a - // water-of-death or terrain-instance room (level flags 0x24). + // less its 2 m: an intersection whose room (FindRoomId of its + // centre) is neither water of death nor a terrain instance's (level + // flags 0x24). MM2's host draws it from the one global stream as its + // frames left it, respawnCounter() + 1 numbers a pick; every OpenMM2 + // machine draws the first set itself from the shared start time, one + // number a pick, since the counter is each machine's own. st.randomIntersection = [this]() -> std::optional { - if (!m_city->aiMap || m_city->aiMap->intersections.size() < 2) + const auto pick = game::session::respawnXYZ( + *m_city, [this](const Vec3& p) { return m_cityRenderer ? m_cityRenderer->roomAt(p) : 0; }, {}, + m_crRng, 1); + if (!pick) return std::nullopt; - const auto& xs = m_city->aiMap->intersections; - const auto& flags = m_city->levelRoomFlags; - constexpr std::uint16_t kRejected = city::LevelRoomFlag::WaterOfDeath | city::LevelRoomFlag::TerrainInstance; - for (int attempt = 0; attempt < 1000; ++attempt) { - m_crRng = m_crRng * 1103515245u + 12345u; - const auto& x = xs[1 + ((m_crRng >> 16) & 0x7fffu) % (xs.size() - 1)]; - if (x.room >= flags.size() || !(flags[x.room] & kRejected)) - return x.center; - } - return xs[1].center; + return pick->position - Vec3{0.0f, 2.0f, 0.0f}; }; // mmMultiCR::DropGold: aiMap::PositionToAIMapComp always answers for // a room, so the gold stays where it fell unless that room is deep @@ -2210,8 +2347,10 @@ class RaceScreen final : public Screen { using platform::Key; const auto& in = ctx.input; if (in.keyPressed(Key::F4) && m_session && !multiplayer(ctx)) { - m_resultsShown = false; - m_session->restart(); + // asRoot::Reset ends a pause at once (the full-screen map's; the + // map stays up), then the reset waits for the next frame. + m_paused = false; + requestRestart(); return; } if (in.keyPressed(Key::F6) && multiplayer(ctx)) { @@ -2227,6 +2366,38 @@ class RaceScreen final : public Screen { openChat(ctx); } + // mmReplayManager's reset flag (+0x19), which Restart Race, F4 in the + // menu and F4 in the game set: mmReplayManager::Update acts on it at the + // start of the next frame. + void requestRestart() { + m_restartPending = true; + m_resultsShown = false; + } + + // PUMain's Restart (and F4 over it, mmPopup::Update): the reset flag and + // DisablePU(0), the menu closed without the return music (mmGame::Reset + // starts the music again). + void restartFromMenu(Context& ctx) { + closePopup(ctx, false); + requestRestart(); + } + + // mmReplayManager::Update with the reset flag: mmReplayManager::Reset + // (the seed back to 1) and the tree's Reset, which reaches the mode's + // Reset: the session and every object of the race back to the start + // before anything of this frame updates. mmGame::Reset's aiMap::Reset + // sets MM2's stream (m_random) to 1 again (ai::World::reset, once per + // restart) and the next AI step places the traffic and pedestrians from + // it round the start; OpenMM2's other streams reseed in their own + // resets. + void applyRestart(Context& ctx) { + m_restartPending = false; + if (!m_session) + return; + m_session->restart(); + handleSessionEvents(ctx); + } + // MenuManager::Switch from one page of the open popup to another. void switchPopup(Context& ctx, Popup page) { m_popup = page; @@ -2408,14 +2579,8 @@ class RaceScreen final : public Screen { // PUMenuBase::DisableExit while locked. resume.enabled = !locked; auto& restart = button(0.0f, 0.125f, 1.0f, 0.1f, - crash ? s.get(655, "Restart Lesson") : s.get(464, "Restart Race"), 2, [this, &ctx] { - // mmReplayManager's reset flag: the race starts over - // (DisablePU(0); mmGame::Reset starts the music again). - closePopup(ctx, false); - m_resultsShown = false; - if (m_session) - m_session->restart(); - }); + crash ? s.get(655, "Restart Lesson") : s.get(464, "Restart Race"), 2, + [this, &ctx] { restartFromMenu(ctx); }); // PUMain::RestartRO: no restart in a network game. restart.enabled = !net; // mmPopup::Update, PUMain id 0xb: the OPTIONS pages (menu 5). @@ -2623,6 +2788,37 @@ class RaceScreen final : public Screen { ov.end(); } + // The street props and the gizmos, with the start of MM2's global random + // stream in mmGame::Init's order: cityLevel::Load places the street + // props, setting the seed to 1 before every road, so the stream is left + // as the last road's walk leaves it; mmPlayer::Init's vehCar::Init then + // draws for the car's siren flares and splash; mmGame::InitGizmos for the + // sailboats, ferries and parked cars. aiMap::Init draws next (loadAi). + void loadWorldObjects(Context& ctx) { + // Inferred: without the propulator's files the stream would be + // whatever the menus left it at; OpenMM2 takes 1. + std::uint32_t state = 1; + if (m_world) { + m_bangers = std::make_unique(*m_bangerData); + // With the race's own props (race//.pathset). + m_bangers->add(game::bangers::placeCityProps( + *m_city, ctx.game->vfs, *m_bangerData, + game::bangers::racePropsName(m_result.config.mode, m_result.config.raceIndex), &state)); + } + m_random.seed(state); + // mmPlayer::Init -> vehCar::Init (the trailer, a vehTrailer, draws + // nothing). + const std::uint32_t playerFlares = game::takeVehCarInitDraws(m_random); + if (m_vehicle) + m_vehicle->setSirenFlares(playerFlares); + if (m_world) { + // mmGame::InitGizmos: sailboats, drawbridges, tube trains, + // ferries, and the parked cars (props) along the streets. + m_gizmos = game::world::initGizmos(ctx.game->vfs, *m_city, m_result.config, multiplayer(ctx), + *m_bangers, *m_bangerData, m_cityLevel.get(), m_random); + } + } + void loadAi(Context& ctx) { const auto mode = m_result.config.mode; // mmMultiBlitz / mmMultiCircuit / mmMultiRace::Init clear mmGame +0x277 @@ -2656,6 +2852,13 @@ class RaceScreen final : public Screen { if (m_result.config.mode == game::GameMode::Circuit) settings.pedestrianDensity = 0.0f; settings.winterPeds = m_result.config.weather == game::Weather::Snow; + // aiMap::Init draws on MM2's global stream: the racers' cars first + // (aiRouteRacer::Init -> vehCar::Init; spawnOpponents makes them), + // then the ambient pool and the pedestrians (ai::World::create). + m_racerInitState = m_random.state(); + if (m_session) + m_random.discard(static_cast(m_session->opponents().size()) * game::kVehCarInitDraws); + settings.random = &m_random; std::string error; const city::AiMapConfig* raceMap = m_session && m_session->setup().aiMap ? &*m_session->setup().aiMap : nullptr; @@ -2668,6 +2871,14 @@ class RaceScreen final : public Screen { m_aiRenderer = std::make_unique(ctx.device(), *m_textures, *m_models, ctx.game->vfs); 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) { + phys::RayHit hit; + if (!m_world || !m_world->probe(from, to, hit)) + return false; + point = hit.position; + normal = hit.normal; + return true; + }); if (m_world) { m_trafficBodies = std::make_unique(*m_ai, *m_world); m_trafficBodies->setWeatherFriction(weatherFriction()); @@ -2755,25 +2966,19 @@ class RaceScreen final : public Screen { if (auto bytes = ctx.game->vfs.readAll("city/" + str::lower(m_city->info.mapName) + "/decals.pathset")) if (auto set = city::parsePathSet(*bytes)) m_roadDecals.load(*set); - if (m_world) { - m_bangers = std::make_unique(*m_bangerData); - // With the race's own props (race//.pathset). - m_bangers->add(game::bangers::placeCityProps( - *m_city, ctx.game->vfs, *m_bangerData, - game::bangers::racePropsName(m_result.config.mode, m_result.config.raceIndex))); - // mmGame::InitGizmos: sailboats, drawbridges, tube trains, - // ferries, and the parked cars (props) along the streets. - m_gizmos = game::world::initGizmos(ctx.game->vfs, *m_city, m_result.config, multiplayer(ctx), - *m_bangers, *m_bangerData, m_cityLevel.get(), m_gizmoRand); + if (m_world && m_bangers && m_gizmos) { if (m_cityLevel) m_cityLevel->addSource(m_gizmos.get()); if (m_bank && ctx.mixer) m_gizmos->loadAudio(ctx.game->vfs, *m_bank, *ctx.mixer, &m_audioSlots); + // aiMap::Init: the traffic lights (aiTrafficLightSet::SetFourWay). + addTrafficLightProps(); // aiMap::Init: the cable cars, unless the network game cleared - // the state pack's EnableCableCars (mmGameMulti::Init). + // the state pack's EnableCableCars (mmGameMulti::Init); their + // draws follow the pedestrians'. if (m_ai && !multiplayer(ctx)) { m_cableCars = std::make_unique(*m_ai, *m_bangerData, *m_bangers); - m_cableCars->create(m_gizmoRand); + m_cableCars->create(m_random); m_cableCars->reset(*m_world); if (m_cityLevel) m_cityLevel->addSource(m_cableCars.get()); @@ -2785,6 +2990,10 @@ class RaceScreen final : public Screen { m_cityLevel->addSource(m_bangers.get()); m_bangers->setWorld(m_world.get()); } + // aiMap::Init ends with the police cars (aiPoliceOfficer::Init -> + // vehCar::Init; spawnPolice), after the subways (none in retail + // data: both cities' [Subway] is commented out). + m_policeInitState = m_random.state(); if (m_player && m_vehicle) { m_vehicleFx = loadVehicleFx(ctx, m_result.config.vehicle, m_player->model(), *m_vehicle); m_player->sim().onImpactCallback = [this](const phys::CarImpact& impact) { playerImpact(impact); }; @@ -3067,7 +3276,7 @@ class RaceScreen final : public Screen { updateAmbientAudio(ctx, dt); updatePedestrianAudio(dt); // mmGame::Update's mmSpeechContainer::Update (AudSpeech::Update); the - // frame's first one was AudManager::Update's (m_audioManager). + // frame's first two were AudManager::Update's (m_audioManager). if (m_announcerOk) m_announcer.update(dt); m_ambience.update(m_camera.transform, dt, m_tunnel); @@ -3215,8 +3424,9 @@ class RaceScreen final : public Screen { // mmReplayManager::Update reads mmInput::GetThrottle / GetBrakes / // GetSteering(playerFilterSteering) / GetHandBrake (the pedal swap // is ArcadeControls'); the steering filters use the parameters - // mmPlayer::Update set from the last frame's speed. - if (!m_flyCamera && m_popup == Popup::None) { + // mmPlayer::Update set from the last frame's speed. It records them + // in every frame the game runs, the menu up or not. + if (!m_flyCamera) { pedals.accelerator = m_gameInput.throttle(); pedals.brake = m_gameInput.brakes(); pedals.handbrake = m_gameInput.handBrake(); @@ -3237,6 +3447,14 @@ class RaceScreen final : public Screen { // ... and records them, and the car is driven with the recorded // values (bytes). pedals = controls::replayQuantize(pedals); + // mmGame::Update with the menu up (mmPopup enabled, the game not + // paused: a network game, a lost race, the chat line): the game's + // keys are off, but mmGame::UpdateSteeringBrakes still gives the car + // the recorded inputs for every controller but the mouse (inputDevice + // 0), whose car keeps the inputs it had. + if (m_popup != Popup::None && m_gameInput.controller() == controls::Controller::Mouse) + pedals = m_carPedals; + m_carPedals = pedals; m_steerApplied = pedals.steering; // mmPlayer::SetSteering: +0x2264 m_gameInput.setSpeed(m_player->sim().speed()); // Countdown: the car is held until "Go!" (and during wreck @@ -3282,15 +3500,17 @@ class RaceScreen final : public Screen { m_ff.update(controls::ffCarState(m_player->sim()), dt, paused); } - // The horn (mmGame::UpdateHorn: the slot's held bit), not in the free - // camera. - bool hornDown(Context&) const { return !m_flyCamera && m_gameInput.held(controls::Action::Horn); } + // The horn as mmGame::UpdateHorn last set it (+0x274, the slot's held + // bit), not in the free camera. + bool hornDown(Context&) const { return !m_flyCamera && m_hornHeld; } // mmGame::UpdateGameInput: the discrete in-race keys, handled while the // game is paused too. void updateGameInput(Context& ctx) { using controls::Action; - const auto& in = ctx.input; + // mmGame::UpdateHorn opens UpdateGameInput: with the menu up neither + // runs and the horn stays as it was (playing on in a running game). + m_hornHeld = m_gameInput.held(Action::Horn); auto pressed = [&](Action a) { return m_gameInput.fired(a); }; bool viewChanged = false; if (m_hud) { @@ -3325,17 +3545,15 @@ class RaceScreen final : public Screen { viewChanged = true; } } - // While paused, mmGame::UpdatePaused also takes the C and V keys - // themselves (key events 0x2E and 0x2F), whatever the two camera - // actions are bound to. - auto pausedKey = [&](Action a, platform::Key k) { - return m_paused && m_gameInput.binding(a) != controls::Binding::key(k) && in.keyPressed(k); - }; - if (pressed(Action::ChangeCamera) || pausedKey(Action::ChangeCamera, platform::Key::C)) + // A paused game takes these keys too: mmGame::Update runs while + // asRoot is paused. (mmGame::UpdatePaused, which would take the C and + // V keys themselves, is never called: nothing reaches the game's + // UpdatePaused slot in build 3393.) + if (pressed(Action::ChangeCamera)) m_cams.toggleCamera(); // mmViewMgr::SetViewSetting(2), input event 0x0C (Thrill Cam): the // XCam, orbiting the car under the keyboard (CameraInput::orbit). - if (pressed(Action::ThrillCam) || pausedKey(Action::ThrillCam, platform::Key::V)) + if (pressed(Action::ThrillCam)) m_cams.toggleXCam(); if (pressed(Action::WideAngle)) m_cams.toggleWideAngle(); @@ -3652,6 +3870,12 @@ class RaceScreen final : public Screen { // The game is paused (asRoot): the full-screen map or the popup in // single player. bool m_paused = false; + // mmReplayManager's reset flag (+0x19): the race starts over at the + // start of the next frame (applyRestart). + bool m_restartPending = false; + // What mmGame::UpdateSteeringBrakes last gave the car (the recorded + // inputs); with the menu up and the mouse controller the car keeps them. + phys::PedalInput m_carPedals; // The in-race popup (mmPopup). Options: one of the pages // frontend::PopupOptions builds (m_popupPage). Popup m_popup = Popup::None; @@ -3664,6 +3888,7 @@ class RaceScreen final : public Screen { ui::NavReader m_nav; bool m_popupPaused = false; float m_camPan = 0.0f; // mmInput::GetCamPan, kept at mmPlayer +0x1D6C + bool m_hornHeld = false; // mmGame +0x274: the horn as UpdateHorn last set it game::session::MapMode m_hudMapBeforeFull = game::session::MapMode::Off; game::PlayerCameras m_cams; // The rear-view mirror's camera (camera-props); drawMirror draws it. Its @@ -3699,6 +3924,8 @@ class RaceScreen final : public Screen { std::string m_voiceCategory; int m_voiceFileNum = -1; std::vector m_trafficImpacts; + // The BangerSet instance of each traffic light (ai::World::signals()). + std::vector> m_signalProps; struct Opponent { std::size_t sessionIndex = 0; // in Session::opponents() (cars that fail to load are skipped) Mat34 spawn; // its start (the car's reset place keeps the settled one) @@ -3735,11 +3962,16 @@ class RaceScreen final : public Screen { game::bangers::RoadDecals m_roadDecals; std::optional m_sparkColors; game::fx::Rand m_ejectRand{0xB4EAu}; - // The gizmos (src/game/world) and the irand / frand of their loading - // (MM2's global rand(); its seed here is OpenMM2's). + // The gizmos (src/game/world). std::unique_ptr m_gizmos; std::unique_ptr m_cableCars; - game::fx::Rand m_gizmoRand{1u}; + // MM2's global irand / frand stream (gRandSeed) through the race's + // set-up, in mmGame::Init's order (loadWorldObjects, loadAi, + // loadEffects; docs/parity/round3/random-streams.md), then the AI + // world's (ai::Settings::random), which aiMap::Reset sets back to 1. + ai::Random m_random{1u}; + std::uint32_t m_racerInitState = 1; // the stream at the racers' vehCar::Init + std::uint32_t m_policeInitState = 1; // the stream at the police cars' vehCar::Init game::fx::EffectLibrary m_effects; std::unique_ptr m_vehicleFx; std::unique_ptr m_weather; diff --git a/src/app/frontend/FrontendScreen.cpp b/src/app/frontend/FrontendScreen.cpp index a75148f..e7f0eb8 100644 --- a/src/app/frontend/FrontendScreen.cpp +++ b/src/app/frontend/FrontendScreen.cpp @@ -675,7 +675,8 @@ std::string formatTime(float seconds) { // "profile:Test;page:races;wait:5;nav:down;nav:right" // Commands: profile: (create/select), page:, nav:, wait:, mode:, -// city:, vehicle:, result: (opens the results screen), +// city:, vehicle:, time:, +// result: (opens the results screen), // mp: (multiplayer). class Script { public: @@ -739,6 +740,12 @@ void Script::run(Frontend& fe, const std::string& cmd, const std::string& arg) { fe.applyRaceDefaults(fe.config); } else if (cmd == "vehicle") { fe.config.vehicle = arg; + } else if (cmd == "time") { + // morning, noon, evening or night (after mode/city, which reset it). + static constexpr std::string_view kTimes[] = {"morning", "noon", "evening", "night"}; + for (std::size_t k = 0; k < std::size(kTimes); ++k) + if (arg == kTimes[k]) + fe.config.timeOfDay = static_cast(k); } else if (cmd == "go") { fe.startRace(); } else if (cmd == "mp") { diff --git a/src/audio/game/AudioManager.cpp b/src/audio/game/AudioManager.cpp index 5ad33f6..657854c 100644 --- a/src/audio/game/AudioManager.cpp +++ b/src/audio/game/AudioManager.cpp @@ -29,4 +29,12 @@ bool AudioManager::update(bool paused, Mixer& mixer, Announcer* speech, float dt return false; } +bool AudioManager::updateFrame(bool paused, Mixer& mixer, Announcer* speech, float dt) { + // GameLoop: AudManager::Update, then asRoot::Update, whose first node is + // the same manager. + const bool first = update(paused, mixer, speech, dt); + const bool second = update(paused, mixer, speech, dt); + return first || second; +} + } // namespace mm2::audio::game diff --git a/src/audio/game/AudioManager.h b/src/audio/game/AudioManager.h index ae4db11..1d5ede3 100644 --- a/src/audio/game/AudioManager.h +++ b/src/audio/game/AudioManager.h @@ -1,22 +1,23 @@ #pragma once -// What MM2's audio manager does once a frame on its own, outside the sound -// objects: AudManager::Update, which GameLoop calls every frame before the -// game updates (asRoot::Update), with AudManagerBase::UpdatePaused while the -// game is paused. +// What MM2's audio manager does every frame on its own, outside the sound +// objects: AudManager::Update, with AudManagerBase::UpdatePaused while the +// game is paused. It runs twice a frame: GameLoop calls it before the game +// updates, and asRoot::Update calls it again as asRoot's first node +// (BeginPhase adds the manager to asRoot). // // * While the game is paused (the popup menu, the full-screen map) every wave // sound stops, on the first two paused updates (UpdatePaused stops all -// sounds while its counter is at most 1, AudManagerBase +0x1e). Loops start -// again from the beginning when the game runs on (their owners' updates -// play them when they are not playing); one-shots and the announcer's line -// are cut. The music is DirectMusic, not one of the manager's sounds, and -// plays on. +// sounds while its counter is at most 1, AudManagerBase +0x1e): both in the +// first paused frame. Loops start again from the beginning when the game +// runs on (their owners' updates play them when they are not playing); +// one-shots and the announcer's line are cut. The music is DirectMusic, not +// one of the manager's sounds, and plays on. // * While the game runs the announcer's queue is updated here -// (mmSpeechContainer::Update), and mmGame::Update updates it again later in -// the same frame, so its delays count down twice as fast as the clock. While -// the game is paused only mmGame::Update's call is left: queued lines keep -// counting down and can start (after the two stops). +// (mmSpeechContainer::Update), twice, and mmGame::Update updates it again +// later in the same frame, so its delays count down three times as fast as +// the clock. While the game is paused only mmGame::Update's call is left: +// queued lines keep counting down and can start (after the two stops). namespace mm2::audio { class Mixer; @@ -38,6 +39,9 @@ class AudioManager { // whether every sound was stopped (audManager::StopAllSounds; OpenMM2's // Mixer::stopAll, which leaves the music streams alone). bool update(bool paused, Mixer& mixer, Announcer* speech, float dt); + // One frame's AudManager::Update calls: GameLoop's, then asRoot's. + // Returns whether either stopped every sound. + bool updateFrame(bool paused, Mixer& mixer, Announcer* speech, float dt); // The paused updates counted so far (AudManagerBase +0x1e); 0 while the // game runs. diff --git a/src/city/AiMap.cpp b/src/city/AiMap.cpp index 8802300..f15a87b 100644 --- a/src/city/AiMap.cpp +++ b/src/city/AiMap.cpp @@ -1,6 +1,7 @@ #include "city/AiMap.h" #include "city/Reader.h" +#include "city/SdlDraw.h" #include #include @@ -175,16 +176,22 @@ std::optional> parseShortcutBai(std::span d return paths; } -void addShortcuts(AiMap& map, std::vector shortcuts) { - // aiIntersection::CreateRoadMap: the list sorted by atan2(dx, dz) of each - // road's far-from-centre end (its last centre vertex when the road's - // end 0 is here, else its first), smallest first (a selection sort, - // swapping on strictly smaller keys); then every listed road's index at - // that end (its first place in the list; end 0 when that is here). +void addShortcuts(AiMap& map, std::vector shortcuts, const Psdl* psdl) { + // aiIntersection::CreateRoadMap: the centre moved to the bound-sphere + // centre of the intersection's room, then the list sorted by atan2(dx, + // dz) of each road's far-from-centre end (its last centre vertex when the + // road's end 0 is here, else its first), smallest first (a selection + // sort, swapping on strictly smaller keys); then every listed road's + // index at that end (its first place in the list; end 0 when that is + // here). auto createRoadMap = [&](std::uint32_t node) { if (node >= map.intersections.size()) return; AiIntersection& in = map.intersections[node]; + if (psdl) { + float radius = 0.0f; + sdlRoomBoundSphere(*psdl, in.room, in.center, radius); + } const std::size_t n = in.paths.size(); std::vector key(n, 0.0f); for (std::size_t i = 0; i < n; ++i) { diff --git a/src/city/AiMap.h b/src/city/AiMap.h index b466ab0..9714b6c 100644 --- a/src/city/AiMap.h +++ b/src/city/AiMap.h @@ -6,6 +6,7 @@ // file parses to its last byte); several field meanings are inferred and named // after mm2hook's in-memory aiPath layout where it matches. +#include "city/Psdl.h" #include "core/Math.h" #include @@ -110,11 +111,11 @@ std::optional> parseShortcutBai(std::span d // cars and pedestrians (road type 3), and joins its end intersections' path // lists (aiIntersection::AddRoad), which are then sorted by the direction of // each road from the intersection's centre and give every listed path its -// index there again (aiIntersection::CreateRoadMap). MM2 first moves the -// centre to the bound-sphere centre of the intersection's room; OpenMM2 keeps -// the file's centre (inferred: the main files' lists are already sorted -// round it). -void addShortcuts(AiMap& map, std::vector shortcuts); +// index there again (aiIntersection::CreateRoadMap). CreateRoadMap first +// moves the centre to the bound-sphere centre of the intersection's room +// (cityLevel::GetBoundSphere: sdlRoomBoundSphere of `psdl`; the files' own +// centres are within 0.21 m of it); without `psdl` the file's centre stays. +void addShortcuts(AiMap& map, std::vector shortcuts, const Psdl* psdl = nullptr); std::vector validateAiMap(const AiMap& map, std::size_t roomCount); diff --git a/src/city/CityData.cpp b/src/city/CityData.cpp index 4942040..ff0d2ae 100644 --- a/src/city/CityData.cpp +++ b/src/city/CityData.cpp @@ -166,7 +166,7 @@ std::optional loadCity(const vfs::Vfs& v, std::string_view city, std:: if (c.aiMap) { if (auto b = bytesOf(cityDir + "_sup.bai", false)) { if (auto shortcuts = parseShortcutBai(*b, &err)) - addShortcuts(*c.aiMap, std::move(*shortcuts)); + addShortcuts(*c.aiMap, std::move(*shortcuts), &c.psdl); else warn(std::format("{}_sup.bai: {}", cityDir, err)); } diff --git a/src/game/AiRenderer.cpp b/src/game/AiRenderer.cpp index 82e6cf5..50fba45 100644 --- a/src/game/AiRenderer.cpp +++ b/src/game/AiRenderer.cpp @@ -13,6 +13,18 @@ namespace mm2::game { +Vec3 skeletonWidthAxis(const Mat34& ped, const Mat34& camera) { + // pedAnimation::DrawSkeleton offsets each quad's corners in the + // pedestrian's own space (the world matrix is the pedestrian's) by the + // first row of gfxRenderState's modelview matrix: the pedestrian's X axis + // in view space, the view matrix being the camera's inverse with z + // negated (sm_FullComposite). For a pedestrian and a camera turned only + // about Y that is the camera's right axis; a pitched camera tilts it. + const Vec3& x = ped.m0; + const Vec3 local{x.dot(camera.m0), x.dot(camera.m1), -x.dot(camera.m2)}; + return ped.transformDir(local); +} + AiRenderer::AiRenderer(render::Device& device, TextureLibrary& textures, ModelLibrary& models, const vfs::Vfs& vfs) : m_device(device), m_textures(textures), m_models(models), m_vfs(vfs) {} @@ -110,9 +122,9 @@ 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) { // 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 the camera's right axis - // (the start and end half widths), in the colour the variant's row - // picks from its shaders' diffuse colours; untextured, unlit, both sides. + // use), a quad to its parent's position across skeletonWidthAxis (the + // start and end half widths), in the colour the variant's row picks from + // its shaders' diffuse colours; untextured, unlit, both sides. if (!type.rays) return; const auto& rays = *type.rays; @@ -121,7 +133,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 = camera.transform.m0; + const Vec3 right = skeletonWidthAxis(ped.transform, camera.transform); std::vector vertices; std::vector indices; for (std::size_t j = 0; j < n; ++j) { @@ -173,21 +185,31 @@ void AiRenderer::drawSkeleton(const ai::Pedestrian& ped, const asset::PedType& t m_device.draw(call); } -void AiRenderer::drawSignal(const ai::Signal& signal, const Camera& camera, bool nightGlows, - const RoomVisibility::Passes* passes) { +Mat34 signalGlowFrame(const Mat34& frame, const Vec3& cg) { + // aiTrafficLightInstance::DrawGlow: GetMatrix less R * CG (summed y, z, + // then x of the CG). + Mat34 m = frame; + m.m3 = {m.m3.x - ((frame.m1.x * cg.y + frame.m2.x * cg.z) + frame.m0.x * cg.x), + m.m3.y - ((frame.m1.y * cg.y + frame.m2.y * cg.z) + frame.m0.y * cg.x), + m.m3.z - ((frame.m1.z * cg.y + frame.m2.z * cg.z) + frame.m0.z * cg.x)}; + return m; +} + +void AiRenderer::drawSignal(const ai::Signal& signal, const Mat34& frame, const Camera& camera, + bool nightGlows, const RoomVisibility::Passes* passes) { const GpuModel* model = m_models.get(signal.model); if (!model) return; - const Mat44 world = Mat44::fromMat34(signal.transform); + // aiTrafficLightInstance::Draw: the body at GetMatrix, the instance's + // frame at its CG (the body mesh is centred on it). // lvlInstance::IsVisible with the Object Detail thresholds, and // aiTrafficLightInstance::Draw's first shader set. - const auto lod = - objectLod(viewDepth(camera.transform, signal.transform.m3), geomRadius(*model, ""), m_detail); + const auto lod = objectLod(viewDepth(camera.transform, frame.m3), geomRadius(*model, ""), m_detail); if (!passes && !lod) return; if (lod && (!passes || passes->objects)) if (const GpuMesh* body = findFilledLod(*model, "", *lod)) - drawGpuMesh(m_device, m_textures, *body, model->materials(0), world); + drawGpuMesh(m_device, m_textures, *body, model->materials(0), Mat44::fromMat34(frame)); // aiTrafficLightInstance::DrawGlow: the light's glow together with the // pedestrian signal (WALK only in the walk phase), both or neither. const char* colour = signal.state == ai::LightState::Green ? "GREEN" @@ -197,13 +219,16 @@ void AiRenderer::drawSignal(const ai::Signal& signal, const Camera& camera, bool // whatever IsVisible says): unlit, no fog, added (ONE/ONE), no depth // writes. Without the city's room list the signal's own distance stands // in for its room's. - if (passes ? !passes->shadowsAndGlows : signal.transform.m3.dist2(camera.position()) >= sq(m_detail.noDraw)) + if (passes ? !passes->shadowsAndGlows : frame.m3.dist2(camera.position()) >= sq(m_detail.noDraw)) return; const char* time = nightGlows ? "NIGHT" : "DAY"; const GpuMesh* glow = findFilledLod(*model, std::format("{}GLOW{}", colour, time), asset::Lod::High); const char* walkName = signal.state == ai::LightState::Walk ? "WALK" : "NOWALK"; const GpuMesh* walk = findFilledLod(*model, std::format("{}_{}", walkName, time), asset::Lod::High); if (glow && walk) { + // DrawGlow: GetMatrix less R * CG, the base the glow meshes are + // modelled from. + const Mat44 world = Mat44::fromMat34(signalGlowFrame(frame, signal.cg)); MeshDrawOptions opts; opts.lighting = false; opts.fog = false; @@ -216,16 +241,15 @@ void AiRenderer::drawSignal(const ai::Signal& signal, const Camera& camera, bool } void AiRenderer::draw(const ai::World& world, const Camera& camera, const Frustum& frustum, TimeOfDay time, - bool lights, const ObjectDetail& detail, - const std::function& physicalTransform) { + bool lights, const ObjectDetail& detail, const PhysicalCarQuery& physicalCar) { m_stats = {}; m_detail = detail; const Vec3 eye = camera.position(); const int blinkClock = world.blinkClock(); const bool rooms = m_rooms && m_rooms->active(); for (const auto& car : world.cars()) { - const Mat34* physical = physicalTransform ? physicalTransform(car.id) : nullptr; - const Mat34& transform = physical ? *physical : car.transform; + const std::optional physical = physicalCar ? physicalCar(car.id) : std::nullopt; + const Mat34& transform = physical ? physical->transform : car.transform; // cityLevel::DrawRooms: from the car's room; the renderer's // lvlInstance::IsVisible ends the car itself at NoDraw. RoomVisibility::Passes passes; @@ -251,12 +275,20 @@ void AiRenderer::draw(const ai::World& world, const Camera& camera, const Frustu r->setTraffic(true); } r->setDetail(detail); + r->setGroundProbe(m_probe); VehiclePose pose; pose.body = transform; // aiVehicleInstance::Draw: the rail's tyre rotation about each axle, - // no steering. + // no steering; with a body, the wheels where its vehWheelCheaps put + // them. for (std::size_t i = 0; i < 6; ++i) pose.wheelSpin[i] = -car.tireRotation; // rolling forward (-Z) spins about -X + if (physical && physical->active) { + pose.physical = true; + pose.hasWheelWorld = true; + pose.wheelWorld = physical->wheels; + pose.wheelValid = physical->wheelValid; + } pose.brakeLights = car.braking; pose.headlights = lights; // aiVehicleInstance::DrawGlow: the indicators the AI set (+0x1a), @@ -285,15 +317,24 @@ void AiRenderer::draw(const ai::World& world, const Camera& camera, const Frustu int index = 0; for (const auto& signal : world.signals()) { const int id = index++; - if (!frustum.intersectsSphere(signal.transform.m3, 6.0f)) + // The aiTrafficLightInstance's GetMatrix while it stands; once it + // broke loose it is neither drawn nor glows (its parts are props). + Mat34 frame = signal.frame(); + if (m_signalFrame) { + const auto f = m_signalFrame(id); + if (!f) + continue; + frame = *f; + } + if (!frustum.intersectsSphere(frame.m3, 6.0f)) continue; if (rooms) { - const RoomVisibility::Passes passes = roomPasses(m_signalRooms, id, signal.transform.m3); + const RoomVisibility::Passes passes = roomPasses(m_signalRooms, id, frame.m3); if (!passes.objects && !passes.shadowsAndGlows) continue; - drawSignal(signal, camera, time >= TimeOfDay::Evening, &passes); + drawSignal(signal, frame, camera, time >= TimeOfDay::Evening, &passes); } else { - drawSignal(signal, camera, time >= TimeOfDay::Evening, nullptr); + drawSignal(signal, frame, camera, time >= TimeOfDay::Evening, nullptr); } ++m_stats.signals; } diff --git a/src/game/AiRenderer.h b/src/game/AiRenderer.h index 1813d82..444271a 100644 --- a/src/game/AiRenderer.h +++ b/src/game/AiRenderer.h @@ -12,6 +12,7 @@ #include "render/Device.h" #include "vfs/Vfs.h" +#include #include #include #include @@ -28,14 +29,36 @@ class AiRenderer { public: AiRenderer(render::Device& device, TextureLibrary& textures, ModelLibrary& models, const vfs::Vfs& vfs); - // `physicalTransform` returns the transform of cars that the physics - // simulation has taken over (null for cars on their rails). `lights` is + // A traffic car the physics simulation has taken over, or has just let + // go of (TrafficBodies): its matrix (model origin) and, while it has a + // body (aiVehicleActive), its wheels' matrices. + struct PhysicalCar { + Mat34 transform; + bool active = false; + std::array wheels{}; + std::array wheelValid{}; + }; + using PhysicalCarQuery = std::function(int carId)>; + + // `physicalCar` answers for the cars that the physics simulation has + // taken over (nullopt for cars on their rails). `lights` is // mmGame::InitWeather's light flag (evening, night or fog: the cars' // headlights and tail lights); the signals switch to their night glows // from the evening on (aiTrafficLightInstance::DrawGlow: time of day > 1). // `detail`: the Object Detail thresholds (lvlInstance::IsVisible). void draw(const ai::World& world, const Camera& camera, const Frustum& frustum, TimeOfDay time, bool lights, - const ObjectDetail& detail, const std::function& physicalTransform = {}); + const ObjectDetail& detail, const PhysicalCarQuery& physicalCar = {}); + + // The ground under the cars for their shadows (aiVehicleInstance:: + // DrawShadow's lvlInstance::DrawPhysics). + void setGroundProbe(VehicleRenderer::GroundProbe probe) { m_probe = std::move(probe); } + + // The traffic lights' aiTrafficLightInstances as props (BangerSet): + // signal i's GetMatrix while it stands, nullopt once it broke loose + // (then neither its body nor its glows are drawn). Without one each + // signal stands at Signal::frame. + using SignalFrameQuery = std::function(int signalIndex)>; + void setSignalFrames(SignalFrameQuery query) { m_signalFrame = std::move(query); } // The rooms the city listed for the view (CityRenderer::rooms()): cars, // pedestrians and signals are then drawn from the rooms MM2 keeps them @@ -59,7 +82,7 @@ class AiRenderer { 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); - void drawSignal(const ai::Signal& signal, const Camera& camera, bool nightGlows, + 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 // (cityLevel::FindRoomId). @@ -76,10 +99,22 @@ class AiRenderer { ObjectDetail m_detail; Stats m_stats; const RoomVisibility* m_rooms = nullptr; + VehicleRenderer::GroundProbe m_probe; + SignalFrameQuery m_signalFrame; // The rooms of the traffic cars (aiVehicleAmbient's update after its // spline, aiVehicleActive::Update), the pedestrians (aiPedestrian::Update) // and the signals (aiTrafficLightSet::SetFourWay), by id or index. std::unordered_map m_carRooms, m_pedRooms, m_signalRooms; }; +// aiTrafficLightInstance::DrawGlow's matrix for the glow and WALK meshes, +// which are modelled from the pole's base: the instance's GetMatrix +// (`frame`, at the CG) less R * CG. +Mat34 signalGlowFrame(const Mat34& frame, const Vec3& cg); + +// The direction pedAnimation::DrawSkeleton widens a pedestrian's stick +// figure along, in world space, for a pedestrian placed at `ped` seen from +// a camera placed at `camera`. +Vec3 skeletonWidthAxis(const Mat34& ped, const Mat34& camera); + } // namespace mm2::game diff --git a/src/game/CityRenderer.cpp b/src/game/CityRenderer.cpp index e9e2a89..35986ff 100644 --- a/src/game/CityRenderer.cpp +++ b/src/game/CityRenderer.cpp @@ -323,9 +323,10 @@ void CityRenderer::setEnvironment(const Environment& env) { // room colour, half of it (curb faces and caps), or for facades and // slivers the light table entry of the room's last FacadeBound shaded by // it (GetShadedColor). The room colours come from city/.lmap, but - // its count never matches the room count cityLevel::Load checks (London - // 1340 for 1341, SF 1125 for 1171), so MM2 drops it and every room is - // white: GetShadedColor of white is the light itself. + // its count never matches the room count cityLevel::Load checks it + // against (lvlSDL's, read by LoadBinary just before, dummy room 0 + // included: London 1341 for 1342, SF 1125 for 1172), so MM2 drops it and + // every room is white: GetShadedColor of white is the light itself. constexpr std::uint32_t kRoomColor = 0xFFFFFFFFu; for (std::size_t i = 0; i < m_streetVertices.size(); ++i) { std::uint32_t argb = kRoomColor; diff --git a/src/game/PlayerVehicle.cpp b/src/game/PlayerVehicle.cpp index 7ce7080..0bbcf37 100644 --- a/src/game/PlayerVehicle.cpp +++ b/src/game/PlayerVehicle.cpp @@ -266,7 +266,7 @@ VehiclePose SimVehicle::trailerPose() const { pose.wheelValid[i] = true; } pose.hasWheelWorld = true; - // vehTrailerInstance::Draw: the tail lights while the tow car brakes over 0.1. + // vehTrailerInstance::Draw: TLIGHT while the tow car brakes over 0.1. pose.brakeLights = m_sim.brakes > 0.1f; return pose; } diff --git a/src/game/RaceConfig.h b/src/game/RaceConfig.h index fffcba2..12743ad 100644 --- a/src/game/RaceConfig.h +++ b/src/game/RaceConfig.h @@ -43,7 +43,9 @@ struct RaceConfig { int laps = 0; // circuit races int opponents = 0; // AI opponents - // Densities as chosen in the menus, 0 (none) .. 1 (maximum). + // Densities as chosen in the menus, 0 (none) .. 1 (maximum). A race + // starts the cop density at its table's cop count, which can be more + // than 1 (RaceMenuBase::SetStateRace); the police placement clamps it. float pedestrianDensity = 0.5f; float trafficDensity = 0.5f; float copDensity = 0.5f; diff --git a/src/game/TrafficBodies.cpp b/src/game/TrafficBodies.cpp index 30e7586..5e69c70 100644 --- a/src/game/TrafficBodies.cpp +++ b/src/game/TrafficBodies.cpp @@ -42,6 +42,10 @@ constexpr double kUprightCosine = 0.9; constexpr float kMaxCompressionRate = 3.0f; // the damper sees at most 3 m/s constexpr double kBottomedOutT = 0.1; // the probe hit in its top tenth constexpr float kTyreGrip = 0.4f; // deflection limit: 0.4 * load * WeatherFriction / RubberSpring +// vehWheelCheap::Update: the drawn wheel moves back by these fractions of +// the sideways and forward tyre deflections. +constexpr float kDrawLateral = 0.2f; +constexpr float kDrawLongitudinal = 0.3f; // vehWheelCheap::Init: a quarter of the car's weight preloads each spring. constexpr float kPreloadShare = -0.25f; @@ -126,6 +130,10 @@ struct TrafficBodies::Wheel { float longitudinal = 0.0f; // and along it bool contact = false; bool bottomedOut = false; // the probe hit near its top (aiVehicleActive::BottomedOut) + // The drawing matrix (+0x128) and its model-space position (+0x158's): + // aiVehicleInstance::Draw draws the wheel there while the car has a body. + Vec3 drawOffset; + Mat34 drawMatrix; // vehWheelCheap::Init. void init(const Vec3& wheelPivot, const ai::VehicleData& data, phys::InertialCS& body) { @@ -138,6 +146,9 @@ struct TrafficBodies::Wheel { rubberDamp = data.rubberDamp; limit = data.limit; preload = body.mass * kGravityY * kPreloadShare; + // The drawing matrix: unturned, at the pivot. + drawOffset = pivot; + drawMatrix = Mat34::mul(Mat34::translation(drawOffset), body.matrix); reset(); } @@ -153,8 +164,7 @@ struct TrafficBodies::Wheel { // vehWheelCheap::Update, after the body's integration: probes the ground // from the top of the wheel's travel to the bottom along the car's up // axis and adds the spring's and the tyre's forces at the contact. (The - // wheel's drawing matrix, offset by the compression and deflections, is - // not kept: the renderer draws traffic wheels from the AI.) + // wheel's drawing matrix follows: see placeDrawing.) void update(const phys::GroundQuery& ground, const phys::Instance* self, float seconds, float invSeconds, float weatherFriction) { phys::InertialCS& body = *ics; @@ -175,6 +185,7 @@ struct TrafficBodies::Wheel { compression = -limit; lateral = 0.0f; longitudinal = 0.0f; + placeDrawing(m); return; } @@ -224,6 +235,17 @@ struct TrafficBodies::Wheel { total.y + (m.m0.y * fLateral - m.m2.y * fForward), total.z + (fLateral * m.m0.z - m.m2.z * fForward)}; body.applyForce(total, hit.position); + placeDrawing(m); + } + + // The end of vehWheelCheap::Update: the drawing matrix, unturned, at the + // pivot moved along the car's up axis by the spring's travel and back by + // 0.2 of the sideways and 0.3 of the forward tyre deflection, carried by + // the body's matrix as it stands after this sample's integration. + void placeDrawing(const Mat34& m) { + drawOffset = {pivot.x - lateral * kDrawLateral, pivot.y + compression, + pivot.z - longitudinal * kDrawLongitudinal}; + drawMatrix = Mat34::mul(Mat34::translation(drawOffset), m); } }; @@ -669,6 +691,32 @@ void TrafficBodies::instancesIn(int room, std::vector& out) con out.push_back(car); } +std::optional TrafficBodies::wheelsOf(int carId) const { + // aiVehicleInstance::Draw with the car's active: WHL0-3 at the four + // vehWheelCheaps' drawing matrices; WHL4 / WHL5 unturned at their pivots + // raised by WHL2's / WHL3's drawn height less the wheel radius, carried + // by GetMatrix (the matrix the wheels were placed with). + const RailCar* r = findRailCar(carId); + if (!r || !r->active || !r->data) + return std::nullopt; + const Active& a = *r->active; + const ai::VehicleData& d = *r->data; + Wheels w; + for (std::size_t i = 0; i < a.wheels.size(); ++i) { + w.matrix[i] = a.wheels[i].drawMatrix; + w.valid[i] = true; + } + for (std::size_t i = 4; i < 6; ++i) { + if (d.wheelCount <= static_cast(i)) + continue; + const Vec3& p = d.wheels[i]; + const Vec3 local{p.x, (a.wheels[i - 2].drawOffset.y - d.wheelRadius) + p.y, p.z}; + w.matrix[i] = Mat34::mul(Mat34::translation(local), a.body.boundMatrix); + w.valid[i] = true; + } + return w; +} + const Mat34* TrafficBodies::transformOf(int carId) const { const RailCar* r = findRailCar(carId); if (!r) diff --git a/src/game/TrafficBodies.h b/src/game/TrafficBodies.h index 30ad2ce..e638e01 100644 --- a/src/game/TrafficBodies.h +++ b/src/game/TrafficBodies.h @@ -22,6 +22,7 @@ #include #include #include +#include #include #include #include @@ -84,6 +85,15 @@ class TrafficBodies final : public InstanceSource { // it and the AI has not caught up with yet (its list is refreshed by its // next update); null when the AI transform is current. const Mat34* transformOf(int carId) const; + // The wheels aiVehicleInstance::Draw draws for a car that has a body + // (aiVehicleActive's vehWheelCheaps instead of the rail's turning + // wheels): WHL0-5 world matrices, valid where the car has the pivot. + // Nullopt for a car without a body. + struct Wheels { + std::array matrix{}; + std::array valid{}; + }; + std::optional wheelsOf(int carId) const; // Cars with a body (at most 32). std::size_t activeCount() const { return static_cast(m_count); } diff --git a/src/game/VehicleRenderer.cpp b/src/game/VehicleRenderer.cpp index 7a18066..07d78fc 100644 --- a/src/game/VehicleRenderer.cpp +++ b/src/game/VehicleRenderer.cpp @@ -30,8 +30,8 @@ float s_glowColor = 0.95f; const Mat44* s_flareViewProj = nullptr; float s_flareAspect = 4.0f / 3.0f; std::vector* s_flareOut = nullptr; -// ltFlare::Random draws on MM2's global generator; OpenMM2 gives the flares -// their own. +// ltFlare::Random draws on MM2's global generator; a car whose set-up does +// not reproduce MM2's order (setSirenFlares) draws its flares from this one. fx::Rand s_flareRand{0x5A1E}; // vehSiren::Draw: ltLight::ComputeIntensity's threshold for the flares. constexpr float kFlareThreshold = 0.05f; @@ -63,6 +63,7 @@ VehicleRenderer::VehicleRenderer(render::Device& device, TextureLibrary& texture std::string wheelPrefix) : m_device(device), m_textures(textures), m_model(model), m_paintjob(paintjob), m_bodyPart(std::move(bodyPart)), m_wheelPrefix(std::move(wheelPrefix)) { + m_trailer = str::iequals(m_bodyPart, "TRAILER"); m_gpu = models.add(model.baseName, model.pkg); setPaintjob(paintjob); if (!m_gpu) @@ -101,7 +102,7 @@ VehicleRenderer::VehicleRenderer(render::Device& device, TextureLibrary& texture } // vehSiren::Init: twenty lens flares for the siren lights. if (!m_sirens.empty()) - m_flare.emplace(20, s_flareRand); + m_flare.emplace(kSirenFlares, s_flareRand); // Fenders follow the front wheels at their pivot's offset from wheel 0 // (lifted 2.5 cm; FNDR1 mirrors it). if (findFilledLod(*m_gpu, "FNDR0", asset::Lod::High) && model.pivot("fndr0") && model.wheel(0)) @@ -118,7 +119,8 @@ void VehicleRenderer::setPaintjob(int paintjob) { // draw the paint job's materials as stored. m_live = m_paint; m_texelDamage.reset(); - if (m_traffic) + // aiVehicleInstance and vehTrailerInstance have no fxTexelDamage. + if (m_traffic || m_trailer) return; for (const auto& mesh : m_model.pkg.meshes) if (str::iequals(mesh.part, m_bodyPart) && mesh.lod == asset::Lod::High) { @@ -241,6 +243,19 @@ void VehicleRenderer::setLensFlareTarget(const Mat44* viewProj, float aspect, s_flareOut = out; } +void VehicleRenderer::setSirenFlares(std::uint32_t randomState) { + if (m_sirens.empty()) + return; + fx::Rand random(randomState); + m_flare.emplace(kSirenFlares, random); +} + +std::uint32_t takeVehCarInitDraws(fx::Rand& random) { + const std::uint32_t flares = random.state(); + random.discard(kVehCarInitDraws); + return flares; +} + void VehicleRenderer::setLightGlowScales(float size, float color) { s_glowSize = size; s_glowColor = color; @@ -320,10 +335,13 @@ void VehicleRenderer::draw(const VehiclePose& pose, const Mat34& camera) { return; if (m_traffic) drawTraffic(pose, *visible); + else if (m_trailer) + drawTrailer(pose, *visible); else drawCar(pose, *visible); drawShadow(pose); - drawGlows(pose, camera); + if (!m_trailer) // vehTrailerInstance keeps lvlInstance's empty DrawGlow + drawGlows(pose, camera); } void VehicleRenderer::draw(const VehiclePose& pose, const Mat34& camera, const RoomVisibility::Passes& passes) { @@ -334,13 +352,16 @@ void VehicleRenderer::draw(const VehiclePose& pose, const Mat34& camera, const R if (const auto visible = lodFor(pose, camera)) { if (m_traffic) drawTraffic(pose, *visible); + else if (m_trailer) + drawTrailer(pose, *visible); else drawCar(pose, *visible); } // cityLevel_drawShadows, cityLevel_drawLights: by the room alone. if (passes.shadowsAndGlows) { drawShadow(pose); - drawGlows(pose, camera); + if (!m_trailer) // vehTrailerInstance keeps lvlInstance's empty DrawGlow + drawGlows(pose, camera); } } @@ -437,10 +458,14 @@ void VehicleRenderer::drawTraffic(const VehiclePose& pose, asset::Lod lod) { return; drawReflection(pose.body); // The wheels on the rails turn about their axles at their pivots (no - // steering); WHL4 and WHL5 likewise at their own pivots. + // steering); WHL4 and WHL5 likewise at their own pivots. A car with a + // body has its wheels where its vehWheelCheaps put them (pose.wheelWorld). for (std::size_t i = 0; i < 6; ++i) { std::optional m; - if (i < 4) { + if (pose.hasWheelWorld) { + if (pose.wheelValid[i]) + m = pose.wheelWorld[i]; + } else if (i < 4) { m = wheelMatrix(pose, i); } else if (const auto* w = m_model.wheel(static_cast(i))) { m = Mat34::rotationX(pose.wheelSpin[i]) * Mat34::translation(w->position) * pose.body; @@ -450,16 +475,36 @@ void VehicleRenderer::drawTraffic(const VehiclePose& pose, asset::Lod lod) { } } -std::optional VehicleRenderer::shadowMatrix(const Mat34& body) const { +void VehicleRenderer::drawTrailer(const VehiclePose& pose, asset::Lod lod) { + // vehTrailerInstance::Draw: the body at its LOD with the paint job's + // shaders as stored; at the high LOD only, TLIGHT while the tow car + // brakes harder than 0.1 (an ordinary lit draw in the object pass, not + // a glow: its black, fully transparent fxltglowred material leaves it + // invisible under the pass's alpha test, inferred from the material) + // and TWHL0-3 at the trailer's + // wheel matrices. None of vehCarModel's other parts: no reflection, + // decal, breakables, fenders, hubs or TWHL4/5, so TWHL0/1's medium and + // low meshes (modelled away from their pivots) are never drawn. + drawPart(m_bodyPart, lod, pose.body, {}, false); + if (lod != asset::Lod::High) + return; + if (pose.brakeLights) + drawPart("TLIGHT", asset::Lod::High, pose.body, {}, false); + for (std::size_t i = 0; i < 4; ++i) + if (pose.hasWheelWorld && pose.wheelValid[i]) + drawPart(std::format("{}{}", m_wheelPrefix, i), asset::Lod::High, pose.wheelWorld[i], {}, false); +} + +std::optional groundShadowMatrix(const Mat34& body, const VehicleRenderer::GroundProbe& probe) { // lvlInstance::DrawPhysics: the ground 1 m above to 1 m below the car, // else to 5 m below; no shadow on slopes steeper than normal.y 0.7. The // car's frame is turned onto the ground (upside down: its flipped up axis). - if (!m_probe) + if (!probe) return std::nullopt; Vec3 point, normal; const Vec3 p = body.m3; - if (!m_probe(p + Vec3{0, 1, 0}, p - Vec3{0, 1, 0}, point, normal) && - !m_probe(p + Vec3{0, 1, 0}, p - Vec3{0, 5, 0}, point, normal)) + if (!probe(p + Vec3{0, 1, 0}, p - Vec3{0, 1, 0}, point, normal) && + !probe(p + Vec3{0, 1, 0}, p - Vec3{0, 5, 0}, point, normal)) return std::nullopt; if (normal.y < 0.7f) return std::nullopt; @@ -474,13 +519,36 @@ std::optional VehicleRenderer::shadowMatrix(const Mat34& body) const { return m; } +Mat34 trafficShadowMatrix(const Mat34& body, bool physical, const VehicleRenderer::GroundProbe& probe) { + // aiVehicleInstance::DrawShadow: a car with a body (an aiVehicleActive) + // lays its shadow on the ground under the active's matrix + // (lvlInstance::DrawPhysics); otherwise, or with no ground there, the + // shadow sits at GetMatrix while the car is upright (m1.y >= 0) and on + // the ground under it when upside down and there is ground. + if (physical) + if (auto m = groundShadowMatrix(body, probe)) + return *m; + if (!(body.m1.y >= 0.0f)) + if (auto m = groundShadowMatrix(body, probe)) + return *m; + return body; +} + +std::optional VehicleRenderer::shadowMatrix(const Mat34& body) const { + return groundShadowMatrix(body, m_probe); +} + void VehicleRenderer::drawShadow(const VehiclePose& pose) { // vehCarModel::DrawShadow: the high LOD shadow mesh on the ground, // alpha blended, depth tested without writes and pulled forward (MM2 // narrows the depth range to 0.001-0.999). - // Without a ground probe (traffic on its rails, aiVehicleInstance:: - // DrawShadow while upright) the shadow sits at the body. - const auto m = m_probe ? shadowMatrix(pose.body) : std::optional(pose.body); + std::optional m; + if (m_traffic) + m = trafficShadowMatrix(pose.body, pose.physical, m_probe); + else if (m_probe) + m = shadowMatrix(pose.body); + else + m = pose.body; // OpenMM2: without a ground probe the shadow sits at the body if (!m) return; MeshDrawOptions shadow; @@ -564,20 +632,31 @@ void VehicleRenderer::drawGlows(const VehiclePose& pose, const Mat34& camera) { } } else { // Headlight beams (vehCarModel::DrawHeadlights) with the light flag, - // or sweeping in opposite directions while the siren is on. - if ((pose.headlights || (pose.siren && !m_sirens.empty())) && (m_headlights[0] || m_headlights[1])) { - for (std::size_t i = 0; i < 2; ++i) { - if (!m_headlights[i]) - continue; - Vec3 direction = -pose.body.m2; - if (pose.siren && !m_sirens.empty()) { - const float sweep = pose.sirenAngle / (2.5f * 3.1415927f) * kHeadlightSweep; - direction = Mat34::rotationY(i == 0 ? sweep : -sweep).transformDir(direction); - } - addLightGlow(m_cards, pose.body.transform(m_headlights[i]->position), direction, - m_headlights[i]->color, camera); + // or sweeping while the siren is on: the two ltLights keep their + // world-space directions, set to the car's forward axis without the + // siren and turned about Y by +-42.411503 rad/s of frame time from + // wherever they point while it is on (Vector3::RotateY), so the + // sweep does not follow the car's turning. + const bool sweeping = pose.siren && !m_sirens.empty(); + if ((pose.headlights || sweeping) && (m_headlights[0] || m_headlights[1])) { + if (sweeping) { + // The frame's time from the siren's angle (2.5 pi rad/s, + // kept within a turn). + float turned = pose.sirenAngle - m_beamSirenAngle; + if (turned < 0.0f) + turned = turned + 6.2831855f; + const float sweep = turned / (2.5f * 3.1415927f) * kHeadlightSweep; + m_beamDirection[0] = Mat34::rotationY(sweep).transformDir(m_beamDirection[0]); + m_beamDirection[1] = Mat34::rotationY(-sweep).transformDir(m_beamDirection[1]); + } else { + m_beamDirection[0] = m_beamDirection[1] = -pose.body.m2; } + for (std::size_t i = 0; i < 2; ++i) + if (m_headlights[i]) + addLightGlow(m_cards, pose.body.transform(m_headlights[i]->position), m_beamDirection[i], + m_headlights[i]->color, camera); } + m_beamSirenAngle = pose.sirenAngle; // Siren beams (vehSiren::Draw): world-space directions turning about // Y, a quarter turn apart. if (pose.siren) diff --git a/src/game/VehicleRenderer.h b/src/game/VehicleRenderer.h index 5ebe23e..860b229 100644 --- a/src/game/VehicleRenderer.h +++ b/src/game/VehicleRenderer.h @@ -28,6 +28,10 @@ struct VehiclePose { std::array wheelWorld{}; std::array wheelValid{}; bool hasWheelWorld = 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). + bool physical = false; // mmGame::InitWeather's light flag (evening, night or fog): the tail // lights glow and the headlights shine. bool headlights = false; @@ -53,7 +57,8 @@ class VehicleRenderer { using GroundProbe = std::function; // `bodyPart`/`wheelPrefix` select the part names: "BODY"/"WHL" for cars, - // "TRAILER"/"TWHL" for semi trailers. + // "TRAILER"/"TWHL" for semi trailers, which are drawn like + // vehTrailerInstance (see drawTrailer). VehicleRenderer(render::Device& device, TextureLibrary& textures, ModelLibrary& models, const asset::VehicleModel& model, int paintjob, std::string bodyPart = "BODY", std::string wheelPrefix = "WHL"); @@ -118,6 +123,10 @@ class VehicleRenderer { // Not for the traffic renderers AiRenderer shares between cars. void setRooms(const RoomVisibility* rooms) { m_rooms = rooms; } + // vehCarModel::DrawHeadlights' two ltLight directions as last drawn + // (world space). + const std::array& headlightDirections() const { return m_beamDirection; } + // The level of detail at that camera; nullopt beyond NoDraw. std::optional lodFor(const VehiclePose& pose, const Mat34& camera) const; @@ -134,6 +143,11 @@ class VehicleRenderer { // set, the siren lights' flares for that view go into `out`, to be drawn // after the scene (fx::drawLensFlares). Null turns them off. static void setLensFlareTarget(const Mat44* viewProj, float aspect, std::vector* out); + // vehSiren::Init's lens flare (ltLensFlare(20)) for a car with siren + // lights, drawn by ltFlare::Random from MM2's global stream as it stood + // at the car's vehCar::Init (takeVehCarInitDraws). Until this is called + // the flares come from a stream of OpenMM2's own. + void setSirenFlares(std::uint32_t randomState); private: struct Light { @@ -144,6 +158,7 @@ class VehicleRenderer { bool live = true); void drawCar(const VehiclePose& pose, asset::Lod lod); void drawTraffic(const VehiclePose& pose, asset::Lod lod); + void drawTrailer(const VehiclePose& pose, asset::Lod lod); void drawReflection(const Mat34& body); // The world matrix wheel `i` (0-5) is drawn with, if the car has it. std::optional wheelMatrix(const VehiclePose& pose, std::size_t i) const; @@ -171,14 +186,38 @@ class VehicleRenderer { std::optional m_fenderOffset; // fndr0 pivot relative to wheel 0 fx::ParticleRenderer m_cards; std::optional m_flare; // vehSiren's ltLensFlare(20) + // The headlight ltLights' world-space directions (vehCarModel:: + // DrawHeadlights; ltLight::Default points them down -Z) and the siren + // angle they were last turned to. + std::array m_beamDirection{Vec3{0.0f, 0.0f, -1.0f}, Vec3{0.0f, 0.0f, -1.0f}}; + float m_beamSirenAngle = 0.0f; std::unique_ptr m_texelDamage; std::set m_detached; std::vector m_ejectedBangers; // in ejection order std::function m_ejectedPartReset; bool m_wreckEjected = false; bool m_traffic = false; + bool m_trailer = false; // a vehTrailerInstance ("TRAILER" body) const RoomVisibility* m_rooms = nullptr; int m_room = 0; // lvlInstance's room (vehCar::Update) }; +// vehCar::Init's draws on MM2's global stream, which every car makes +// whatever its model: vehCarModel::Init -> vehSiren::Init builds the siren's +// lens flare (ltLensFlare(20), ltFlare::Random: six frand per flare), then +// vehSplash::Init fills its 64 points with random directions (three frand +// each) that it overwrites at once. Returns the state the flares are drawn +// from (VehicleRenderer::setSirenFlares) and advances `random` past all of +// them. +inline constexpr int kSirenFlares = 20; +inline constexpr int kVehCarInitDraws = kSirenFlares * 6 + 64 * 3; +std::uint32_t takeVehCarInitDraws(fx::Rand& random); + +// lvlInstance::DrawPhysics: a shadow's matrix on the ground under `body` +// (nullopt without ground or on a steep slope). +std::optional groundShadowMatrix(const Mat34& body, const VehicleRenderer::GroundProbe& probe); +// aiVehicleInstance::DrawShadow's placement of a traffic car's shadow; +// `physical`: the car has a body (aiVehicleActive). +Mat34 trafficShadowMatrix(const Mat34& body, bool physical, const VehicleRenderer::GroundProbe& probe); + } // namespace mm2::game diff --git a/src/game/bangers/BangerData.cpp b/src/game/bangers/BangerData.cpp index 7b9c66c..ec94228 100644 --- a/src/game/bangers/BangerData.cpp +++ b/src/game/bangers/BangerData.cpp @@ -44,7 +44,16 @@ std::optional parseBangerData(std::string_view name, std::string_vie d.glowOffsets.resize(static_cast(std::max(0, std::min(numGlows, static_cast(d.glowOffsets.size()))))); if (d.name.find("_tree") != std::string::npos) d.billFlags |= BangerData::kTree; - if (const auto* rule = b.child("BirthRule")) { + // asBirthRule::Load skips the block's name, its brace and the first + // field's name before it reads by position, so the block counts + // whatever it is called (sp_tree1_s_break06 calls it "asBirthRule"). + const data::DatNode* rule = nullptr; + for (const auto& c : b.children) + if (!c.children.empty()) { + rule = &c; + break; + } + if (rule) { fx::BirthRule r; if (fx::loadBirthRule(*rule, r)) { // asBirthRule::Load reads only the 24 fields dgBangerData::Save diff --git a/src/game/bangers/BangerSet.cpp b/src/game/bangers/BangerSet.cpp index b198cfc..94ec8cc 100644 --- a/src/game/bangers/BangerSet.cpp +++ b/src/game/bangers/BangerSet.cpp @@ -418,49 +418,54 @@ void BangerSet::instancesIn(int room, std::vector& out) const { // --- Setup ----------------------------------------------------------------------------------- void BangerSet::add(const std::vector& props) { - for (const auto& p : props) { - const BangerData* d = m_data.find(p.model); - if (!d) { - ++m_skipped; - continue; - } - // dgBangerDataManager::AddBangerDataEntry: the data's bound, and its - // parts' (dgUnhitBangerInstance::InitBreakables). - boundsOf(*d); - for (int k = 0; k < d->numParts; ++k) - if (const BangerData* part = m_data.part(p.model, k)) - boundsOf(*part); - const std::size_t i = newInstance(); - Instance& inst = m_instances[i]; - inst.data = d; - inst.model = p.model; - inst.ground = p.transform; - if (!p.fullMatrix) { - // dgUnhitYBangerInstance keeps only the X row's x and z: rows - // (c, 0, s), (0, 1, 0), (-s, 0, c), not renormalised. - const float c = p.transform.m0.x, s = p.transform.m0.z; - inst.ground.m0 = {c, 0.0f, s}; - inst.ground.m1 = {0.0f, 1.0f, 0.0f}; - inst.ground.m2 = {-s, 0.0f, c}; - } - // The banger's frame sits at its CG (dgUnhitBangerInstance::Init: the - // CG offset turned by the matrix as placed, before SetMatrix keeps - // only the Y rotation), with the instance data's variant - // (SetVariant). - inst.matrix = inst.ground; - inst.matrix.m3 = p.transform.transform(d->cg); - inst.paint = p.variant; - inst.roomHint = p.roomHint; - inst.state = State::Unhit; - Prop& prop = *m_props[i]; - prop.banger = true; - prop.collidable = true; - prop.audioId = d->colliderId; - moveToRoom(i, p.room); - } + for (const auto& p : props) + addOne(p); placeUnroomed(); } +std::optional BangerSet::addOne(const PlacedProp& p) { + const BangerData* d = m_data.find(p.model); + if (!d) { + ++m_skipped; + return std::nullopt; + } + // dgBangerDataManager::AddBangerDataEntry: the data's bound, and its + // parts' (dgUnhitBangerInstance::InitBreakables). + boundsOf(*d); + for (int k = 0; k < d->numParts; ++k) + if (const BangerData* part = m_data.part(p.model, k)) + boundsOf(*part); + const std::size_t i = newInstance(); + Instance& inst = m_instances[i]; + inst.data = d; + inst.model = p.model; + inst.ground = p.transform; + if (!p.fullMatrix) { + // dgUnhitYBangerInstance keeps only the X row's x and z: rows + // (c, 0, s), (0, 1, 0), (-s, 0, c), not renormalised. + const float c = p.transform.m0.x, s = p.transform.m0.z; + inst.ground.m0 = {c, 0.0f, s}; + inst.ground.m1 = {0.0f, 1.0f, 0.0f}; + inst.ground.m2 = {-s, 0.0f, c}; + } + // The banger's frame sits at its CG (dgUnhitBangerInstance::Init: the + // CG offset turned by the matrix as placed, before SetMatrix keeps + // only the Y rotation), with the instance data's variant + // (SetVariant). + inst.matrix = inst.ground; + inst.matrix.m3 = p.transform.transform(d->cg); + inst.paint = p.variant; + inst.roomHint = p.roomHint; + inst.ownerDrawn = p.ownerDrawn; + inst.state = State::Unhit; + Prop& prop = *m_props[i]; + prop.banger = true; + prop.collidable = true; + prop.audioId = d->colliderId; + moveToRoom(i, p.room); + return i; +} + void BangerSet::setWorld(phys::World* world) { if (m_world) for (auto& a : m_active) @@ -1088,6 +1093,11 @@ void BangerSet::draw(render::Device& device, ModelLibrary& models, TextureLibrar const Instance& inst = m_instances[i]; if (inst.state == State::Gone) continue; + // A traffic light standing is drawn with its signal (AiRenderer: + // aiTrafficLightInstance::Draw / DrawGlow); once it breaks loose it + // leaves the rooms (Gone) and its parts are ordinary hit bangers. + if (inst.ownerDrawn && standing(i)) + continue; RoomVisibility::Passes passes; if (rooms) { passes = params.rooms->passes(inst.room); diff --git a/src/game/bangers/BangerSet.h b/src/game/bangers/BangerSet.h index 4e75ecf..f59e012 100644 --- a/src/game/bangers/BangerSet.h +++ b/src/game/bangers/BangerSet.h @@ -25,6 +25,7 @@ #include #include #include +#include #include #include @@ -60,6 +61,9 @@ class BangerSet final : public InstanceSource { // Adds props that have banger data (others are ignored and counted). void add(const std::vector& props); + // Adds one prop and returns its instance index (nullopt without banger + // data). Call setWorld (again) afterwards for a prop without a room. + std::optional addOne(const PlacedProp& prop); // The physics world whose movers the actives become and whose level // places props without a room. Must outlive this set (or call // setWorld(nullptr) first). @@ -109,6 +113,7 @@ class BangerSet final : public InstanceSource { Mat34 matrix; // current frame at the CG (meshes are centred on it) State state = State::Unhit; bool everHit = false; // one of the ring of hit instances rather than a placed prop + bool ownerDrawn = false; // PlacedProp::ownerDrawn: not drawn here while it stands int active = -1; // index into the active pool int room = 0; int roomHint = 0; // with room 0: where FindRoomId starts (an xref's parent room) diff --git a/src/game/bangers/PropPlacement.cpp b/src/game/bangers/PropPlacement.cpp index 52ee21e..3e6a830 100644 --- a/src/game/bangers/PropPlacement.cpp +++ b/src/game/bangers/PropPlacement.cpp @@ -472,7 +472,7 @@ std::vector placePathSet(const city::PathSet& set, PlacedProp::Sourc } std::vector placeStreetProps(const city::Psdl& psdl, const std::vector& defs, - const std::vector& rules) { + const std::vector& rules, std::uint32_t* randomState) { std::vector out; std::map> defByName; for (const auto& d : defs) @@ -481,10 +481,14 @@ std::vector placeStreetProps(const city::Psdl& psdl, const std::vect for (const auto& r : rules) ruleByName.try_emplace(r.name, &r); + // MM2's global seed as each road leaves it. + std::uint32_t state = 1; for (const auto& road : psdl.roads) { - // Without sidewalks (flag 0x40) the curb and the outer edge are the - // same point and nothing can stand: skipping the road changes nothing. - if (road.rooms.empty() || !(road.flags & kRoadSidewalks)) + state = 1; // ResetRandomSeed, before every road + // A road without sidewalks (flag 0x40) is walked too: its curb and + // outer edge are the same point, so nothing stands (the X row is + // zero), but the walk draws as MM2's does. + if (road.rooms.empty()) continue; const auto firstRoom = city::PsdlRoad::roomId(road.rooms.front()); if (firstRoom >= psdl.rooms.size()) @@ -561,7 +565,10 @@ std::vector placeStreetProps(const city::Psdl& psdl, const std::vect } } } + state = rng.state(); } + if (randomState && !psdl.roads.empty()) + *randomState = state; return out; } @@ -655,7 +662,11 @@ std::vector placeXrefs(const city::Instance& record, const std::vect log::debug("bangers: x-ref {} of {} has no banger data", x.name, record.name); continue; } - PlacedProp p{str::lower(x.name), m, 0, PlacedProp::Source::Instance, true, record.flags & 0xFF}; + // RequestBanger(name, 0): a dgUnhitYBangerInstance, which keeps only + // the turn about Y of the matrix (and its CG offset turned by the + // whole matrix, dgUnhitBangerInstance::Init). 68 retail xrefs are + // exported Z up (m2 pointing down); MM2 stands them upright. + PlacedProp p{str::lower(x.name), m, 0, PlacedProp::Source::Instance, false, record.flags & 0xFF}; p.roomHint = record.room; out.push_back(std::move(p)); } @@ -663,7 +674,8 @@ std::vector placeXrefs(const city::Instance& record, const std::vect } std::vector placeCityProps(const city::CityData& city, const vfs::Vfs& vfs, - const BangerDataLibrary& data, std::string_view raceProps) { + const BangerDataLibrary& data, std::string_view raceProps, + std::uint32_t* randomState) { std::vector out; const std::string map = str::lower(city.info.mapName); const std::string dir = "city/" + map + "/"; @@ -684,7 +696,8 @@ std::vector placeCityProps(const city::CityData& city, const vfs::Vf const auto defs = vfs.readAll(dir + "propdefs.csv"); const auto rules = vfs.readAll(dir + "proprules.csv"); if (defs && rules) { - auto props = placeStreetProps(city.psdl, parsePropDefs(text(*defs)), parsePropRules(text(*rules))); + auto props = placeStreetProps(city.psdl, parsePropDefs(text(*defs)), parsePropRules(text(*rules)), + randomState); out.insert(out.end(), props.begin(), props.end()); } // lvlLevel::LoadInstances of .inst and _ai.inst: banger records diff --git a/src/game/bangers/PropPlacement.h b/src/game/bangers/PropPlacement.h index 476d32c..4992802 100644 --- a/src/game/bangers/PropPlacement.h +++ b/src/game/bangers/PropPlacement.h @@ -30,6 +30,10 @@ struct PlacedProp { // With room 0: the room FindRoomId tries first (a PKG xref's parent // record's room, lvlLevel::LoadInstances); 0 for none. int roomHint = 0; + // Drawn by its owner while it stands, not by BangerSet::draw: a traffic + // light (aiTrafficLightInstance, drawn with its signal by AiRenderer). + // The parts it breaks into are ordinary hit bangers. + bool ownerDrawn = false; }; // city//propdefs.csv: one street prop type (columns by name). @@ -92,9 +96,14 @@ std::vector placePathSet(const city::PathSet& set, PlacedProp::Sourc // and road, picking a random variant. The sidewalk polylines are // lvlAiMap::GetSidewalkVertex's: each room's sections after the first room's // continue the road without their first section, interior corners are cut -// by up to 0.1 m. +// by up to 0.1 m. `randomState` receives MM2's global seed as the loop +// leaves it (the last road's draws from 1, or 1 when the last road has no +// props): the state the rest of the race's set-up draws from +// (docs/parity/round3/random-streams.md). It is left alone when there are no +// roads. std::vector placeStreetProps(const city::Psdl& psdl, const std::vector& defs, - const std::vector& rules); + const std::vector& rules, + std::uint32_t* randomState = nullptr); // The name of a race's prop path set in race// (cityLevel::Load: // dgGameModeNames[mode] formatted with the race index): "roam" for cruise, @@ -119,7 +128,10 @@ std::vector placeXrefs(const city::Instance& record, const std::vect // banger instances of city/.inst and _ai.inst (e.g. stop signs), // city//props.pathset, then race//.pathset when // `raceProps` names one (racePropsName()). +// `randomState` as placeStreetProps' (left alone without the propulator's +// files; the rest of cityLevel::Load draws nothing). std::vector placeCityProps(const city::CityData& city, const vfs::Vfs& vfs, - const BangerDataLibrary& data, std::string_view raceProps = {}); + const BangerDataLibrary& data, std::string_view raceProps = {}, + std::uint32_t* randomState = nullptr); } // namespace mm2::game::bangers diff --git a/src/game/fx/Particles.h b/src/game/fx/Particles.h index c0688f0..b7bdfaa 100644 --- a/src/game/fx/Particles.h +++ b/src/game/fx/Particles.h @@ -96,7 +96,7 @@ class ParticleSystem { const Mat34* m_matrix = nullptr; float m_elapsed = 0.0f; float m_spewFraction = 0.0f; - Rand m_rand; + Rand m_rand{0x2Au}; }; // Runs per-update effect rules at a fixed rate whatever the frame rate: diff --git a/src/game/fx/Random.h b/src/game/fx/Random.h index 75925f3..a7f5c70 100644 --- a/src/game/fx/Random.h +++ b/src/game/fx/Random.h @@ -5,31 +5,15 @@ */ #pragma once -#include +#include "ai/Random.h" namespace mm2::game::fx { // irand()/frand() of the Angel engine: the MSVC linear congruential -// generator (seed * 214013 + 2531011), returning 15 bits. The original used -// one global seed (gRandSeed) for everything; OpenMM2 gives each subsystem -// its own instance so simulations stay deterministic independently. -class Rand { -public: - explicit Rand(std::uint32_t seed = 0x2A) : m_seed(seed) {} - - void seed(std::uint32_t s) { m_seed = s; } - std::uint32_t state() const { return m_seed; } - - // 0 .. 32767 - int irand() { - m_seed = m_seed * 214013u + 2531011u; - return static_cast((m_seed >> 16) & 0x7FFF); - } - // [0, 1): irand() * 2^-15 (frand) - float frand() { return static_cast(irand()) * 3.0517578125e-05f; } - -private: - std::uint32_t m_seed; -}; +// generator (seed * 214013 + 2531011), returning 15 bits. One class for the +// whole game (ai::Random), so the systems MM2 sets up from its one global +// seed can share a stream (ai/Random.h); the effects, which MM2 draws from +// its secondary seed (DisableGlobalSeed / EnableGlobalSeed), keep their own. +using Rand = ai::Random; } // namespace mm2::game::fx diff --git a/src/game/session/RaceSetup.cpp b/src/game/session/RaceSetup.cpp index 4265b15..8fb0676 100644 --- a/src/game/session/RaceSetup.cpp +++ b/src/game/session/RaceSetup.cpp @@ -1,5 +1,6 @@ #include "game/session/RaceSetup.h" +#include "ai/Random.h" #include "city/RoomInfo.h" #include "core/Log.h" #include "core/StringUtil.h" @@ -51,11 +52,6 @@ float headingTowards(const Vec3& from, const Vec3& to) { return std::atan2(from.x - to.x, from.z - to.z) * -57.295776f; } -std::uint32_t nextRandom(std::uint32_t& rng) { - rng = rng * 1103515245u + 12345u; - return (rng >> 16) & 0x7fffu; -} - } // namespace std::vector buildCheckpoints(const std::vector& points, bool loop) { @@ -118,39 +114,77 @@ Vec3 findGroundPos(const Vec3& p, const GroundProbe& probe) { return p; } -std::optional randomIntersectionStart(const city::CityData& city, std::uint32_t& rng) { +int& respawnCounter() { + static int counter = 0; + return counter; +} + +std::optional respawnXYZ(const city::CityData& city, const RoomLookup& findRoom, + RespawnRules rules, std::uint32_t& stream, int draws) { if (!city.aiMap || city.aiMap->intersections.size() < 2) return std::nullopt; const auto& xs = city.aiMap->intersections; - // The level's room flags (lvlRoomInfo, not the PSDL's): mmSingleRoam asks - // for no subterranean or covered rooms (0x0A), and RespawnXYZ never takes - // water-of-death or terrain-instance rooms (0x24). - const auto& levelFlags = city.levelRoomFlags; - constexpr std::uint16_t kRejected = city::LevelRoomFlag::Subterranean | city::LevelRoomFlag::Covered | - city::LevelRoomFlag::WaterOfDeath | city::LevelRoomFlag::TerrainInstance; - auto acceptable = [&](const city::AiIntersection& x) { - if (x.room < levelFlags.size() && (levelFlags[x.room] & kRejected)) + const auto& paths = city.aiMap->paths; + // The level's room flags (lvlRoomInfo): water of death and terrain + // instances always (0x24), subterranean and covered rooms (0x0A) with the + // second rule. + using namespace city::LevelRoomFlag; + const std::uint16_t rejectedRooms = static_cast( + WaterOfDeath | TerrainInstance | (rules.noCovered ? Subterranean | Covered : 0)); + // aiPath +0xc: 0x4 a freeway, 0x2 an alley (mm2hook's names). + const std::uint16_t rejectedRoads = + static_cast((rules.noFreeways ? 0x4 : 0) | (rules.noCovered ? 0x2 : 0)); + auto fits = [&](const city::AiIntersection& x) { + const int room = findRoom ? findRoom(x.center) : x.room; + if (room >= 0 && static_cast(room) < city.levelRoomFlags.size() && + (city.levelRoomFlags[static_cast(room)] & rejectedRooms)) return false; - for (const auto pathId : x.paths) { - if (pathId >= city.aiMap->paths.size()) - continue; - // aiPath flags 0x4 (freeway) and 0x2 (alley), mm2hook's naming. - if (city.aiMap->paths[pathId].flags & 0x6) + for (const auto id : x.paths) + if (id < paths.size() && (paths[id].flags & rejectedRoads)) return false; - } return true; }; - // The original retries random picks until one fits; bound the search - // and fall back to a scan so a city without a valid one cannot hang. - for (int attempt = 0; attempt < 1000; ++attempt) { - const auto& x = xs[1 + nextRandom(rng) % (xs.size() - 1)]; - if (acceptable(x)) - return x.center + Vec3{0.0f, 2.0f, 0.0f}; + // MM2 retries until one fits and would never return from a city where + // none does: OpenMM2 checks that one exists first (the same pick whenever + // MM2 returns at all). + bool any = false; + for (std::size_t i = 1; i < xs.size() && !any; ++i) + any = fits(xs[i]); + if (!any) + return std::nullopt; + ai::Random rng; + rng.seed(stream); + const int count = static_cast(xs.size()); + int index = 0; + for (;;) { + for (int k = 0; k < draws; ++k) + index = rng.irand() % (count - 1) + 1; + if (fits(xs[static_cast(index)])) + break; + } + stream = rng.state(); + const Vec3& c = xs[static_cast(index)].center; + return RespawnPick{{c.x, c.y + 2.0f, c.z}, 0.0f, index}; +} + +std::optional cruiseStart(const city::CityData& city, const RoomLookup& findRoom, + bool multiplayer, std::uint32_t globalSeed, std::uint32_t playerSeed) { + const RespawnRules rules{true, true}; + int& counter = respawnCounter(); + if (!multiplayer) { + std::uint32_t stream = globalSeed; + return respawnXYZ(city, findRoom, rules, stream, counter + 1); + } + // mmMultiRoam / mmMultiCR::Reset, then InitNetworkPlayers: each seeds + // the second stream with the player's id (DisableGlobalSeed, the + // global stream itself untouched) and counts one more call. + std::optional pick; + for (int call = 0; call < 2 && city.aiMap; ++call) { + std::uint32_t stream = playerSeed; + pick = respawnXYZ(city, findRoom, rules, stream, counter + 1); + counter = (counter + 1) % 100; } - for (std::size_t i = 1; i < xs.size(); ++i) - if (acceptable(xs[i])) - return xs[i].center + Vec3{0.0f, 2.0f, 0.0f}; - return std::nullopt; + return pick; } Vec3 multiplayerGridOffset(int slot, bool longVehicle) { @@ -189,9 +223,13 @@ void applyRaceTableDefaults(RaceConfig& cfg, const city::RaceDefinition* race) { return; } cfg.trafficDensity = std::clamp(s.ambientDensity, 0.0f, 1.0f); - // MM2 keeps the race's cop count in the cop density; OpenMM2's densities - // are 0..1, so the count is clamped (the slider shows full either way). - cfg.copDensity = std::clamp(static_cast(s.cops), 0.0f, 1.0f); + // RaceMenuBase::SetStateRace keeps the race's cop count (0 to 8 in the + // retail tables) in the cop density as it is: aiMap::Init clamps it to + // 0..1 when it places the posts, the slider shows anything above 1 as + // full, and the modes' RegisterFinish compare it with the count, so a + // race with two or more cops is no longer recorded once the slider has + // been moved. + cfg.copDensity = static_cast(s.cops); if (cfg.mode == GameMode::Checkpoint) { cfg.opponents = std::max(0, s.opponents); cfg.laps = 1; @@ -202,7 +240,7 @@ void applyRaceTableDefaults(RaceConfig& cfg, const city::RaceDefinition* race) { } std::optional loadRaceSetup(const RaceConfig& config, const city::CityData& city, const vfs::Vfs& vfs, - std::string* error, std::uint32_t seed) { + std::string* error) { RaceSetup s; s.config = config; const bool pro = config.difficulty == Difficulty::Professional; @@ -338,7 +376,11 @@ std::optional loadRaceSetup(const RaceConfig& config, const city::Cit for (const auto& p : s.aiMap->police) { PoliceSetup ps; ps.vehicle = str::lower(p.car); - ps.spawn = Mat34::rotationY(-p.heading * kDegToRad); + // aiRaceData::aiRaceData stores the [Police] heading (degrees) as + // heading x -0.017444445, MM2's own degree factor rather than + // pi / 180 (as aiRouteRacer::Init's for the racers), and + // aiPoliceOfficer::Reset copies it to the reset rotation. + ps.spawn = Mat34::rotationY(p.heading * -0.017444445f); ps.spawn.m3 = p.position; ps.params = p.params; s.police.push_back(std::move(ps)); @@ -346,15 +388,16 @@ std::optional loadRaceSetup(const RaceConfig& config, const city::Cit } // Player start: the first waypoint (mmWaypoints::GetStart / - // GetStartAngle). Cruise starts at a random AI intersection facing -Z - // (mmSingleRoam::InitOtherPlayers -> mmGame::RespawnXYZ); without an AI - // map, the city's first Blitz start. + // GetStartAngle). Cruise and Cops and Robbers start where the mode's + // InitGameObjects puts the car, and InitOtherPlayers then moves it to a + // random AI intersection facing -Z (mmGame::RespawnXYZ, once the AI map + // has been reset: Session::placeRespawnStart); without an AI map, the + // city's first Blitz start. // // The car is placed there (the modes' InitGameObjects: SetResetPos and // vehCar::Reset); then the race modes' InitOtherPlayers settle it on the - // ground (SimVehicle::settleOnGround), while mmSingleRoam::InitOtherPlayers leaves - // it at RespawnXYZ's point, 2 m above the intersection. - std::uint32_t rng = seed; + // ground (SimVehicle::settleOnGround), while the cruise modes leave it at + // RespawnXYZ's point, 2 m above the intersection. s.playerDrop = StartDrop::OnGround; if (!s.checkpoints.empty()) { s.playerSpawn = spawnAt(s.checkpoints.front()); @@ -364,11 +407,16 @@ std::optional loadRaceSetup(const RaceConfig& config, const city::Cit // mmGame::FindGroundPos before the one reset. if (config.multiplayer) s.playerDrop = StartDrop::FindGround; - } else if (auto p = randomIntersectionStart(city, rng)) { + } else if (city.aiMap && city.aiMap->intersections.size() >= 2) { + // mmSingleRoam::InitGameObjects: mmGame's start position, (0, 10, 0) + // (mmGame::mmGame), and mmGameSingle's angle 0; mmMultiRoam / + // mmMultiCR::InitGameObjects: the origin. + const Vec3 place = config.multiplayer ? Vec3{} : Vec3{0.0f, 10.0f, 0.0f}; s.playerSpawn = Mat34::identity(); - s.playerSpawn.m3 = *p; - s.playerPlace = {*p, 0.0f}; + s.playerSpawn.m3 = place; + s.playerPlace = {place, 0.0f}; s.playerDrop = StartDrop::None; + s.respawnStart = true; } else { // OpenMM2's fallback for a city without an AI map (RespawnXYZ would // use (0, 20, 0)): the city's first Blitz start. diff --git a/src/game/session/RaceSetup.h b/src/game/session/RaceSetup.h index 728bcec..57f96f4 100644 --- a/src/game/session/RaceSetup.h +++ b/src/game/session/RaceSetup.h @@ -103,15 +103,20 @@ struct RaceSetup { // InitOtherPlayers) and whether it is then settled on the ground. ResetPlace playerPlace; StartDrop playerDrop = StartDrop::OnGround; + // Cruise and Cops and Robbers: playerPlace is the InitGameObjects place + // (mmSingleRoam: mmGame's (0, 10, 0); mmMultiRoam / mmMultiCR: the + // origin) and the mode's InitOtherPlayers moves the car to + // mmGame::RespawnXYZ's start once aiMap::Reset has run + // (Session::placeRespawnStart). + bool respawnStart = false; }; // Loads the event described by `config`. Fails (returns std::nullopt and // sets `error`) when the race does not exist or its waypoints are missing. // // (See also applyRaceTableDefaults below.) -// `seed` drives the random parts (the cruise start). std::optional loadRaceSetup(const RaceConfig& config, const city::CityData& city, const vfs::Vfs& vfs, - std::string* error = nullptr, std::uint32_t seed = 1); + std::string* error = nullptr); // Waypoint list as mmWaypoints::LoadCSV / ReInit build it: the radius is // read as an integer (0 means 15 m) and a zero heading is replaced by the @@ -134,10 +139,52 @@ Vec3 findGroundPos(const Vec3& p, const GroundProbe& probe); // Driving direction of an Angel heading (degrees): (sin h, 0, -cos h). Vec3 headingDirection(float headingDeg); -// mmGame::RespawnXYZ: a random AI intersection (not the first), skipping -// those in underground, road or building rooms and those on freeways or -// alleys; 2 m above its centre. Nothing when the city has no AI map. -std::optional randomIntersectionStart(const city::CityData& city, std::uint32_t& rng); +// The room of a position as cityLevel::FindRoomId(position, 0) finds it +// (city::RoomLocator::find, ai::World::roomAt). +using RoomLookup = std::function; + +// mmGame::RespawnXYZ's two rules (its third and fourth arguments). +struct RespawnRules { + bool noFreeways = false; // no intersection on a freeway road (aiPath +0xc flag 0x4) + bool noCovered = false; // no subterranean or covered room (lvlRoomInfo 0x0A), no alley road (aiPath 0x2) +}; + +struct RespawnPick { + Vec3 position; // the intersection's centre, 2 m up + float angle = 0.0f; // always 0 (facing -Z) + int intersection = -1; // the AI intersection's index +}; + +// mmGame's count of the RespawnXYZ calls that draw from the player's own +// seed (multiplayer), a global that starts at 0 and is kept for the whole +// run (mod 100). Every pick draws one more number than it, multiplayer or +// not, so playing a network game changes the single-player cruise start. +int& respawnCounter(); + +// mmGame::RespawnXYZ with an AI map: draws `draws` numbers irand() % +// (intersections - 1) + 1 from `stream` (irand's seed, advanced in place) +// and takes the last, again until the intersection fits: its room +// (`findRoom` of its centre; levelRoomFlags) is neither water of death nor +// a terrain instance's (0x24), nor (noCovered) subterranean or covered +// (0x0A), and none of its roads is (noFreeways) a freeway or (noCovered) an +// alley. The start is the centre 2 m up, angle 0. Nothing when the city has +// no AI map (MM2: (0, 20, 0)), fewer than two intersections (MM2 divides +// by zero) or none that fits (MM2 never returns). +std::optional respawnXYZ(const city::CityData& city, const RoomLookup& findRoom, + RespawnRules rules, std::uint32_t& stream, int draws); + +// The cruise / Cops and Robbers start the mode's InitOtherPlayers picks +// (RaceSetup::respawnStart): +// - single player (mmSingleRoam::InitOtherPlayers): RespawnXYZ(true, true, +// false) from the one global stream, `globalSeed` being what aiMap::Reset +// left it at (ai::World::resetAndPopulate); +// - multiplayer (mmGameMulti::InitOtherPlayers): mmMultiRoam / mmMultiCR:: +// Reset and then InitNetworkPlayers each call RespawnXYZ(true, true, +// true), which draws from a second stream seeded with the local player's +// id (`playerSeed`) and advances respawnCounter(); the second pick is +// the start. +std::optional cruiseStart(const city::CityData& city, const RoomLookup& findRoom, + bool multiplayer, std::uint32_t globalSeed, std::uint32_t playerSeed); // The settings a race runs with by default (RaceMenuBase::SetStateRace, // the Crash Course page's GO): the race table's time of day, weather and diff --git a/src/game/session/Session.cpp b/src/game/session/Session.cpp index b5e1989..4cc1801 100644 --- a/src/game/session/Session.cpp +++ b/src/game/session/Session.cpp @@ -7,7 +7,6 @@ #include #include #include -#include namespace mm2::game::session { namespace { @@ -134,10 +133,7 @@ void setCheating(bool on) { g_cheating = on; } std::unique_ptr Session::create(const RaceConfig& config, const city::CityData& city, const vfs::Vfs& vfs, const Strings& strings, std::string* error, const SessionOptions& options) { - std::uint32_t seed = options.seed; - if (seed == 0) - seed = std::random_device{}() | 1u; - auto setup = loadRaceSetup(config, city, vfs, error, seed); + auto setup = loadRaceSetup(config, city, vfs, error); if (!setup) return nullptr; std::unique_ptr s(new Session()); @@ -151,6 +147,21 @@ std::unique_ptr Session::create(const RaceConfig& config, const city::C return s; } +std::optional Session::placeRespawnStart(const city::CityData& city, std::uint32_t globalSeed, + const RoomLookup& findRoom) { + if (!m_setup.respawnStart) + return std::nullopt; + const auto pick = cruiseStart(city, findRoom, multiplayer(), globalSeed, m_options.seed); + if (!pick) + return std::nullopt; + // vehCarSim::SetResetPos(start) and the reset angle (+0x250), 0. + m_setup.playerPlace = {pick->position, pick->angle}; + m_setup.playerSpawn = Mat34::rotationY(pick->angle); + m_setup.playerSpawn.m3 = pick->position; + m_respawn = m_setup.playerSpawn; + return pick; +} + std::string Session::str(std::uint32_t id, std::string_view fallback) const { return m_strings ? m_strings->get(id, fallback) : std::string(fallback); } @@ -1646,6 +1657,19 @@ void Session::update(float dt, const PlayerState& player, std::span.info ScoringBias (race score multiplier) bool skipCountdown = false; - std::uint32_t seed = 0; // random parts (the cruise start); 0 = random + // Multiplayer cruise and Cops and Robbers: the seed mmGame::RespawnXYZ + // draws the player's start from (MM2: the local player's DirectPlay id; + // OpenMM2: 1 + its network id). + std::uint32_t seed = 1; // Line of sight between two points for mmSingleStunt::CheckCopPursuit // (the original probes the level); unset = always clear. std::function lineOfSight; @@ -84,6 +87,16 @@ class Session { // Starting places. Mat34 playerSpawn() const { return m_setup.playerSpawn; } + // Cruise and Cops and Robbers (setup().respawnStart): the mode's + // InitOtherPlayers, which mmGame::Init runs after aiMap::Reset, moves + // the player's start to mmGame::RespawnXYZ's pick (cruiseStart; the + // multiplayer seed is SessionOptions::seed). `globalSeed` is MM2's + // random seed as aiMap::Reset left it (ai::World::resetAndPopulate); + // `findRoom` is the level's room lookup. Returns the pick (nothing when + // the mode has no such start); playerSpawn(), setup().playerPlace and the + // water respawn follow it. + std::optional placeRespawnStart(const city::CityData& city, std::uint32_t globalSeed, + const RoomLookup& findRoom); const std::vector& opponents() const { return m_setup.opponents; } const std::vector& police() const { return m_setup.police; } @@ -105,6 +118,11 @@ class Session { // the centre of gravity): every rule of the original tests those. void update(float dt, const PlayerState& player, std::span opponents = {}, std::span police = {}); + // A frame with the game paused (asRoot's pause: the menu, the full-screen + // map): the rules, the clocks and the AI stand still, but mmGame::Update + // still runs its fall and water checks and mmHUD::Update still counts + // the message down. + void updatePaused(float dt, const PlayerState& player); // The whole race starts over (mmGame::Reset, e.g. the pause menu's // Restart); emits Restart. void restart(); diff --git a/src/game/world/CableCars.cpp b/src/game/world/CableCars.cpp index 84d6027..75c156d 100644 --- a/src/game/world/CableCars.cpp +++ b/src/game/world/CableCars.cpp @@ -283,10 +283,19 @@ bool CableCars::nextLink(int path, int dir, int& next, int& nextDir) const { void CableCars::create(fx::Rand& random) { // aiMap::Init: the starts, in the intersections' order, then the cars. const auto& net = m_ai.map().net(); - for (int i = 0; i < static_cast(net.intersections().size()); ++i) { + struct Start { int path = -1, dir = 1; - if (!isCableCarStart(i, path, dir)) - continue; + }; + std::vector starts; + for (int i = 0; i < static_cast(net.intersections().size()); ++i) { + Start s; + if (isCableCarStart(i, s.path, s.dir)) + starts.push_back(s); + } + // The array's constructors: each aiCableCar's aiRailSet member draws its + // lane offset (frand; a cable car never reads it). + random.discard(static_cast(starts.size())); + for (const auto& [path, dir] : starts) { auto c = std::make_unique(); c->index = static_cast(m_cars.size()); // aiCableCar::Init: its start, its instance (BeginGeom of the model diff --git a/src/game/world/CableCars.h b/src/game/world/CableCars.h index 798c57a..4e33f03 100644 --- a/src/game/world/CableCars.h +++ b/src/game/world/CableCars.h @@ -84,7 +84,9 @@ class CableCars final : public InstanceSource { // aiMap::Init "Create the cable cars": a car at every cable-car start, // in intersection order, then each one's sister (DetermineSister). - // `random` gives aiCableCar::Init's frand. + // `random` is MM2's global stream after the pedestrians' draws: one + // frand per car for the array's constructors (aiRailSet), then one per + // car for aiCableCar::Init's acceleration. void create(fx::Rand& random); // aiMap::Reset: every car back to its start (aiCableCar::Reset); the // world probes the ground and finds the rooms. diff --git a/src/phys/BoundTerrain.cpp b/src/phys/BoundTerrain.cpp index e7dfd82..52dab5b 100644 --- a/src/phys/BoundTerrain.cpp +++ b/src/phys/BoundTerrain.cpp @@ -484,6 +484,24 @@ int testBoundTerrainEdgesVsPoly(const BoundTerrain& terrain, const BoundPolygona return count; } +// testBoundPolyTerrain's per-edge and per-vertex state. At namespace scope: +// MSVC cannot default-construct a function-local class with default member +// initializers inside std::vector. +constexpr std::uint16_t kNoTerrainPolygon = 0xffff; + +struct EdgeCrossings { + std::uint16_t first = kNoTerrainPolygon, second = kNoTerrainPolygon; + float tFirst = 2.0f, tSecond = -1.0f; + float depthFirst = 0.0f, depthSecond = 0.0f; +}; + +struct VertexSweep { + std::uint16_t polygon = kNoTerrainPolygon; + float t = 2.0f; + float depth = 0.0f; + Vec3 last; // the vertex at the last pose, in the world +}; + // phBoundTerrain::TestBoundPolyTerrain: the vertex sweeps (last pose to // current, when `sweep`) and edges of a polygonal bound with fewer than 31 // vertices against the terrain polygons near it, in world space. Each sweep @@ -494,18 +512,7 @@ int testBoundTerrainEdgesVsPoly(const BoundTerrain& terrain, const BoundPolygona int testBoundPolyTerrain(const BoundTerrain& terrain, const BoundPolygonal& poly, Collider* polyCollider, const Mat34& m, const Mat34& last, Intersection* out, int max, int& count, bool sweep) { - constexpr std::uint16_t kNone = 0xffff; - struct EdgeCrossings { - std::uint16_t first = kNone, second = kNone; - float tFirst = 2.0f, tSecond = -1.0f; - float depthFirst = 0.0f, depthSecond = 0.0f; - }; - struct VertexSweep { - std::uint16_t polygon = kNone; - float t = 2.0f; - float depth = 0.0f; - Vec3 last; // the vertex at the last pose, in the world - }; + constexpr std::uint16_t kNone = kNoTerrainPolygon; const int numVertices = poly.numVertices(); const int numEdges = poly.numEdges(); std::vector crossings(static_cast(numEdges)); diff --git a/src/phys/vehicle/Controls.h b/src/phys/vehicle/Controls.h index 5acf47f..702871a 100644 --- a/src/phys/vehicle/Controls.h +++ b/src/phys/vehicle/Controls.h @@ -32,10 +32,11 @@ class ArcadeControls { // The steering filter of the keyboard and gamepad (mmInput:: // FilterDiscreteSteering / FilterGamepadSteering) with the speed-sensitive // rates and response curve mmPlayer::Update sets from the player's tune -// (mmPlayer::FileIO; the retail game has no file, so the constructor's -// values). The filtered position moves towards the target (the key's full -// lock, or the stick) at DeltaOut per second when turning further the same -// way and DeltaIn per second otherwise; the car gets sign * |position|^Filter. +// (mmPlayer::FileIO reads tune/.asnode; Params holds mmPlayer's +// constructor values, which a car without the file keeps). The filtered +// position moves towards the target (the key's full lock, or the stick) at +// DeltaOut per second when turning further the same way and DeltaIn per +// second otherwise; the car gets sign * |position|^Filter. class SteeringFilter { public: struct Params { diff --git a/src/render/shaders/mesh.vert b/src/render/shaders/mesh.vert index 2afb30f..c9ae5f4 100644 --- a/src/render/shaders/mesh.vert +++ b/src/render/shaders/mesh.vert @@ -44,7 +44,11 @@ void main() { vec4 color = draw.color; if ((draw.flags & kVertexColor) != 0u) color *= aColor; - vec3 worldNormal = normalize(mat3(draw.world) * aNormal); + // Direct3D 7's fixed function takes normals through the inverse + // transpose of the world matrix and, with D3DRENDERSTATE_NORMALIZENORMALS + // off (MM2 never sets it), does not renormalise them: a scaled instance + // is lit in proportion to 1 / its scale (inferred from Direct3D). + vec3 worldNormal = transpose(inverse(mat3(draw.world))) * aNormal; if ((draw.flags & kLighting) != 0u) { vec3 light = frame.ambient.rgb; for (int i = 0; i < 3; ++i) diff --git a/src/ui/Widgets.cpp b/src/ui/Widgets.cpp index 0037473..8a4971b 100644 --- a/src/ui/Widgets.cpp +++ b/src/ui/Widgets.cpp @@ -670,8 +670,11 @@ bool Slider::adjust(UiFrame& f, int dir) { if (!enabled || readOnly) return true; // UISlider::Action: Left mmSlider::Dec, Right mmSlider::Inc, then - // "Switch", also at a limit. - set(std::clamp(get() + step() * static_cast(dir), min, max)); + // "Switch", also at a limit. The step starts from the slider's own value, + // which mmSlider::SetValue clamped when UISlider::Update took the bound + // variable (a race's cop count of 3 is 1 on the slider, so Left gives + // 1 - step). + set(std::clamp(std::clamp(get(), min, max) + step() * static_cast(dir), min, max)); f.play("Switch", 0.85f); m_clicked = 0; return true; diff --git a/tests/ai/test_ai.cpp b/tests/ai/test_ai.cpp index 7a21ec3..a07e286 100644 --- a/tests/ai/test_ai.cpp +++ b/tests/ai/test_ai.cpp @@ -250,6 +250,8 @@ TEST(AiWorld, DeterministicAndWellBehaved) { int redRuns = 0; std::vector redFor(a->signals().size(), 0.0f); std::map wasTurning; + std::map wasEntered; + std::map committedBeforeRed; // the lane a car committed on before red, else -1 for (int i = 0; i < 30 * 120; ++i) { a->step(player, {}); b->step(player, {}); @@ -260,9 +262,22 @@ TEST(AiWorld, DeterministicAndWellBehaved) { net.lanes()[static_cast(d.lane)].line.project(car.transform.m3, &dist); maxLaneError = std::max(maxLaneError, dist); } + // A car that committed to the intersection (aiVehicleSpline's + // enterInt) before its light turned red goes on whatever the + // light does next, however long the queue ahead keeps it. + if (d.entered && !wasEntered[car.id] && d.lane >= 0) { + const auto& lane = net.lanes()[static_cast(d.lane)]; + const bool red = + lane.lightSlot >= 0 && redFor[static_cast(lane.lightSlot)] > 0.0f; + committedBeforeRed[car.id] = red ? -1 : d.lane; + } + wasEntered[car.id] = d.entered; if (d.turning && !wasTurning[car.id] && d.lane >= 0) { const auto& lane = net.lanes()[static_cast(d.lane)]; - if (lane.lightSlot >= 0 && redFor[static_cast(lane.lightSlot)] > 2.0f) + const auto it = committedBeforeRed.find(car.id); + const bool committed = it != committedBeforeRed.end() && it->second == d.lane; + if (lane.lightSlot >= 0 && redFor[static_cast(lane.lightSlot)] > 2.0f && + !committed) ++redRuns; } wasTurning[car.id] = d.turning; diff --git a/tests/ai/test_parity_conventions.cpp b/tests/ai/test_parity_conventions.cpp new file mode 100644 index 0000000..eda7a87 --- /dev/null +++ b/tests/ai/test_parity_conventions.cpp @@ -0,0 +1,24 @@ +// Round 3 of the parity audit, conventions: frames built from vectors face +// the way the vectors point (Angel convention: objects face -Z). See +// docs/parity/round3/conventions.md. + +#include "ai/Traffic.h" + +#include + +using namespace mm2; + +// The stand-in player car of mm2tool's aisim and the tests faces along its +// velocity: its forward axis -m2 is the velocity's direction. +TEST(ConventionsParity, PlayerCarFacesItsVelocity) { + for (const Vec3 v : {Vec3{0, 0, -10}, Vec3{10, 0, 0}, Vec3{-3, 0, 4}}) { + const ai::PlayerCar p = ai::PlayerCar::at({1, 2, 3}, v); + const Vec3 forward = -p.transform.m2; + const Vec3 dir = v * (1.0f / v.mag()); + EXPECT_NEAR(forward.x, dir.x, 1e-5f); + EXPECT_NEAR(forward.z, dir.z, 1e-5f); + EXPECT_EQ(p.transform.m3, (Vec3{1, 2, 3})); + } + // Still: facing -Z. + EXPECT_NEAR(ai::PlayerCar::at({}, {}).transform.m2.z, 1.0f, 1e-6f); +} diff --git a/tests/ai/test_parity_mm2_ai.cpp b/tests/ai/test_parity_mm2_ai.cpp index 3571862..ab15239 100644 --- a/tests/ai/test_parity_mm2_ai.cpp +++ b/tests/ai/test_parity_mm2_ai.cpp @@ -409,3 +409,34 @@ TEST(ParityMm2Ai, WorldWithoutARaceMapOrTraffic) { world->update(1.0f / 30.0f, {430, 0, -150}, {}); EXPECT_TRUE(world->cars().empty()); } + +// aiTrafficLightInstance::Init places a traffic light at its pole's base +// plus R * CG (the model's dgBangerData CG), the frame its CG-centred body is +// drawn with; DrawGlow takes R * CG off again for the glows, which are +// modelled from the base. +TEST(MM2AiParity, TrafficLightsStandOnTheirCG) { + ai::Signal s; + s.transform = Mat34::rotationY(0.5f); + s.transform.m3 = {10.0f, 2.0f, -4.0f}; + s.cg = {0.5f, 3.892483f, -0.25f}; + const Mat34 f = s.frame(); + EXPECT_EQ(f.m0.x, s.transform.m0.x); + EXPECT_EQ(f.m2.z, s.transform.m2.z); + const Vec3 expect = s.transform.transform(s.cg); + EXPECT_FLOAT_EQ(f.m3.x, expect.x); + EXPECT_FLOAT_EQ(f.m3.y, 2.0f + 3.892483f); + EXPECT_FLOAT_EQ(f.m3.z, expect.z); + EXPECT_FLOAT_EQ(s.position().y, f.m3.y); + + MM2_REQUIRE_GAME_DATA(); + auto c = city::loadCity(*test::gameData(), "sf"); + ASSERT_TRUE(c); + auto world = ai::World::create(*c, *test::gameData(), {}); + ASSERT_TRUE(world); + ASSERT_FALSE(world->signals().empty()); + for (const auto& sig : world->signals()) { + // sp_traflitsingle_f / sp_traflitdual_f: CG half the pole's height. + EXPECT_NEAR(sig.cg.y, 3.89f, 0.02f) << sig.model; + EXPECT_EQ(sig.cg.x, 0.0f) << sig.model; + } +} diff --git a/tests/ai/test_parity_random_streams.cpp b/tests/ai/test_parity_random_streams.cpp new file mode 100644 index 0000000..1ff9807 --- /dev/null +++ b/tests/ai/test_parity_random_streams.cpp @@ -0,0 +1,265 @@ +// Round 3 parity checks for MM2's one global random stream (gRandSeed) in the +// AI: aiMap::Init draws for the ambient pool and then the pedestrians from +// the stream as the race's set-up leaves it, aiMap::Reset sets it to 1 and +// populates the traffic and then the pedestrians from it. See +// docs/parity/round3/random-streams.md. +#include "ai/Pedestrians.h" +#include "ai/Random.h" +#include "ai/RoadNetwork.h" +#include "ai/Traffic.h" +#include "ai/TrafficLights.h" +#include "ai/World.h" + +#include + +#include + +using namespace mm2; + +namespace { + +// A straight two-way road from `a` to `b` with one lane per side and a +// sidewalk on each side, flat (path flag 0x8). +city::AiPath road(int id, Vec3 a, Vec3 b, std::uint32_t nodeA, std::uint32_t nodeB) { + constexpr int kSections = 4; + city::AiPath p; + p.id = static_cast(id); + p.flags = 0x8; + p.halfWidth = 7.0f; + const Vec3 d = (b - a).normalized(); + const Vec3 left{d.z, 0.0f, -d.x}; + for (int i = 0; i < kSections; ++i) { + const float t = static_cast(i) / static_cast(kSections - 1); + p.center.push_back(lerp(a, b, t)); + p.xAxis.push_back(left); + p.yAxis.push_back(Vec3::yAxis()); + p.zAxis.push_back(-d); + p.wAxis.push_back(d); + if (i > 0) + p.centerLengths.push_back(a.dist(b) * t); + } + auto side = [&](float sign, bool reversed) { + city::AiRoadSide s; + s.numLanes = 1; + s.numSidewalks = 1; + for (float offset : {2.0f, 5.5f, 4.0f, 7.0f}) { // lane, sidewalk, curb, edge + std::vector line; + for (int i = 0; i < kSections; ++i) { + const int k = offset == 2.0f && reversed ? kSections - 1 - i : i; + line.push_back(p.center[static_cast(k)] + left * (sign * offset)); + } + s.polylines.push_back(line); + } + s.params = {-4.0f, 0.0f, 4.0f, 7.0f}; + return s; + }; + p.left = side(1.0f, true); + p.right = side(-1.0f, false); + p.ends[0].intersection = nodeB; + p.ends[1].intersection = nodeA; + p.ends[0].vehicleRule = 3; + p.ends[1].vehicleRule = 3; + return p; +} + +// A square block, 200 m a side; room 1 lists all four roads. +city::AiMap square() { + city::AiMap map; + const Vec3 corner[4] = {{0, 0, 0}, {0, 0, -200}, {200, 0, -200}, {200, 0, 0}}; + for (int k = 0; k < 4; ++k) + map.paths.push_back(road(k, corner[k], corner[(k + 1) % 4], static_cast(k), + static_cast((k + 1) % 4))); + for (int k = 0; k < 4; ++k) + map.intersections.push_back( + {static_cast(k), 0, corner[k], + {static_cast((k + 3) % 4), static_cast(k)}}); + for (auto& node : map.intersections) + for (std::size_t k = 0; k < node.paths.size(); ++k) + for (auto& e : map.paths[node.paths[k]].ends) + if (e.intersection == node.id) + e.roadIndex = static_cast(k); + map.roomPathsNear = {{}, {0, 1, 2, 3}}; + map.roomPathsIn = {{}, {0, 1, 2, 3}}; + return map; +} + +ai::VehicleData sedan() { + ai::VehicleData d; + d.model = "test"; + d.size = {2.0f, 1.5f, 4.5f}; + return d; +} + +// Two pedestrian types, each with three clothing variants. +std::vector walkers() { + std::vector out; + for (const char* name : {"pedmodel_a", "pedmodel_b"}) { + ai::PedTypeInfo t; + t.name = name; + t.variants = 3; + asset::PedAnimState walk; + walk.name = "WALK"; + walk.animFile = "walk"; + walk.firstFrame = 1; + walk.lastFrame = 20; + walk.forwardDistance = 1.4f; + walk.next = "WALK"; + t.table.states.push_back(walk); + out.push_back(std::move(t)); + } + return out; +} + +ai::PedSettings pedSettings(int pool) { + ai::PedSettings s; + s.pool = pool; + s.names = {"pedmodel_a", "pedmodel_b"}; + return s; +} + +std::uint32_t advanced(std::uint32_t state, int draws) { + ai::Random r(state); + r.discard(draws); + return r.state(); +} + +const ai::PlayerCar kFarPlayer = ai::PlayerCar::at({5000, 0, 5000}, {}); + +} // namespace + +TEST(ParityRandomStreams, GeneratorIsMsvcRand) { + // irand from seed 1: 41, 18467, 6334 (MSVC rand()); frand = irand / 32768. + ai::Random r(1); + EXPECT_EQ(r.irand(), 41); + EXPECT_EQ(r.irand(), 18467); + EXPECT_FLOAT_EQ(r.frand(), 6334.0f / 32768.0f); + ai::Random a(1), b(1); + a.discard(3); + for (int i = 0; i < 3; ++i) + b.irand(); + EXPECT_EQ(a.state(), b.state()); +} + +// aiMap::Init on one stream: the ambient pool's constructors (two draws a +// car), each car's type, SetColor twice and aiGoalRandomDrive (four a car), +// then each pedestrian's type and aiPedestrian::Init's variant (two each), +// from where the traffic left the stream. +TEST(ParityRandomStreams, AiMapInitDrawsTrafficThenPedestrians) { + const auto map = square(); + const auto net = ai::RoadNetwork::build(map, {}); + ai::TrafficLights lights; + lights.build(net); + ai::TrafficSettings traffic; + traffic.poolSize = 9; + constexpr std::uint32_t kStart = 0x1234567u; + ai::Random stream(kStart); + ai::Traffic cars(net, lights, {sedan()}, traffic, stream); + EXPECT_EQ(stream.state(), advanced(kStart, 6 * traffic.poolSize)); + // The paint of car i is the third of its four draws. + ai::Random expected(advanced(kStart, 2 * traffic.poolSize)); + std::vector paints; + for (int i = 0; i < traffic.poolSize; ++i) { + expected.discard(2); + paints.push_back(expected.frand()); + expected.discard(1); + } + cars.populateAll(); + cars.step(ai::kAiStepSeconds, kFarPlayer, 1); + ASSERT_FALSE(cars.cars().empty()); + for (const auto& c : cars.cars()) + EXPECT_FLOAT_EQ(c.paint, paints[static_cast(c.id)]); + + constexpr int kPeds = 6; + ai::Random pedStream(advanced(kStart, 6 * traffic.poolSize)); + ai::Pedestrians peds(net, walkers(), pedSettings(kPeds), pedStream); + EXPECT_EQ(pedStream.state(), advanced(kStart, 6 * traffic.poolSize + 2 * kPeds)); + ai::Random pick(advanced(kStart, 6 * traffic.poolSize)); + std::vector> types; // (type, variant) + for (int i = 0; i < kPeds; ++i) { + const int type = static_cast(pick.frand() * 2.0f); + const int variant = static_cast(pick.frand() * 2.0f); // variants - 1 + types.emplace_back(type, variant); + } + peds.populateAll(); + peds.step(ai::kAiStepSeconds, kFarPlayer, 1); + ASSERT_EQ(peds.peds().size(), static_cast(kPeds)); + for (const auto& p : peds.peds()) { + EXPECT_EQ(p.type, types[static_cast(p.id)].first) << p.id; + EXPECT_EQ(p.variant, types[static_cast(p.id)].second) << p.id; + } +} + +// MM2 builds the ambient array of the state's vehicle count before it looks +// at the density: at density 0 there are no cars, but the constructors still +// drew (two each). +TEST(ParityRandomStreams, NoTrafficStillDrawsForThePool) { + const auto map = square(); + const auto net = ai::RoadNetwork::build(map, {}); + ai::TrafficLights lights; + lights.build(net); + ai::TrafficSettings traffic; + traffic.density = 0.0f; + traffic.poolSize = 300; + ai::Random stream(5); + ai::Traffic cars(net, lights, {sedan()}, traffic, stream); + EXPECT_EQ(stream.state(), advanced(5, 2 * 300)); + cars.step(ai::kAiStepSeconds, kFarPlayer, 1); + EXPECT_TRUE(cars.cars().empty()); +} + +// aiMap::Reset: the seed back to 1, then AdjustAmbients and +// AdjustPedestrians round the player, before anything updates. On one stream +// the pedestrians are placed from where the traffic's placing left it. +TEST(ParityRandomStreams, ResetPlacesTrafficThenPedestriansFromSeedOne) { + const auto map = square(); + const auto net = ai::RoadNetwork::build(map, {}); + ai::TrafficLights lights; + lights.build(net); + ai::TrafficSettings traffic; + traffic.poolSize = 20; + constexpr int kPeds = 8; + + ai::Random stream(0xABCDEFu); + ai::Traffic cars(net, lights, {sedan()}, traffic, stream); + ai::Pedestrians peds(net, walkers(), pedSettings(kPeds), stream); + stream.seed(1); // aiMap::Reset's ResetRandomSeed + cars.reset(); + peds.reset(); + ASSERT_TRUE(cars.populate(1)); + const std::uint32_t afterTraffic = stream.state(); + ASSERT_TRUE(peds.populate(1)); + EXPECT_FALSE(cars.populate(1)); // once per reset + + // The same traffic without pedestrians is placed alike: the + // pedestrians' draws all come after the traffic's. + ai::Random aloneStream(0xABCDEFu); + ai::Traffic alone(net, lights, {sedan()}, traffic, aloneStream); + aloneStream.seed(1); + alone.reset(); + alone.step(0.0f, kFarPlayer, 1); + cars.step(0.0f, kFarPlayer, 1); + ASSERT_EQ(alone.cars().size(), cars.cars().size()); + ASSERT_FALSE(cars.cars().empty()); + for (std::size_t i = 0; i < cars.cars().size(); ++i) { + EXPECT_EQ(alone.cars()[i].id, cars.cars()[i].id); + EXPECT_EQ(alone.cars()[i].transform.m3, cars.cars()[i].transform.m3); + } + // The pedestrians on a stream of their own from that state are placed + // where the shared ones were. + ai::Pedestrians pedsAlone(net, walkers(), pedSettings(kPeds), afterTraffic); + pedsAlone.reset(); + pedsAlone.step(0.0f, kFarPlayer, 1); + peds.step(0.0f, kFarPlayer, 1); + ASSERT_EQ(pedsAlone.peds().size(), peds.peds().size()); + ASSERT_FALSE(peds.peds().empty()); + for (std::size_t i = 0; i < peds.peds().size(); ++i) { + EXPECT_EQ(pedsAlone.peds()[i].id, peds.peds()[i].id); + EXPECT_EQ(pedsAlone.peds()[i].transform.m3, peds.peds()[i].transform.m3); + EXPECT_TRUE(peds.peds()[i].placed); + } + // A shared stream is its owner's to seed: reset() leaves it alone. + stream.seed(77); + cars.reset(); + peds.reset(); + EXPECT_EQ(stream.state(), 77u); +} diff --git a/tests/app/test_parity_conventions.cpp b/tests/app/test_parity_conventions.cpp new file mode 100644 index 0000000..f447317 --- /dev/null +++ b/tests/app/test_parity_conventions.cpp @@ -0,0 +1,82 @@ +// Round 3 of the parity audit, conventions: tuning values reach the code +// that uses them (MM2Recomp, build 3393). See +// docs/parity/round3/conventions.md. + +#include "TestData.h" +#include "app/GameInput.h" +#include "data/DatFile.h" +#include "vfs/Vfs.h" + +#include + +#include + +using namespace mm2; +using namespace mm2::app::controls; + +// mmPlayer::FileIO: the player node's tune/.asnode replaces the +// constructor's steering values field by field; fields it lacks keep them. +TEST(ConventionsParity, PlayerTuneReplacesTheSteeringDefaults) { + constexpr std::string_view text = R"(type: a +asNode { + SpeedSensitive 2 + SpeedBaseLow 5.000000 + MouseSensitivityLow 1.300000 + JoySensitivityLow 2.500000 + SpeedBaseHi 44.600002 + DiscreteSteeringDeltaOutLo 2.573000 + DiscreteSteeringDeltaInLo 5.000000 + DiscreteSteeringFilterLo 1.200000 + DiscreteSteeringDeltaOutHi 0.800000 + JoyApp 1 + JoySteerApproachOutLo 2.572000 + JoySteerAppApp 0.127000 + WheelSensitivityHi 2.000000 + WheelApp 0 + ScoreWeight 1.000000 +} +)"; + auto dat = data::parseDat(text); + ASSERT_TRUE(dat && dat->top()); + GameInput in; + const phys::SteeringFilter::Params defaults; + const AnalogSteering analogDefaults; + in.setPlayerTune(*dat->top()); + for (const auto* p : {&in.discreteSteering(), &in.padSteering()}) { + EXPECT_EQ(p->speedSensitive, 2); + EXPECT_FLOAT_EQ(p->speedBaseHi, 44.600002f); + EXPECT_FLOAT_EQ(p->deltaOutLo, 2.573f); + EXPECT_FLOAT_EQ(p->deltaInLo, 5.0f); + EXPECT_FLOAT_EQ(p->filterLo, 1.2f); + EXPECT_FLOAT_EQ(p->deltaOutHi, 0.8f); + EXPECT_FLOAT_EQ(p->deltaInHi, defaults.deltaInHi); // not in the file + EXPECT_FLOAT_EQ(p->filterHi, defaults.filterHi); + } + const AnalogSteering& a = in.analogSteering(); + EXPECT_FLOAT_EQ(a.speedBaseHi, 44.600002f); + EXPECT_FLOAT_EQ(a.mouseSensitivityLow, 1.3f); + EXPECT_FLOAT_EQ(a.mouseSensitivityHi, analogDefaults.mouseSensitivityHi); + EXPECT_FLOAT_EQ(a.joystick.sensitivityLow, 2.5f); + EXPECT_TRUE(a.joystick.approach); + EXPECT_FLOAT_EQ(a.joystick.approachOutLow, 2.572f); + EXPECT_FLOAT_EQ(a.joystick.approachGrowth, 0.127f); + EXPECT_FLOAT_EQ(a.wheel.sensitivityHi, 2.0f); + EXPECT_FALSE(a.wheel.approach); +} + +// The retail cars ship the file (vpbug's: SpeedBaseHi 44.6, keyboard +// DeltaOutLo 2.573); two do not and keep mmPlayer's constructor values. +TEST(ConventionsParity, RetailPlayerTunes) { + MM2_REQUIRE_GAME_DATA(); + const vfs::Vfs& vfs = *test::gameData(); + auto bytes = vfs.readAll("tune/vpbug.asnode"); + ASSERT_TRUE(bytes); + auto dat = data::parseDat(std::string_view(reinterpret_cast(bytes->data()), bytes->size())); + ASSERT_TRUE(dat && dat->top()); + GameInput in; + in.setPlayerTune(*dat->top()); + EXPECT_FLOAT_EQ(in.discreteSteering().speedBaseHi, 44.600002f); + EXPECT_FLOAT_EQ(in.discreteSteering().deltaOutLo, 2.573f); + EXPECT_TRUE(in.analogSteering().joystick.approach); + EXPECT_FALSE(vfs.exists("tune/vpcentury.asnode")); +} diff --git a/tests/audio/test_parity_mm2_audio.cpp b/tests/audio/test_parity_mm2_audio.cpp index 77ad8e5..2bed1f6 100644 --- a/tests/audio/test_parity_mm2_audio.cpp +++ b/tests/audio/test_parity_mm2_audio.cpp @@ -151,8 +151,9 @@ TEST(AudioParityMm2, AnnouncerQueueCountsTwiceAFrameWhileRunning) { ASSERT_EQ(line, "al1pre"); // queued for 1.5 s }; { - // Running: GameLoop's AudManager::Update and mmGame::Update both - // update the queue, so 1.5 s pass in 0.75 s. + // Running: one AudManager::Update and mmGame::Update both update the + // queue, so 1.5 s pass in 0.75 s. A whole frame runs AudManager::Update + // twice (AudioOrderParity.AnnouncerQueueCountsThreeTimesAFrameWhileRunning). Announcer a; queuePreRace(a); AudioManager manager; diff --git a/tests/audio/test_parity_order.cpp b/tests/audio/test_parity_order.cpp new file mode 100644 index 0000000..122c33c --- /dev/null +++ b/tests/audio/test_parity_order.cpp @@ -0,0 +1,121 @@ +// Parity checks of the order in which MM2 updates its audio (MM2Recomp, +// build 3393): AudManager::Update runs twice a frame, once from GameLoop and +// once as asRoot's first node. See docs/parity/round3/order.md. +#include "audio/AngelRandom.h" +#include "audio/Mixer.h" +#include "audio/SoundBank.h" +#include "audio/game/AudioManager.h" +#include "audio/game/Voices.h" +#include "core/File.h" +#include "vfs/DirectoryFs.h" + +#include + +#include +#include +#include +#include +#include +#include + +using namespace mm2; +using namespace mm2::audio; +using namespace mm2::audio::game; + +namespace { + +std::vector wav(int frames) { + std::vector out; + auto put = [&](const void* p, std::size_t n) { + const auto* b = static_cast(p); + out.insert(out.end(), b, b + n); + }; + const std::uint32_t rate = 22050, data = static_cast(frames) * 2, size = 36 + data; + const std::uint32_t fmt = 16, bytes = rate * 2; + const std::uint16_t pcm = 1, mono = 1, align = 2, bits = 16; + const std::int16_t level = 8000; + put("RIFF", 4), put(&size, 4), put("WAVE", 4), put("fmt ", 4), put(&fmt, 4), put(&pcm, 2), put(&mono, 2); + put(&rate, 4), put(&bytes, 4), put(&align, 2), put(&bits, 2), put("data", 4), put(&data, 4); + for (int i = 0; i < frames; ++i) + put(&level, 2); + return out; +} + +// A throwaway game-data tree with one-second 22 kHz sounds and text files. +struct OrderData { + std::filesystem::path root; + vfs::Vfs vfs; + OrderData(std::initializer_list> files, + std::initializer_list sounds) { + root = std::filesystem::temp_directory_path() / + ("openmm2_audio_order_" + std::to_string(std::rand()) + + std::to_string(reinterpret_cast(this))); + for (const auto& [path, text] : files) + file::writeAtomic(root / path, text); + for (const char* s : sounds) { + file::writeAtomic(root / "aud/aud22" / (std::string(s) + ".22k.wav"), + std::span(wav(22050))); + file::writeAtomic(root / "aud/aud11" / (std::string(s) + ".11k.wav"), + std::span(wav(22050))); + } + vfs.mount(std::make_shared(root)); + } + ~OrderData() { + std::error_code ec; + std::filesystem::remove_all(root, ec); + } +}; + +} // namespace + +// GameLoop calls AudManager::Update, then asRoot::Update calls it again as +// its first node: AudManagerBase::UpdatePaused's two stops (its counter +// limit of 1) both come in the first paused frame. +TEST(AudioOrderParity, BothPausedStopsComeInTheFirstPausedFrame) { + Mixer mixer(48000); + AudioManager manager; + EXPECT_FALSE(manager.updateFrame(false, mixer, nullptr, 0.03f)); + EXPECT_EQ(manager.pausedUpdates(), 0); + EXPECT_TRUE(manager.updateFrame(true, mixer, nullptr, 0.03f)); + EXPECT_EQ(manager.pausedUpdates(), 2); + EXPECT_FALSE(manager.updateFrame(true, mixer, nullptr, 0.03f)); + EXPECT_EQ(manager.pausedUpdates(), 2); + // Running again: the counter goes back to 0. + EXPECT_FALSE(manager.updateFrame(false, mixer, nullptr, 0.03f)); + EXPECT_EQ(manager.pausedUpdates(), 0); +} + +// With mmGame::Update's own mmSpeechContainer::Update, the announcer's +// queue counts three times a frame while the game runs: the pre-race line, +// queued for 1.5 s, starts after 0.5 s. +TEST(AudioOrderParity, AnnouncerQueueCountsThreeTimesAFrameWhileRunning) { + const char* blitz = + "Name prefix/type header,end sufix value,sufix add value\nPRERACE header,,\nPRE,2,0\n"; + OrderData data({{"aud/spchdata/london.csv", "Num announcers\n1\nprefix\nAL\n"}, + {"aud/spchdata/al1/blitz.csv", blitz}}, + {"al1pre01", "al1pre02"}); + SoundBank bank(data.vfs); + Mixer mixer(48000); + Announcer a; + ASSERT_TRUE(a.load(data.vfs, bank, mixer, "london")); + a.beginRace(AnnouncerMode::Blitz, {}, 1, 4); + std::string line; + for (int i = 0; i < 40 && line.empty(); ++i) { + setRandomizeSeedSource([i] { return 1000 + i; }); + line = a.playPreRace(); + } + setRandomizeSeedSource({}); + ASSERT_EQ(line, "al1pre"); // queued for 1.5 s + AudioManager manager; + auto frame = [&] { + manager.updateFrame(false, mixer, &a, 0.125f); + a.update(0.125f); // mmGame::Update + }; + for (int i = 0; i < 3; ++i) { + frame(); + EXPECT_EQ(mixer.activeVoices(), 0) << i; // up to 1.125 s counted + } + frame(); + EXPECT_EQ(mixer.activeVoices(), 1); // 1.5 s counted in 0.5 s: the line starts + a.stop(); +} diff --git a/tests/game/test_opponent_race.cpp b/tests/game/test_opponent_race.cpp index 1d6dfda..9fe176a 100644 --- a/tests/game/test_opponent_race.cpp +++ b/tests/game/test_opponent_race.cpp @@ -665,7 +665,7 @@ TEST(OpponentRace, RecoversWhenFacingAWall) { namespace { // A suspect (a player-tuned car driven by an Opponent along a road route, -// flagged as the player) starts 70 m behind a parked cop and drives past it +// flagged as the player) starts 75 m behind a parked cop and drives past it // at up to `speed` m/s, keeping to `slowSpeed` after `slowAfter` seconds. struct ChaseOptions { float speed = 20.0f; @@ -704,7 +704,7 @@ ChaseResult runChase(const char* plotName, const ChaseOptions& opt) { const ai::RoadNetwork& net = cw->ai->network(); // The first [Police] car of roam.aimap on a road that runs straight - // through its post: the suspect starts 70 m behind the cop (out of its + // through its post: the suspect starts 75 m behind the cop (out of its // view) and drives past it. const game::session::PoliceSetup* post = nullptr; Vec3 suspectStart, ahead; @@ -712,7 +712,7 @@ ChaseResult runChase(const char* plotName, const ChaseOptions& opt) { for (const auto& p : setup->police) { phys::RayHit hit; const Vec3 fwd = -p.spawn.m2; - const Vec3 behind = p.spawn.m3 - fwd * 70.0f, front = p.spawn.m3 + fwd * 30.0f; + const Vec3 behind = p.spawn.m3 - fwd * 75.0f, front = p.spawn.m3 + fwd * 30.0f; if (!ai::locateOnRoads(net, behind).onRoad || !ai::locateOnRoads(net, front).onRoad || std::abs(behind.y - p.spawn.m3.y) > 2.0f || std::abs(front.y - p.spawn.m3.y) > 2.0f || cw->world->probe(behind + Vec3{0, 1, 0}, front + Vec3{0, 1, 0}, hit)) @@ -892,7 +892,7 @@ TEST(PoliceChase, ApprehendsAMovingSuspect) { TEST(PoliceChase, IgnoresACarBehindIt) { MM2_REQUIRE_GAME_DATA(); - // The suspect stays 70 m behind the cop: outside its field of view. + // The suspect stays 75 m behind the cop: outside its field of view. ChaseOptions opt; opt.parked = true; opt.seconds = 10.0f; diff --git a/tests/game/test_parity_camera_props_bangers.cpp b/tests/game/test_parity_camera_props_bangers.cpp index f08672e..c754db2 100644 --- a/tests/game/test_parity_camera_props_bangers.cpp +++ b/tests/game/test_parity_camera_props_bangers.cpp @@ -301,7 +301,8 @@ TEST(ParityBangersRetail, PkgXrefsBecomeBangers) { ASSERT_EQ(placed.size(), 2u); for (std::size_t i = 0; i < placed.size(); ++i) { EXPECT_EQ(placed[i].model, "sp_light_red_f"); - EXPECT_TRUE(placed[i].fullMatrix); + // RequestBanger(name, 0): Y rotation only (round 3, conventions). + EXPECT_FALSE(placed[i].fullMatrix); EXPECT_EQ(placed[i].room, 0); EXPECT_EQ(placed[i].roomHint, parliament->room); const Vec3 expect = parliament->transform.transform(xrefs[i].transform.m3); diff --git a/tests/game/test_parity_conventions.cpp b/tests/game/test_parity_conventions.cpp new file mode 100644 index 0000000..4d480bf --- /dev/null +++ b/tests/game/test_parity_conventions.cpp @@ -0,0 +1,168 @@ +// Round 3 of the parity audit, conventions: values that cross from MM2's +// data files into the game keep their units, signs and axes (MM2Recomp, +// build 3393). See docs/parity/round3/conventions.md. + +#include "TestData.h" +#include "city/CityData.h" +#include "game/Profile.h" +#include "game/bangers/BangerData.h" +#include "game/bangers/PropPlacement.h" +#include "game/Strings.h" +#include "game/session/RaceSetup.h" +#include "game/session/Session.h" +#include "phys/vehicle/CarSim.h" +#include "vfs/GameSource.h" + +#include + +#include +#include +#include + +using namespace mm2; +using namespace mm2::game; +using namespace mm2::game::session; + +namespace { + +struct ConventionsRetail { + city::CityData london; + Strings strings; +}; + +ConventionsRetail* conventionsRetail() { + static std::unique_ptr r = []() -> std::unique_ptr { + if (!test::gameData()) + return nullptr; + auto src = vfs::probeGameSource(std::getenv("OPENMM2_GAME_DATA")); + auto london = city::loadCity(*test::gameData(), "london"); + if (!src || !london) + return nullptr; + auto out = std::make_unique(); + out->london = std::move(*london); + out->strings = Strings::load(*src); + return out; + }(); + return r.get(); +} + +std::unique_ptr londonSession(GameMode mode, int index) { + RaceConfig cfg; + cfg.mode = mode; + cfg.city = "london"; + cfg.raceIndex = index; + SessionOptions options; + options.seed = 11; + std::string error; + const ConventionsRetail& r = *conventionsRetail(); + auto s = Session::create(cfg, r.london, *test::gameData(), r.strings, &error, options); + EXPECT_TRUE(s) << error; + return s; +} + +} // namespace + +// aiRaceData::aiRaceData: a [Police] heading in degrees becomes the reset +// rotation heading x -0.017444445 (not pi / 180). London's checkpoint race +// 3 posts a cop at -195 degrees. +TEST(ConventionsParity, PoliceHeadingUsesMM2DegreeFactor) { + MM2_REQUIRE_GAME_DATA(); + if (!conventionsRetail()) + GTEST_SKIP() << "retail data incomplete"; + auto s = londonSession(GameMode::Checkpoint, 3); + ASSERT_TRUE(s); + ASSERT_GE(s->police().size(), 2u); + const Mat34& post = s->police()[0].spawn; + const float a = -195.0f * -0.017444445f; + EXPECT_FLOAT_EQ(post.m2.x, std::sin(a)); + EXPECT_FLOAT_EQ(post.m2.z, std::cos(a)); + EXPECT_FLOAT_EQ(post.m3.x, 250.610474f); + EXPECT_FLOAT_EQ(post.m3.z, 521.594055f); + // The reset rotation the car gets back from the post is that angle + // (wrapped into (-pi, pi]). + EXPECT_NEAR(phys::resetRotationOf(post), a - 2.0f * kPi, 1e-5f); +} + +// RaceMenuBase::SetStateRace keeps the race table's cop count (not a 0..1 +// density) in the cop density; aiMap::Init clamps it when it places the +// posts, and RegisterFinish compares the setting with the count. +TEST(ConventionsParity, RaceCopDensityIsTheTablesCount) { + MM2_REQUIRE_GAME_DATA(); + if (!conventionsRetail()) + GTEST_SKIP() << "retail data incomplete"; + const city::RaceDefinition* race = nullptr; + for (const auto& r : conventionsRetail()->london.races) + if (r.mode == city::RaceMode::Checkpoint && r.index == 3) + race = &r; + ASSERT_TRUE(race && race->settings); + ASSERT_EQ(race->settings->amateur.cops, 2); // London mmracedata.csv, race 3 + RaceConfig cfg; + cfg.mode = GameMode::Checkpoint; + cfg.city = "london"; + cfg.raceIndex = 3; + applyRaceTableDefaults(cfg, race); + EXPECT_EQ(cfg.copDensity, 2.0f); + EXPECT_TRUE(Progress::recordable(cfg, cfg)); + // The slider moved (it can only hold 0..1): no longer the race's setting. + RaceConfig played = cfg; + played.copDensity = 1.0f; + EXPECT_FALSE(Progress::recordable(played, cfg)); +} + +// lvlLevel::LoadInstances requests a PKG xref's banger with +// RequestBanger(name, 0), a dgUnhitYBangerInstance: San Francisco's +// sp_awning_4_f is exported Z up, and MM2 keeps only its turn about Y. +TEST(ConventionsParity, XrefBangersKeepOnlyTheirYTurn) { + MM2_REQUIRE_GAME_DATA(); + const vfs::Vfs& v = *test::gameData(); + auto sf = city::loadCity(v, "sf"); + ASSERT_TRUE(sf); + const city::Instance* record = nullptr; + for (const auto& inst : sf->instances) + if (inst.name == "nw_b4awn_ff01_8_f") + record = &inst; + ASSERT_TRUE(record); + bangers::BangerDataLibrary lib(v); + const auto placed = bangers::placeXrefs(*record, bangers::pkgXrefs(v, "nw_b4awn_ff01_8_f"), lib); + ASSERT_FALSE(placed.empty()); + for (const auto& p : placed) { + EXPECT_EQ(p.model, "sp_awning_4_f"); + EXPECT_FALSE(p.fullMatrix); + EXPECT_NEAR(p.transform.m1.y, 0.0f, 1e-3f); // exported Z up + } +} + +// asBirthRule::Load skips the block's name before it reads the rule by +// position: sp_tree1_s_break06 calls its block "asBirthRule" and throws +// leaves all the same. +TEST(ConventionsParity, BangerBirthRuleBlockOfAnyName) { + constexpr std::string_view text = R"(type: a +dgBangerData { + AudioId 0 + Size 0.2 0.5 0.2 + CG 0 0 0 + Mass 1 + NumParts 0 + asBirthRule { + SpewRate 1.24 + InitialBlast 25 + } + TexNumber 5 +} +)"; + std::string error; + const auto d = bangers::parseBangerData("sp_tree1_s_break06", text, &error); + ASSERT_TRUE(d) << error; + ASSERT_TRUE(d->birthRule); + EXPECT_EQ(d->birthRule->initialBlast, 25); + EXPECT_FLOAT_EQ(d->birthRule->spewRate, 1.24f); +} + +TEST(ConventionsParity, RetailTreePartThrowsLeaves) { + MM2_REQUIRE_GAME_DATA(); + bangers::BangerDataLibrary lib(*test::gameData()); + const bangers::BangerData* d = lib.find("sp_tree1_s_break06"); + ASSERT_TRUE(d); + ASSERT_TRUE(d->birthRule); + EXPECT_EQ(d->birthRule->initialBlast, 25); +} diff --git a/tests/game/test_parity_cruise_spawn.cpp b/tests/game/test_parity_cruise_spawn.cpp new file mode 100644 index 0000000..9951814 --- /dev/null +++ b/tests/game/test_parity_cruise_spawn.cpp @@ -0,0 +1,359 @@ +// The cruise start against MM2's own code (MM2Recomp, build 3393): +// mmGame::RespawnXYZ, mmSingleRoam / mmMultiRoam / mmMultiCR's +// InitOtherPlayers and Reset, and the draws aiMap::Reset makes from the one +// random stream before them. See docs/parity/round3/cruise-spawn.md. + +#include "TestData.h" +#include "ai/Random.h" +#include "ai/World.h" +#include "city/CityData.h" +#include "city/RoomInfo.h" +#include "city/RoomLocator.h" +#include "city/SdlDraw.h" +#include "game/PlayerVehicle.h" +#include "game/RaceConfig.h" +#include "game/Strings.h" +#include "game/session/RaceSetup.h" +#include "game/session/Session.h" + +#include + +#include +#include + +using namespace mm2; +using namespace mm2::game; +using namespace mm2::game::session; + +namespace { + +// Sets mmGame's respawn counter for a test and puts it back afterwards. +struct CounterScope { + int saved; + explicit CounterScope(int value) : saved(respawnCounter()) { respawnCounter() = value; } + ~CounterScope() { respawnCounter() = saved; } +}; + +// A city of `n` intersections in rooms 1..n (intersection i in room i), one +// road each, intersection i at (10 i, 0, 0). +city::CityData syntheticCity(int n) { + city::CityData c; + c.aiMap.emplace(); + c.levelRoomFlags.assign(static_cast(n + 1), 0); + for (int i = 0; i < n; ++i) { + city::AiIntersection x; + x.id = static_cast(i); + x.room = static_cast(i + 1); + x.center = {10.0f * static_cast(i), 0.0f, 0.0f}; + x.paths = {static_cast(i)}; + c.aiMap->intersections.push_back(x); + city::AiPath p; + p.id = static_cast(i); + c.aiMap->paths.push_back(p); + } + return c; +} + +RoomLookup syntheticRooms() { + return [](const Vec3& p) { return static_cast(p.x / 10.0f + 0.5f) + 1; }; +} + +// RespawnXYZ's draw: irand() % (n - 1) + 1. +int draw(ai::Random& r, int n) { return r.irand() % (n - 1) + 1; } + +struct Retail { + city::CityData london, sf; +}; + +Retail* retail() { + static std::unique_ptr r = []() -> std::unique_ptr { + if (!test::gameData()) + return nullptr; + auto london = city::loadCity(*test::gameData(), "london"); + auto sf = city::loadCity(*test::gameData(), "sf"); + if (!london || !sf) + return nullptr; + auto out = std::make_unique(); + out->london = std::move(*london); + out->sf = std::move(*sf); + return out; + }(); + return r.get(); +} + +// The number of irand() calls from seed 1 (ResetRandomSeed) to `seed`. +int drawsFromOne(std::uint32_t seed) { + ai::Random r; + int n = 0; + while (r.state() != seed && n < 100000) { + r.irand(); + ++n; + } + return n; +} + +// mmGame::Init for a single-player cruise in `city` with the menu's cruise +// settings (RaceMenuBase::SetStateRace: traffic 0.5, pedestrians 0.25) in +// vpbug: the seed aiMap::Reset leaves with the car at mmSingleRoam's +// InitGameObjects place, mmGame's (0, 10, 0). +std::uint32_t cruiseResetSeed(const city::CityData& city) { + RaceConfig cfg; + cfg.mode = GameMode::Cruise; + applyRaceTableDefaults(cfg, nullptr); + ai::Settings settings; + settings.trafficDensity = cfg.trafficDensity; + settings.pedestrianDensity = cfg.pedestrianDensity; + std::string error; + const city::AiMapConfig* config = city.cruise ? &*city.cruise : nullptr; + auto world = ai::World::create(city, *test::gameData(), settings, config, &error); + EXPECT_TRUE(world) << error; + auto car = SimVehicle::loadPlayer(*test::gameData(), cfg.vehicle, &error, true); + EXPECT_TRUE(car) << error; + if (!world || !car) + return 0; + car->setResetPos({0.0f, 10.0f, 0.0f}, 0.0f); + car->reset(); + world->resetAndPopulate(car->sim().resetPos()); + return world->random().state(); +} + +} // namespace + +// mmGame::RespawnXYZ: each pick is the last of (counter + 1) draws +// irand() % (n - 1) + 1 (never intersection 0), repeated until one fits; the +// start is its centre 2 m up, angle 0. +TEST(ParityCruiseSpawn, RespawnDrawsCounterPlusOneAndRetries) { + auto city = syntheticCity(6); + const RoomLookup rooms = syntheticRooms(); + for (int draws : {1, 2, 5}) { + for (std::uint32_t seed : {1u, 7u, 12345u}) { + ai::Random expect; + expect.seed(seed); + int index = 0; + for (int k = 0; k < draws; ++k) + index = draw(expect, 6); + std::uint32_t stream = seed; + const auto pick = respawnXYZ(city, rooms, {true, true}, stream, draws); + ASSERT_TRUE(pick); + EXPECT_EQ(pick->intersection, index); + EXPECT_EQ(pick->position, Vec3(10.0f * static_cast(index), 2.0f, 0.0f)); + EXPECT_EQ(pick->angle, 0.0f); + EXPECT_EQ(stream, expect.state()); + } + } +} + +// The rules: rooms flagged 0x24 always fail, 0x0A with the second argument; +// a freeway road (aiPath flag 0x4) with the first, an alley (0x2) with the +// second. A failed pick draws again. +TEST(ParityCruiseSpawn, RespawnRejectsRoomsAndRoads) { + auto city = syntheticCity(3); + const RoomLookup rooms = syntheticRooms(); + auto pickWith = [&](RespawnRules rules) { + std::uint32_t stream = 1; + return respawnXYZ(city, rooms, rules, stream, 1)->intersection; + }; + // Seed 1 draws 2 first (irand: 41). + ai::Random r; + std::vector seq; + for (int k = 0; k < 5; ++k) + seq.push_back(draw(r, 3)); + ASSERT_EQ(seq.front(), 2); + const int first = seq[0]; + int second = -1; + for (int v : seq) + if (v != first) { + second = v; + break; + } + ASSERT_NE(second, -1); + EXPECT_EQ(pickWith({}), first); + for (std::uint16_t flags : {city::LevelRoomFlag::WaterOfDeath, city::LevelRoomFlag::TerrainInstance}) { + city.levelRoomFlags[static_cast(first) + 1] = flags; + EXPECT_EQ(pickWith({}), second) << flags; + } + for (std::uint16_t flags : {city::LevelRoomFlag::Subterranean, city::LevelRoomFlag::Covered}) { + city.levelRoomFlags[static_cast(first) + 1] = flags; + EXPECT_EQ(pickWith({}), first) << flags; + EXPECT_EQ(pickWith({false, true}), second) << flags; + } + // Bridge (0x10), warp (0x40) and open road (0x01) rooms are fine. + city.levelRoomFlags[static_cast(first) + 1] = 0x51; + EXPECT_EQ(pickWith({true, true}), first); + city.levelRoomFlags[static_cast(first) + 1] = 0; + city.aiMap->paths[static_cast(first)].flags = 0x4; + EXPECT_EQ(pickWith({false, true}), first); + EXPECT_EQ(pickWith({true, false}), second); + city.aiMap->paths[static_cast(first)].flags = 0x2; + EXPECT_EQ(pickWith({true, false}), first); + EXPECT_EQ(pickWith({false, true}), second); + // Every intersection but the first fails: MM2 would never return. + city.aiMap->paths[1].flags = city.aiMap->paths[2].flags = 0x6; + std::uint32_t stream = 1; + EXPECT_FALSE(respawnXYZ(city, rooms, {true, true}, stream, 1)); + auto tiny = syntheticCity(1); + EXPECT_FALSE(respawnXYZ(tiny, rooms, {}, stream, 1)); +} + +// mmMultiRoam / mmMultiCR: Reset and then InitNetworkPlayers each draw from +// the player's own seed and count one more seeded call; the start is the +// second pick. The count stays for the run, so a later single-player start +// draws more numbers a pick. +TEST(ParityCruiseSpawn, MultiplayerStartIsTheSecondSeededPick) { + CounterScope scope(0); + auto city = syntheticCity(9); + const RoomLookup rooms = syntheticRooms(); + const std::uint32_t player = 1234; + std::uint32_t stream = player; + const auto second = respawnXYZ(city, rooms, {true, true}, stream, 2); + const auto pick = cruiseStart(city, rooms, true, 999, player); + ASSERT_TRUE(pick && second); + EXPECT_EQ(pick->intersection, second->intersection); + EXPECT_EQ(respawnCounter(), 2); + // Single player: the global seed, 3 draws a pick now, the counter kept. + stream = 77; + const auto three = respawnXYZ(city, rooms, {true, true}, stream, 3); + const auto single = cruiseStart(city, rooms, false, 77, player); + ASSERT_TRUE(single && three); + EXPECT_EQ(single->intersection, three->intersection); + EXPECT_EQ(respawnCounter(), 2); + // mod 100. + respawnCounter() = 99; + cruiseStart(city, rooms, true, 0, player); + EXPECT_EQ(respawnCounter(), 1); +} + +// CreateRoadMap moves the centre of every intersection a shortcut road of +// _sup.bai joins to its room's bound-sphere centre +// (cityLevel::GetBoundSphere); RespawnXYZ starts there. +TEST(ParityCruiseSpawn, ShortcutEndsTakeTheRoomCentre) { + MM2_REQUIRE_GAME_DATA(); + ASSERT_TRUE(retail()); + for (const auto* city : {&retail()->london, &retail()->sf}) { + const auto& map = *city->aiMap; + ASSERT_GT(map.numShortcuts, 0u); + std::set ends; + for (std::size_t i = map.paths.size() - map.numShortcuts; i < map.paths.size(); ++i) + for (const auto& e : map.paths[i].ends) + ends.insert(e.intersection); + for (const auto i : ends) { + const auto& x = map.intersections[i]; + Vec3 centre; + float radius = 0.0f; + city::sdlRoomBoundSphere(city->psdl, x.room, centre, radius); + EXPECT_EQ(x.center, centre) << city->info.mapName << " " << i; + } + } +} + +// RespawnXYZ's room is cityLevel::FindRoomId of the intersection's centre, +// not the room the .bai gives it: San Francisco's intersection 187 lists room +// 1078 (an open road) but its centre lies in room 265, a terrain instance's +// (0x20), so it is never a start. +TEST(ParityCruiseSpawn, RespawnRoomIsTheCentresRoom) { + MM2_REQUIRE_GAME_DATA(); + ASSERT_TRUE(retail()); + const auto& sf = retail()->sf; + const city::RoomLocator rooms(sf.psdl, "sf"); + const auto& x = sf.aiMap->intersections[187]; + EXPECT_EQ(x.room, 1078); + EXPECT_EQ(sf.levelRoomFlags[1078] & 0x2E, 0); + ASSERT_EQ(rooms.find(x.center), 265); + EXPECT_EQ(sf.levelRoomFlags[265], city::LevelRoomFlag::TerrainInstance); + const RoomLookup findRoom = [&](const Vec3& p) { return rooms.find(p); }; + std::uint32_t stream = 1; + for (int i = 0; i < 2000; ++i) + EXPECT_NE(respawnXYZ(sf, findRoom, {true, true}, stream, 1)->intersection, 187); +} + +// aiMap::Reset in mmGame::Init (ResetRandomSeed, then AdjustAmbients and +// AdjustPedestrians for the room of the car's reset position, (0, 10, 0) + +// its CG): London's room 65 holds 28 ambient roads and 13 pedestrian roads, +// San Francisco's room 265 three ambient roads and none for pedestrians. With +// the cruise menu's densities the ambient cars draw 73 and 21 numbers (one +// link choice each) and the 25 pedestrians 4 each. +TEST(ParityCruiseSpawn, ResetDrawsBeforeTheStart) { + MM2_REQUIRE_GAME_DATA(); + ASSERT_TRUE(retail()); + const city::RoomLocator london(retail()->london.psdl, "london"), sf(retail()->sf.psdl, "sf"); + EXPECT_EQ(london.find({0.0f, 9.9f, 0.0f}), 65); + EXPECT_EQ(sf.find({0.0f, 9.9f, 0.0f}), 265); + EXPECT_EQ(drawsFromOne(cruiseResetSeed(retail()->london)), 73 + 25 * 4); + EXPECT_EQ(drawsFromOne(cruiseResetSeed(retail()->sf)), 21); +} + +// The default single-player cruise starts (MM2 picks the same one every time +// in a city until a network game changes the respawn counter): +// - London: intersection 19, the road along the north side of Marble Arch's +// island in front of the Underground station; +// - San Francisco: intersection 121, on the Embarcadero, about 160 m south +// of the Ferry Building. +TEST(ParityCruiseSpawn, LondonAndSanFranciscoStarts) { + MM2_REQUIRE_GAME_DATA(); + ASSERT_TRUE(retail()); + CounterScope scope(0); + struct Expect { + const city::CityData* city; + int intersection; + Vec3 centre; + }; + for (const Expect& e : {Expect{&retail()->london, 19, {-542.529f, 4.934f, -676.292f}}, + Expect{&retail()->sf, 121, {40.306f, -0.082f, 153.295f}}}) { + const city::RoomLocator rooms(e.city->psdl, e.city->info.mapName); + const RoomLookup findRoom = [&](const Vec3& p) { return rooms.find(p); }; + const std::uint32_t seed = cruiseResetSeed(*e.city); + const auto pick = cruiseStart(*e.city, findRoom, false, seed, 1); + ASSERT_TRUE(pick) << e.city->info.mapName; + EXPECT_EQ(pick->intersection, e.intersection) << e.city->info.mapName; + const Vec3& centre = e.city->aiMap->intersections[static_cast(e.intersection)].center; + EXPECT_NEAR(centre.x, e.centre.x, 1e-3f); + EXPECT_NEAR(centre.y, e.centre.y, 1e-3f); + EXPECT_NEAR(centre.z, e.centre.z, 1e-3f); + EXPECT_EQ(pick->position, centre + Vec3(0.0f, 2.0f, 0.0f)); + EXPECT_EQ(pick->angle, 0.0f); + + // The session: (0, 10, 0) until InitOtherPlayers, then the start, + // which the water respawn and restarts keep. + RaceConfig cfg; + cfg.mode = GameMode::Cruise; + cfg.city = e.city->info.mapName; + applyRaceTableDefaults(cfg, nullptr); + Strings strings; + std::string error; + auto s = Session::create(cfg, *e.city, *test::gameData(), strings, &error); + ASSERT_TRUE(s) << error; + EXPECT_TRUE(s->setup().respawnStart); + EXPECT_EQ(s->setup().playerDrop, StartDrop::None); + EXPECT_EQ(s->playerSpawn().m3, Vec3(0.0f, 10.0f, 0.0f)); + const auto placed = s->placeRespawnStart(*e.city, seed, findRoom); + ASSERT_TRUE(placed); + EXPECT_EQ(placed->intersection, e.intersection); + EXPECT_EQ(s->playerSpawn().m3, pick->position); + EXPECT_EQ(s->setup().playerPlace.angle, 0.0f); + EXPECT_EQ(s->playerSpawn().m2, Vec3(0.0f, 0.0f, 1.0f)); // facing -Z + s->start(); + s->restart(); + EXPECT_EQ(s->playerSpawn().m3, pick->position); + } +} + +// The car on the start: vehCarSim::SetResetPos adds the CG unrotated, the +// reset angle 0 turns nothing, and vehCar::Reset puts the body there with +// the model at body + R * CG; cruise does not settle it on the ground. +TEST(ParityCruiseSpawn, TheCarStandsOnTheStart) { + MM2_REQUIRE_GAME_DATA(); + std::string error; + auto car = SimVehicle::loadPlayer(*test::gameData(), "vpbug", &error, true); + ASSERT_TRUE(car) << error; + const Vec3 start{-542.529f, 6.934f, -676.292f}; + car->setResetPos(start, 0.0f); + car->reset(); + const Vec3 cg = car->sim().centerOfGravity; + EXPECT_EQ(car->sim().resetPos(), Vec3(cg.x + start.x, cg.y + start.y, cg.z + start.z)); + const auto& body = car->sim().body.ics.matrix; + EXPECT_EQ(body.m3, car->sim().resetPos()); + EXPECT_EQ(body.m0, Vec3(1.0f, 0.0f, 0.0f)); + EXPECT_EQ(body.m2, Vec3(0.0f, 0.0f, 1.0f)); + const Mat34 model = car->sim().modelMatrix(); + EXPECT_NEAR(model.m3.y, body.m3.y + cg.y, 1e-4f); +} diff --git a/tests/game/test_parity_game_flow.cpp b/tests/game/test_parity_game_flow.cpp index 9911f33..abda676 100644 --- a/tests/game/test_parity_game_flow.cpp +++ b/tests/game/test_parity_game_flow.cpp @@ -5,12 +5,12 @@ #include "TestData.h" #include "city/CityData.h" +#include "city/RoomLocator.h" #include "game/Strings.h" #include "game/bangers/BangerData.h" #include "game/session/CopsAndRobbers.h" #include "game/session/RaceSetup.h" #include "game/session/Session.h" -#include "phys/World.h" #include "vfs/GameSource.h" #include @@ -26,14 +26,14 @@ using namespace mm2::game::session; namespace { // A probe of the plane y = `height` (the level as dgPhysManager::Collide sees -// it, for the tests). +// it, for the tests), worked out as phys::FlatGround::probe does. MSVC 14.51's +// optimiser crashes (C1001) on a FlatGround made inside this lambda. GroundProbe planeProbe(float height) { return [height](const Vec3& from, const Vec3& to) -> std::optional { - phys::FlatGround ground(height); - phys::RayHit hit; - if (!ground.probe(from, to, hit)) + const float da = from.y - height, db = to.y - height; + if ((da > 0 && db > 0) || (da < 0 && db < 0) || da == db) return std::nullopt; - return hit.position; + return from + (to - from) * (da / (da - db)); }; } @@ -104,7 +104,8 @@ TEST(GameFlowParity, FindGroundPosProbesSevenAndAHalfUpFifteenDown) { } // Where each mode puts the player (retail data): the race modes on the first -// waypoint with its angle, settled on the ground; cruise 2 m above a random +// waypoint with its angle, settled on the ground; cruise first at mmGame's +// (0, 10, 0) (mmSingleRoam::InitGameObjects), then 2 m above a random // intersection, facing -Z, as it is (mmSingleRoam::InitOtherPlayers). TEST(GameFlowParity, PlayerPlacePerMode) { MM2_REQUIRE_GAME_DATA(); @@ -122,6 +123,11 @@ TEST(GameFlowParity, PlayerPlacePerMode) { auto cruise = flowSession(GameMode::Cruise, -1); ASSERT_TRUE(cruise); EXPECT_EQ(cruise->setup().playerDrop, StartDrop::None); + EXPECT_TRUE(cruise->setup().respawnStart); + EXPECT_EQ(cruise->setup().playerPlace.position, Vec3(0.0f, 10.0f, 0.0f)); + const city::RoomLocator rooms(flowRetail()->london.psdl, "london"); + const RoomLookup findRoom = [&](const Vec3& p) { return rooms.find(p); }; + ASSERT_TRUE(cruise->placeRespawnStart(flowRetail()->london, 1, findRoom)); EXPECT_EQ(cruise->setup().playerPlace.angle, 0.0f); bool atIntersection = false; for (const auto& x : flowRetail()->london.aiMap->intersections) diff --git a/tests/game/test_parity_order.cpp b/tests/game/test_parity_order.cpp new file mode 100644 index 0000000..bea6b54 --- /dev/null +++ b/tests/game/test_parity_order.cpp @@ -0,0 +1,146 @@ +// Parity checks of the order in which MM2 updates its systems (MM2Recomp, +// build 3393): what still runs while the game is paused (asRoot's pause: +// mmGame::Update's fall and water checks, mmHUD::Update's message timer). +// See docs/parity/round3/order.md. + +#include "TestData.h" +#include "city/CityData.h" +#include "game/Strings.h" +#include "game/session/Session.h" +#include "vfs/GameSource.h" + +#include + +#include +#include + +using namespace mm2; +using namespace mm2::game; +using namespace mm2::game::session; + +namespace { + +struct OrderRetail { + city::CityData london; + Strings strings; +}; + +OrderRetail* orderRetail() { + static std::unique_ptr r = []() -> std::unique_ptr { + if (!test::gameData()) + return nullptr; + auto src = vfs::probeGameSource(std::getenv("OPENMM2_GAME_DATA")); + auto london = city::loadCity(*test::gameData(), "london"); + if (!src || !london) + return nullptr; + auto out = std::make_unique(); + out->london = std::move(*london); + out->strings = Strings::load(*src); + return out; + }(); + return r.get(); +} + +std::unique_ptr orderSession(GameMode mode, int index) { + RaceConfig cfg; + cfg.mode = mode; + cfg.city = "london"; + cfg.raceIndex = index; + SessionOptions options; + options.seed = 5; + std::string error; + auto s = Session::create(cfg, orderRetail()->london, *test::gameData(), orderRetail()->strings, &error, + options); + EXPECT_TRUE(s) << error; + return s; +} + +int countEvents(const std::vector& events, EventType type) { + int n = 0; + for (const auto& e : events) + n += e.type == type; + return n; +} + +constexpr float kDt = 1.0f / 30.0f; + +} // namespace + +// mmHUD::Update is a node of the game, which asNode::Update visits whether +// or not asRoot is paused: the message counts down through a pause, while +// the countdown (the mode's UpdateGame) and its clocks (mmTimer) stand still. +TEST(OrderParity, PausedFrameCountsTheMessageDownNotTheRace) { + MM2_REQUIRE_GAME_DATA(); + if (!orderRetail()) + GTEST_SKIP() << "retail data incomplete"; + auto s = orderSession(GameMode::Blitz, 0); + ASSERT_TRUE(s); + PlayerState player; + player.transform = s->playerSpawn(); + s->start(); + // Run the countdown until it shows a line. + for (int i = 0; i < 400 && s->message().timeLeft == 0.0f; ++i) + s->update(kDt, player); + ASSERT_EQ(s->phase(), Phase::Countdown); + ASSERT_GT(s->message().timeLeft, 0.0f); + const float clock = s->timeRemaining(); + const float raceTime = s->raceTime(); + const float shownFor = s->message().timeLeft; + for (float t = 0.0f; t < shownFor + 0.5f; t += kDt) + s->updatePaused(kDt, player); + EXPECT_EQ(s->message().timeLeft, 0.0f); + EXPECT_TRUE(s->message().text.empty()); + EXPECT_EQ(s->phase(), Phase::Countdown); + EXPECT_EQ(s->timeRemaining(), clock); + EXPECT_EQ(s->raceTime(), raceTime); +} + +// mmGame::Update runs its water check after the IsPaused test that skips +// UpdateGame: the water timer runs on through a pause and the handler fires +// (cruise: mmSingleRoam::HitWaterHandler restarts the game). +TEST(OrderParity, PausedFrameRunsTheWaterAndFallChecks) { + MM2_REQUIRE_GAME_DATA(); + if (!orderRetail()) + GTEST_SKIP() << "retail data incomplete"; + auto s = orderSession(GameMode::Cruise, -1); + ASSERT_TRUE(s); + PlayerState player; + player.transform = s->playerSpawn(); + s->start(); + s->update(kDt, player); + (void)s->takeEvents(); + player.inWater = true; + std::vector events; + float restartAt = -1.0f; + for (float t = 0.0f; t < 6.0f && restartAt < 0.0f; t += kDt) { + s->updatePaused(kDt, player); + for (auto& e : s->takeEvents()) { + events.push_back(e); + if (e.type == EventType::Restart) + restartAt = t; + } + } + EXPECT_EQ(countEvents(events, EventType::HitWater), 1); + EXPECT_EQ(countEvents(events, EventType::Restart), 1); + EXPECT_NEAR(restartAt, 5.0f, 0.1f); // five seconds in the water, paused or not + // Falling through the city (mmGame::DropThruCityHandler) too. + player.inWater = false; + player.transform.m3.y = -60.0f; + s->updatePaused(kDt, player); + EXPECT_EQ(countEvents(s->takeEvents(), EventType::Restart), 1); +} + +// Nothing runs before the session starts (mmGame::Init has not run). +TEST(OrderParity, PausedFrameBeforeTheStartDoesNothing) { + MM2_REQUIRE_GAME_DATA(); + if (!orderRetail()) + GTEST_SKIP() << "retail data incomplete"; + auto s = orderSession(GameMode::Cruise, -1); + ASSERT_TRUE(s); + (void)s->takeEvents(); + PlayerState player; + player.transform = s->playerSpawn(); + player.transform.m3.y = -60.0f; + s->updatePaused(kDt, player); + EXPECT_TRUE(s->takeEvents().empty()); +} diff --git a/tests/game/test_parity_random_streams.cpp b/tests/game/test_parity_random_streams.cpp new file mode 100644 index 0000000..ccba567 --- /dev/null +++ b/tests/game/test_parity_random_streams.cpp @@ -0,0 +1,291 @@ +// Round 3 parity checks for MM2's one global random stream (gRandSeed) +// through a race's set-up, on retail data: cityLevel::Load's street props +// (seed 1 before every road) leave the stream as the last road's walk does; +// mmPlayer::Init's vehCar::Init, mmGame::InitGizmos, the racers' cars, +// aiMap::Init's ambient pool and pedestrians and the cable cars draw from it +// in that order; aiMap::Reset sets it to 1 and places the traffic, then the +// pedestrians. See docs/parity/round3/random-streams.md. +#include "TestData.h" + +#include "ai/Random.h" +#include "ai/World.h" +#include "city/CityData.h" +#include "game/CityLevel.h" +#include "game/RaceConfig.h" +#include "game/VehicleRenderer.h" +#include "game/bangers/BangerData.h" +#include "game/bangers/BangerSet.h" +#include "game/bangers/PropPlacement.h" +#include "game/world/CableCars.h" +#include "game/world/Gizmos.h" +#include "game/PlayerVehicle.h" +#include "city/RoomLocator.h" +#include "game/session/RaceSetup.h" +#include "phys/World.h" + +#include + +#include +#include +#include + +using namespace mm2; +using namespace mm2::game; + +namespace { + +std::uint32_t advanced(std::uint32_t state, int draws) { + ai::Random r(state); + r.discard(draws); + return r.state(); +} + +// mmGame::Init's set-up of a single-player cruise in `cityName` (no racers), +// in the order RaceScreen makes it. +struct RaceSetUp { + std::optional city; + std::unique_ptr data; + std::unique_ptr level; + std::unique_ptr physics; + std::unique_ptr bangers; + std::unique_ptr gizmos; + std::unique_ptr ai; + std::unique_ptr cableCars; + ai::Random random{1u}; + std::uint32_t afterProps = 0, playerFlares = 0, afterPlayer = 0, afterGizmos = 0, afterAi = 0, + afterCableCars = 0; + std::size_t parkedCars = 0; + + bool load(const vfs::Vfs& v, const char* cityName, float traffic = 1.0f, float peds = 1.0f) { + city = city::loadCity(v, cityName); + if (!city) + return false; + data = std::make_unique(v); + level = std::make_unique(*city, v, [&](std::string_view n) { return data->has(n); }); + physics = std::make_unique(level->takeMaterials()); + physics->setStatic(level->takeProbeSoup()); + physics->setLevel(level.get()); + // cityLevel::Load. + bangers = std::make_unique(*data); + std::uint32_t state = 1; + bangers->add(bangers::placeCityProps(*city, v, *data, "roam", &state)); + afterProps = state; + random.seed(state); + // mmPlayer::Init. + playerFlares = takeVehCarInitDraws(random); + afterPlayer = random.state(); + // mmGame::InitGizmos. + RaceConfig config; + config.city = cityName; + const std::size_t before = bangers->instances().size(); + gizmos = world::initGizmos(v, *city, config, false, *bangers, *data, level.get(), random); + parkedCars = bangers->instances().size() - before; + afterGizmos = random.state(); + // aiMap::Init: no racers in cruise; the ambient pool and the + // pedestrians at full density, then the cable cars. + ai::Settings settings; + settings.trafficDensity = traffic; + settings.pedestrianDensity = peds; + settings.random = &random; + ai = ai::World::create(*city, v, settings); + if (!ai) + return false; + afterAi = random.state(); + cableCars = std::make_unique(*ai, *data, *bangers); + cableCars->create(random); + afterCableCars = random.state(); + return true; + } + + // aiMap::Reset and the first step with the player at the intersection + // with the most lights (a fixed stand-in for the cruise start, which + // mmGame::RespawnXYZ draws from the same stream). + void start() { + ai->reset(); + const auto& net = ai->network(); + std::size_t busiest = 0; + for (std::size_t i = 0; i < net.intersections().size(); ++i) + if (net.intersections()[i].lights.size() > net.intersections()[busiest].lights.size()) + busiest = i; + ai->step(ai::PlayerCar::at(net.intersections()[busiest].centre, {})); + } +}; + +} // namespace + +// The stream after cityLevel::Load is the last road's walk from seed 1 (or +// 1 when that road has no props): a PSDL holding only the last road leaves +// it the same. Walking the roads without sidewalks too places nothing more +// (the cities' 10,184 street props). +TEST(ParityRandomStreamsRetail, StreetPropsLeaveTheLastRoadsState) { + MM2_REQUIRE_GAME_DATA(); + const auto& v = *test::gameData(); + struct Expected { + const char* city; + std::size_t props; + std::uint32_t state; + }; + for (const auto& [name, count, expected] : + {Expected{"london", 4835, 1u}, Expected{"sf", 5349, 2380015889u}}) { + const auto city = city::loadCity(v, name); + ASSERT_TRUE(city) << name; + const std::string dir = std::string("city/") + name + "/"; + const auto defBytes = v.readAll(dir + "propdefs.csv"); + const auto ruleBytes = v.readAll(dir + "proprules.csv"); + ASSERT_TRUE(defBytes && ruleBytes) << name; + auto text = [](const std::vector& b) { + return std::string_view(reinterpret_cast(b.data()), b.size()); + }; + const auto defs = bangers::parsePropDefs(text(*defBytes)); + const auto rules = bangers::parsePropRules(text(*ruleBytes)); + std::uint32_t state = 0; + const auto props = bangers::placeStreetProps(city->psdl, defs, rules, &state); + EXPECT_EQ(props.size(), count) << name; + EXPECT_EQ(state, expected) << name; + city::Psdl last = city->psdl; + last.roads = {city->psdl.roads.back()}; + std::uint32_t lastState = 0; + bangers::placeStreetProps(last, defs, rules, &lastState); + EXPECT_EQ(state, lastState) << name; + } +} + +// London cruise: the stream through the set-up, and the traffic and +// pedestrians it decides. +TEST(ParityRandomStreamsRetail, LondonCruiseSetUp) { + MM2_REQUIRE_GAME_DATA(); + RaceSetUp s; + ASSERT_TRUE(s.load(*test::gameData(), "london")); + // London's last road has no props: the player's car draws from 1. + EXPECT_EQ(s.afterProps, 1u); + EXPECT_EQ(s.playerFlares, 1u); + EXPECT_EQ(s.afterPlayer, advanced(1u, kVehCarInitDraws)); + // Sixteen sailboats (paint, speed), six ferries (speed), 488 parked + // cars (two draws each, one for each point left empty). + EXPECT_EQ(s.gizmos->sailboats().size(), 16u); + EXPECT_EQ(s.gizmos->ferries().size(), 6u); + EXPECT_EQ(s.parkedCars, 488u); + EXPECT_EQ(s.afterGizmos, 1960625569u); + // aiMap::Init: 300 ambient cars (two constructor draws each, then four + // each), 100 pedestrians (London's [Ped Pool] is commented out; two + // draws each), no cable cars. + EXPECT_EQ(s.afterAi, advanced(s.afterGizmos, 6 * ai::kAmbientPoolSize + 2 * 100)); + EXPECT_EQ(s.afterCableCars, s.afterAi); + + s.start(); + const auto& cars = s.ai->cars(); + ASSERT_GE(cars.size(), 3u); + for (const auto& c : cars) { + // Car i's paint is the third of its four set-up draws. + ai::Random r(advanced(s.afterGizmos, 2 * ai::kAmbientPoolSize + 4 * c.id + 2)); + EXPECT_FLOAT_EQ(c.paint, r.frand()) << c.id; + } + struct Car { + int id; + const char* model; + float paint; + }; + const Car expectedCars[] = {{198, "va_eurocargo_l", 0.255981445f}, + {199, "va_eurocargo_l", 0.557220459f}, + {200, "va_2sitersport_l", 0.655487061f}}; + for (std::size_t i = 0; i < std::size(expectedCars); ++i) { + EXPECT_EQ(cars[i].id, expectedCars[i].id); + EXPECT_EQ(cars[i].model, expectedCars[i].model); + EXPECT_FLOAT_EQ(cars[i].paint, expectedCars[i].paint); + } + const auto& peds = s.ai->peds(); + ASSERT_GE(peds.size(), 3u); + struct Ped { + int id; + const char* type; + int variant; + }; + const Ped expectedPeds[] = {{0, "pedmodel_man", 37}, {1, "pedmodel_woman", 4}, {2, "pedmodel_man", 32}}; + for (std::size_t i = 0; i < std::size(expectedPeds); ++i) { + EXPECT_EQ(peds[i].id, expectedPeds[i].id); + EXPECT_EQ(peds[i].typeName, expectedPeds[i].type); + EXPECT_EQ(peds[i].variant, expectedPeds[i].variant); + } + + // A restart (aiMap::Reset from mmGame::Reset) places them all again + // exactly as at the start. + std::vector> first; + for (const auto& c : cars) + first.emplace_back(c.id, c.transform.m3); + std::vector> firstPeds; + for (const auto& p : peds) + firstPeds.emplace_back(p.id, p.transform.m3); + for (int i = 0; i < 300; ++i) + s.ai->step(ai::PlayerCar::at(s.ai->network().intersections()[0].centre, {})); + s.start(); + ASSERT_EQ(s.ai->cars().size(), first.size()); + for (std::size_t i = 0; i < first.size(); ++i) { + EXPECT_EQ(s.ai->cars()[i].id, first[i].first); + EXPECT_EQ(s.ai->cars()[i].transform.m3, first[i].second); + } + ASSERT_EQ(s.ai->peds().size(), firstPeds.size()); + for (std::size_t i = 0; i < firstPeds.size(); ++i) { + EXPECT_EQ(s.ai->peds()[i].id, firstPeds[i].first); + EXPECT_EQ(s.ai->peds()[i].transform.m3, firstPeds[i].second); + } +} + +// San Francisco: the street props leave the stream where its last road's +// walk does; the cable cars draw twice each (aiRailSet's constructor, then +// aiCableCar::Init) after the pedestrians. +TEST(ParityRandomStreamsRetail, SanFranciscoCableCarsDrawAfterThePedestrians) { + MM2_REQUIRE_GAME_DATA(); + RaceSetUp s; + ASSERT_TRUE(s.load(*test::gameData(), "sf")); + EXPECT_EQ(s.afterProps, 2380015889u); + EXPECT_EQ(s.afterPlayer, advanced(s.afterProps, kVehCarInitDraws)); + // The parked cars from there (90 from seed 1, as OpenMM2 had them). + EXPECT_EQ(s.parkedCars, 102u); + const int cableCars = static_cast(s.cableCars->size()); + ASSERT_GT(cableCars, 0); + EXPECT_EQ(s.afterCableCars, advanced(s.afterAi, 2 * cableCars)); +} + +// mmGame::RespawnXYZ draws the cruise start from the stream as the first +// aiMap::Reset left it, and that reset starts with ResetRandomSeed: the +// set-up's draws before it (props, the player's car, gizmos, aiMap::Init) do +// not reach the start. With the cruise menu's densities (traffic 0.5, +// pedestrians 0.25) and the car at mmGame's (0, 10, 0), the shared stream +// gives the cruise-spawn record's starts, and the same stream state as a +// world whose stream began anywhere else. +TEST(ParityRandomStreamsRetail, CruiseStartIgnoresTheDrawsBeforeTheReset) { + MM2_REQUIRE_GAME_DATA(); + const int saved = session::respawnCounter(); + session::respawnCounter() = 0; + struct Expected { + const char* city; + int intersection; + }; + for (const auto& [name, intersection] : {Expected{"london", 19}, Expected{"sf", 121}}) { + RaceSetUp s; + ASSERT_TRUE(s.load(*test::gameData(), name, 0.5f, 0.25f)) << name; + std::string error; + auto car = SimVehicle::loadPlayer(*test::gameData(), "vpbug", &error, true); + ASSERT_TRUE(car) << error; + car->setResetPos({0.0f, 10.0f, 0.0f}, 0.0f); + car->reset(); + s.ai->resetAndPopulate(car->sim().resetPos()); + const std::uint32_t seed = s.random.state(); + // The same world on a stream that began elsewhere. + ai::Random other(0xBADC0DEu); + ai::Settings settings; + settings.trafficDensity = 0.5f; + settings.pedestrianDensity = 0.25f; + settings.random = &other; + auto world = ai::World::create(*s.city, *test::gameData(), settings); + ASSERT_TRUE(world); + world->resetAndPopulate(car->sim().resetPos()); + EXPECT_EQ(other.state(), seed) << name; + const city::RoomLocator rooms(s.city->psdl, s.city->info.mapName); + const auto pick = session::cruiseStart( + *s.city, [&](const Vec3& p) { return rooms.find(p); }, false, seed, 1); + ASSERT_TRUE(pick) << name; + EXPECT_EQ(pick->intersection, intersection) << name; + } + session::respawnCounter() = saved; +} diff --git a/tests/game/test_parity_round3_frames.cpp b/tests/game/test_parity_round3_frames.cpp new file mode 100644 index 0000000..8d5bd78 --- /dev/null +++ b/tests/game/test_parity_round3_frames.cpp @@ -0,0 +1,519 @@ +// Parity round 3, frames: every model, part, glow and shadow is drawn in the +// frame MM2 draws it in (see docs/parity/round3/frames.md). +#include "TestData.h" +#include "ai/World.h" +#include "asset/VehicleModel.h" +#include "city/CityData.h" +#include "game/AiRenderer.h" +#include "game/ModelLibrary.h" +#include "game/TextureLibrary.h" +#include "game/TrafficBodies.h" +#include "game/bangers/BangerSet.h" +#include "game/fx/ParticleRenderer.h" +#include "game/VehicleRenderer.h" +#include "phys/World.h" + +#include + +#include +#include +#include +#include +#include +#include +#include + +using namespace mm2; + +namespace { + +constexpr float kFrame = 1.0f / 60.0f; + +// A one-room level with a flat floor, listing the traffic's rail cars. +class FloorLevel final : public phys::Level { +public: + FloorLevel(const Vec3& centre, float half) { + const float y = centre.y; + m_corners = {Vec3{centre.x - half, y, centre.z - half}, Vec3{centre.x - half, y, centre.z + half}, + Vec3{centre.x + half, y, centre.z + half}, Vec3{centre.x + half, y, centre.z - half}}; + } + int findRoom(const Vec3&, int) const override { return 1; } + int touchedNeighbors(int*, int, int, const Vec3&, float) const override { return 0; } + void collect(const int*, int, const Vec3&, float, phys::LevelBound& out) const override { + out.clear(); + out.addPolygon(m_corners.data(), 4, {0.0f, 1.0f, 0.0f}, 0); + } + void instances(int room, std::vector& out) const override { + if (source) + source->instancesIn(room, out); + } + phys::PolygonSoup soup(const phys::MaterialTable& materials) const { + phys::SoupGeometry g; + g.vertices.assign(m_corners.begin(), m_corners.end()); + phys::SoupGeometry::Poly p; + p.v = {0, 1, 2, 3}; + p.count = 4; + g.polys.push_back(p); + g.materialNames.push_back(materials[0].name); + phys::PolygonSoup s; + s.add(g, Mat34::identity(), materials); + s.finalize(64.0f); + return s; + } + const game::InstanceSource* source = nullptr; + +private: + std::array m_corners; +}; + +// A device that keeps the draw calls (and draws nothing). +class RecordingDevice final : public render::Device { +public: + const render::DeviceInfo& info() const override { return m_info; } + render::TextureHandle createTexture(const render::TextureDesc&, + std::span) override { + return {++m_next}; + } + void updateTexture(render::TextureHandle, std::uint32_t, const render::Rect&, const void*, + std::uint32_t) override {} + 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 {{++m_next}, 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& call) override { calls.push_back(call); } + void requestCapture() override {} + bool readCapture(render::Image&) override { return false; } + void waitIdle() override {} + const render::FrameStats& stats() const override { return m_stats; } + + std::vector calls; + +private: + render::DeviceInfo m_info; + render::FrameStats m_stats; + std::uint32_t m_next = 0; +}; + +// The meshes ("_") of `model` the recorded calls drew, and the +// world matrix of each call that drew one. +struct Drawn { + std::set meshes; + std::vector> worlds; +}; + +Drawn drawnMeshes(const RecordingDevice& device, const game::GpuModel& model) { + auto lodName = [](asset::Lod lod) { + switch (lod) { + case asset::Lod::High: return "H"; + case asset::Lod::Medium: return "M"; + case asset::Lod::Low: return "L"; + case asset::Lod::VeryLow: return "VL"; + default: return "?"; + } + }; + Drawn out; + for (const auto& call : device.calls) + for (const auto& mesh : model.meshes) + if (call.vertices.buffer == mesh.vertices) { + const std::string name = mesh.part + "_" + lodName(mesh.lod); + out.meshes.insert(name); + out.worlds.emplace_back(name, call.constants.world); + } + return out; +} + +void expectSameMatrix(const Mat34& a, const Mat34& b, const char* what) { + for (int r = 0; r < 4; ++r) { + EXPECT_FLOAT_EQ(a.row(r).x, b.row(r).x) << what << " row " << r; + EXPECT_FLOAT_EQ(a.row(r).y, b.row(r).y) << what << " row " << r; + EXPECT_FLOAT_EQ(a.row(r).z, b.row(r).z) << what << " row " << r; + } +} + +} // namespace + +// aiVehicleInstance::Draw draws a traffic car that has a body +// (aiVehicleActive) with its wheels at the vehWheelCheaps' drawing matrices: +// unturned, at the pivot until the first update, then at the pivot moved by +// the spring's travel along the car's up axis (-Limit off the ground) and +// back by 0.2 / 0.3 of the tyre deflections; WHL4 / WHL5 unturned at their +// pivots raised by WHL2's / WHL3's drawn height less the wheel radius. +TEST(Round3Frames, ActiveTrafficWheelsAreTheCheapWheelsMatrices) { + MM2_REQUIRE_GAME_DATA(); + const vfs::Vfs& vfs = *test::gameData(); + auto city = city::loadCity(vfs, "london"); + ASSERT_TRUE(city); + ai::Settings settings; + settings.trafficDensity = 1.0f; + settings.pedestrianDensity = 0.0f; + auto ai = ai::World::create(*city, vfs, settings); + ASSERT_TRUE(ai); + for (int i = 0; i < 60; ++i) + ai->update(1.0f / 30.0f, {430, 0, -150}, {}); + // A car standing level, preferably one with a WHL4 pivot. + const ai::AmbientCar* target = nullptr; + for (const auto& c : ai->cars()) { + if (!c.data || c.transform.m1.y < 0.99f) + continue; + if (!target || (c.data->wheelCount > target->data->wheelCount)) + target = &c; + } + ASSERT_TRUE(target); + const int id = target->id; + const Mat34 frame = target->transform; + const ai::VehicleData& d = *target->data; + + // The floor 10 m below: the car falls with its wheels off the ground. + auto level = std::make_unique(frame.m3 - Vec3{0.0f, 10.0f, 0.0f}, 400.0f); + phys::MaterialTable materials; + phys::World world(materials); + world.setLevel(level.get()); + world.setStatic(level->soup(materials)); + game::TrafficBodies traffic(*ai, world); + level->source = &traffic; + traffic.beforeStep(); // the rail cars join their rooms + std::vector listed; + traffic.instancesIn(1, listed); + phys::Instance* rail = nullptr; + for (phys::Instance* inst : listed) + if (inst->matrix().m3.dist2(frame.m3) < 1e-6f) + rail = inst; + ASSERT_TRUE(rail) << "the car is an instance of its room"; + EXPECT_FALSE(traffic.wheelsOf(id)) << "no body yet: the rail's turning wheels"; + // What World::collideInstances does with a hit: the body, a new mover. + phys::Body* attached = rail->attachEntity(); + ASSERT_TRUE(attached); + world.addNewMover(attached); + + // vehWheelCheap::Init: at the pivots of the car's matrix. + auto wheels = traffic.wheelsOf(id); + ASSERT_TRUE(wheels); + for (std::size_t i = 0; i < 4; ++i) { + ASSERT_TRUE(wheels->valid[i]); + expectSameMatrix(wheels->matrix[i], Mat34::mul(Mat34::translation(d.wheels[i]), frame), "init"); + } + + // Falling: no ground under the wheels, so each hangs Limit below its + // pivot with no deflection. (A new mover takes part from the frame's + // second sample: two frames.) + for (int k = 0; k < 2; ++k) { + ai->update(kFrame, {430, 0, -150}, {}); // the AI publishes the car as physical + traffic.beforeStep(); // aiVehicleManager::Update declares the body + world.advanceFixed(kFrame); + traffic.afterStep(); + } + const Mat34* body = traffic.transformOf(id); + ASSERT_TRUE(body); + EXPECT_LT(body->m3.y, frame.m3.y) << "the car falls"; + wheels = traffic.wheelsOf(id); + ASSERT_TRUE(wheels); + for (std::size_t i = 0; i < 4; ++i) { + const Vec3 local{d.wheels[i].x, d.wheels[i].y + -d.limit, d.wheels[i].z}; + expectSameMatrix(wheels->matrix[i], Mat34::mul(Mat34::translation(local), *body), "falling"); + } + for (std::size_t i = 4; i < 6; ++i) { + if (d.wheelCount <= static_cast(i)) { + EXPECT_FALSE(wheels->valid[i]); + continue; + } + ASSERT_TRUE(wheels->valid[i]); + const float raised = (d.wheels[i - 2].y + -d.limit) - d.wheelRadius; + const Vec3 local{d.wheels[i].x, raised + d.wheels[i].y, d.wheels[i].z}; + expectSameMatrix(wheels->matrix[i], Mat34::mul(Mat34::translation(local), *body), "WHL4/5"); + } +} + +// aiVehicleInstance::DrawShadow: with a body the shadow lies on the ground +// (lvlInstance::DrawPhysics); on its rail it sits at GetMatrix while the car +// is upright, on the ground only when it is upside down; without ground it +// stays at GetMatrix. +TEST(Round3Frames, TrafficShadowPlacement) { + const float groundY = 2.0f; + game::VehicleRenderer::GroundProbe flat = [&](const Vec3& from, const Vec3& to, Vec3& point, Vec3& normal) { + if (!(from.y >= groundY && to.y <= groundY)) + return false; + point = {from.x, groundY, from.z}; + normal = {0.0f, 1.0f, 0.0f}; + return true; + }; + game::VehicleRenderer::GroundProbe none = [](const Vec3&, const Vec3&, Vec3&, Vec3&) { return false; }; + Mat34 upright = Mat34::rotationY(0.3f); + upright.m3 = {5.0f, 2.5f, -7.0f}; + // On the rail, upright: at the body. + expectSameMatrix(game::trafficShadowMatrix(upright, false, flat), upright, "rail upright"); + // With a body: on the ground. + const Mat34 active = game::trafficShadowMatrix(upright, true, flat); + EXPECT_FLOAT_EQ(active.m3.y, groundY); + EXPECT_FLOAT_EQ(active.m3.x, upright.m3.x); + EXPECT_NEAR(active.m1.y, 1.0f, 1e-6f); + // With a body but no ground: at the body. + expectSameMatrix(game::trafficShadowMatrix(upright, true, none), upright, "active, no ground"); + // Upside down on the rail: on the ground; DrawPhysics turns the flipped + // up axis onto the ground's normal and negates it, so the shadow's up + // axis points up again (its other two axes stay the car's). + Mat34 flipped = upright; + flipped.m1 = -upright.m1; + flipped.m2 = -upright.m2; + const Mat34 under = game::trafficShadowMatrix(flipped, false, flat); + EXPECT_FLOAT_EQ(under.m3.y, groundY); + EXPECT_NEAR(under.m1.y, 1.0f, 1e-6f); + EXPECT_NEAR(under.m2.x, flipped.m2.x, 1e-6f); + expectSameMatrix(game::trafficShadowMatrix(flipped, false, none), flipped, "upside down, no ground"); +} + +// vehTrailerInstance::Draw: a semi trailer draws its body alone below the +// high LOD; at H the body, TLIGHT while the tow car brakes (in the object +// pass) and TWHL0-3 at its wheel matrices, nothing else of vehCarModel's +// (in particular no TWHL0/1 medium meshes, which are modelled away from +// their pivots), and no glows (vehTrailerInstance keeps lvlInstance's +// empty DrawGlow). +TEST(Round3Frames, TrailerDrawnAsVehTrailerInstance) { + MM2_REQUIRE_GAME_DATA(); + const vfs::Vfs& vfs = *test::gameData(); + auto read = [&](std::string_view path) { return vfs.readAll(path); }; + auto model = asset::loadVehicleModel("vpsemi_trailer", read); + ASSERT_TRUE(model); + RecordingDevice device; + game::TextureLibrary textures(device, vfs); + game::ModelLibrary models(device, vfs); + game::VehicleRenderer r(device, textures, models, *model, 0, "TRAILER", "TWHL"); + const game::GpuModel* gpu = models.get("vpsemi_trailer"); + ASSERT_TRUE(gpu); + ASSERT_TRUE(gpu->find("TWHL0", asset::Lod::Medium)) << "the trailer has a TWHL0 medium mesh"; + + // Side on to a camera at the origin looking down -Z, 15 m away. + game::VehiclePose pose; + pose.body = Mat34::rotationY(1.5707964f); + pose.body.m3 = {0.0f, 0.0f, -15.0f}; + for (std::size_t i = 0; i < 4; ++i) { + const auto* pivot = model->pivot("twhl" + std::to_string(i)); + ASSERT_TRUE(pivot); + pose.wheelWorld[i] = Mat34::translation(pivot->origin) * pose.body; + pose.wheelValid[i] = true; + } + pose.hasWheelWorld = true; + pose.brakeLights = true; + pose.headlights = true; + const Mat34 camera; + + r.draw(pose, camera); + const Drawn near = drawnMeshes(device, *gpu); + EXPECT_EQ(near.meshes, (std::set{"TRAILER_H", "TLIGHT_L", "TWHL0_H", "TWHL1_H", "TWHL2_H", + "TWHL3_H", "SHADOW_H"})); + for (const auto& [name, world] : near.worlds) + for (std::size_t i = 0; i < 4; ++i) + if (name == "TWHL" + std::to_string(i) + "_H") { + const Mat44 expect = Mat44::fromMat34(pose.wheelWorld[i]); + for (int a = 0; a < 4; ++a) + for (int b = 0; b < 4; ++b) + EXPECT_FLOAT_EQ(world.m[a][b], expect.m[a][b]) << name; + } + EXPECT_EQ(near.worlds.size(), device.calls.size()) << "no glow cards"; + + // Not braking: no TLIGHT. + device.calls.clear(); + pose.brakeLights = false; + r.draw(pose, camera); + EXPECT_FALSE(drawnMeshes(device, *gpu).meshes.contains("TLIGHT_L")); + + // 80 m away: the medium LOD body and no wheels. + device.calls.clear(); + pose.body.m3.z = -80.0f; + for (std::size_t i = 0; i < 4; ++i) + pose.wheelWorld[i].m3.z = pose.wheelWorld[i].m3.z - 65.0f; + r.draw(pose, camera); + EXPECT_EQ(drawnMeshes(device, *gpu).meshes, (std::set{"TRAILER_M", "SHADOW_H"})); +} + +// pedAnimation::DrawSkeleton widens the stick figures along the first row of +// the modelview matrix taken in the pedestrian's own space: the camera's +// right axis while both only turn about Y, tilted under a pitched camera. +TEST(Round3Frames, StickFigureWidthAxis) { + Mat34 ped = Mat34::rotationY(1.1f); + ped.m3 = {10.0f, 0.0f, -30.0f}; + Mat34 camera = Mat34::rotationY(0.4f); + camera.m3 = {0.0f, 2.0f, 5.0f}; + const Vec3 flat = game::skeletonWidthAxis(ped, camera); + EXPECT_NEAR(flat.x, camera.m0.x, 1e-6f); + EXPECT_NEAR(flat.y, camera.m0.y, 1e-6f); + EXPECT_NEAR(flat.z, camera.m0.z, 1e-6f); + + const float a = 0.7f, p = 0.3f; + const Vec3 tilted = game::skeletonWidthAxis(Mat34::rotationY(a), Mat34::rotationX(p)); + const float ca = std::cos(a), sa = std::sin(a), cp = std::cos(p), sp = std::sin(p); + EXPECT_NEAR(tilted.x, ca * ca + sa * sa * cp, 1e-5f); + EXPECT_NEAR(tilted.y, -sa * sp, 1e-5f); + EXPECT_NEAR(tilted.z, sa * ca * (cp - 1.0f), 1e-5f); +} + +// vehCarModel::DrawHeadlights keeps the headlights' ltLight directions in +// world space: the car's forward axis without the siren; with it, turned +// about Y by +-42.411503 rad/s of frame time from wherever they point, so +// the sweep does not turn with the car. +TEST(Round3Frames, HeadlightSweepIsInWorldSpace) { + MM2_REQUIRE_GAME_DATA(); + const vfs::Vfs& vfs = *test::gameData(); + auto read = [&](std::string_view path) { return vfs.readAll(path); }; + auto model = asset::loadVehicleModel("vpcop", read); + ASSERT_TRUE(model); + RecordingDevice device; + game::TextureLibrary textures(device, vfs); + game::ModelLibrary models(device, vfs); + game::VehicleRenderer r(device, textures, models, *model, 0); + Mat34 camera; + camera.m3 = {0.0f, 1.0f, 20.0f}; + game::VehiclePose pose; + pose.headlights = true; + pose.body = Mat34::rotationY(0.3f); + r.draw(pose, camera); + const Vec3 forward = -pose.body.m2; + EXPECT_FLOAT_EQ(r.headlightDirections()[0].x, forward.x); + EXPECT_FLOAT_EQ(r.headlightDirections()[1].z, forward.z); + + // The siren on for a tenth of a second, the car turned meanwhile. + pose.siren = true; + pose.sirenAngle = 0.1f * 2.5f * 3.1415927f; + pose.body = Mat34::rotationY(1.2f); + r.draw(pose, camera); + const float sweep = pose.sirenAngle / (2.5f * 3.1415927f) * 42.411503f; + const Vec3 left = Mat34::rotationY(sweep).transformDir(forward); + const Vec3 right = Mat34::rotationY(-sweep).transformDir(forward); + EXPECT_NEAR(r.headlightDirections()[0].x, left.x, 1e-5f); + EXPECT_NEAR(r.headlightDirections()[0].z, left.z, 1e-5f); + EXPECT_NEAR(r.headlightDirections()[1].x, right.x, 1e-5f); + EXPECT_NEAR(r.headlightDirections()[1].z, right.z, 1e-5f); + + // Off again: forward. + pose.siren = false; + r.draw(pose, camera); + EXPECT_FLOAT_EQ(r.headlightDirections()[0].x, -pose.body.m2.x); +} + +namespace { + +// One room with a floor at height `y`, listing a source's instances. +class FloorRoom final : public phys::Level { +public: + FloorRoom(const game::InstanceSource& source, float y) : m_source(source), m_y(y) {} + int findRoom(const Vec3&, int) const override { return 1; } + int touchedNeighbors(int*, int, int, const Vec3&, float) const override { return 0; } + void collect(const int*, int, const Vec3& c, float, phys::LevelBound& out) const override { + out.clear(); + const Vec3 floor[4] = {{c.x - 500, m_y, c.z - 500}, {c.x - 500, m_y, c.z + 500}, + {c.x + 500, m_y, c.z + 500}, {c.x + 500, m_y, c.z - 500}}; + out.addPolygon(floor, 4, {0, 1, 0}, 0); + } + void instances(int room, std::vector& out) const override { + m_source.instancesIn(room, out); + } + +private: + const game::InstanceSource& m_source; + float m_y; +}; + +} // namespace + +// aiTrafficLightInstance is an unhit Y banger of its model's data: it stands +// at the pole's base + R * CG (the body's frame), collides as a prop and +// breaks loose into its BREAKnn parts, which are then drawn as ordinary hit +// bangers; while it stands, BangerSet leaves its drawing to the signal +// (AiRenderer). +TEST(Round3Frames, TrafficLightsAreProps) { + MM2_REQUIRE_GAME_DATA(); + const vfs::Vfs& vfs = *test::gameData(); + auto city = city::loadCity(vfs, "sf"); + ASSERT_TRUE(city); + auto world = ai::World::create(*city, vfs, {}); + ASSERT_TRUE(world); + ASSERT_FALSE(world->signals().empty()); + const ai::Signal& signal = world->signals().front(); + + game::bangers::BangerDataLibrary lib(vfs); + game::bangers::BangerSet set(lib); + FloorRoom level(set, signal.transform.m3.y); + phys::MaterialTable materials; + phys::World physics(materials); + physics.setLevel(&level); + set.setWorld(&physics); + + game::bangers::PlacedProp p; + p.model = signal.model; + p.transform = signal.transform; + p.room = 1; + p.ownerDrawn = true; + const auto index = set.addOne(p); + ASSERT_TRUE(index); + expectSameMatrix(set.instances()[*index].matrix, signal.frame(), "the light's frame"); + EXPECT_TRUE(set.standing(*index)); + std::vector listed; + set.instancesIn(1, listed); + ASSERT_EQ(listed.size(), 1u) << "collidable in its room"; + ASSERT_TRUE(listed[0]->bound(0)); + + // Standing: BangerSet draws nothing of it. + RecordingDevice device; + game::TextureLibrary textures(device, vfs); + game::ModelLibrary models(device, vfs); + game::fx::ParticleRenderer cards; + game::Camera camera; + camera.transform = game::Camera::lookAt(signal.frame().m3 + Vec3{12.0f, 2.0f, 12.0f}, signal.frame().m3); + const Mat44 proj = Mat44::perspective(1.0f, 4.0f / 3.0f, camera.nearPlane, camera.farPlane, true); + const game::Frustum frustum(camera.view() * proj); + set.draw(device, models, textures, cards, frustum, camera, {}); + const game::GpuModel* gpu = models.get(signal.model); + ASSERT_TRUE(gpu); + EXPECT_TRUE(drawnMeshes(device, *gpu).meshes.empty()); + + // A heavy box at 20 m/s into the pole's middle knocks it over. + phys::BoundBox box({1.8f, 1.2f, 4.0f}); + box.makeOwnMaterial(); + phys::Body car; + car.ics.setMass(1.8f, 1.2f, 4.0f, 1500.0f); + car.collisionBound = &box; + car.place(Mat34::translation(signal.frame().m3 + Vec3{0.0f, 0.0f, 3.0f})); + car.ics.gravity = {0, 0, 0}; + car.ics.linearVelocity = {0.0f, 0.0f, -20.0f}; + car.ics.linearMomentum = {0.0f, 0.0f, -20.0f * 1500.0f}; + physics.add(&car); + for (int i = 0; i < 60 && set.standing(*index); ++i) { + physics.step(kFrame); + set.update(kFrame); + } + ASSERT_FALSE(set.standing(*index)) << "the light broke loose"; + EXPECT_EQ(set.instances()[*index].state, game::bangers::BangerSet::State::Gone); + std::size_t parts = 0; + for (const auto& inst : set.instances()) + if (inst.everHit && inst.model == signal.model && inst.part >= 0) { + ++parts; + EXPECT_FALSE(inst.ownerDrawn); + } + EXPECT_EQ(static_cast(parts), set.instances()[*index].data->numParts); + device.calls.clear(); + set.draw(device, models, textures, cards, frustum, camera, {}); + const Drawn drawn = drawnMeshes(device, *gpu); + EXPECT_FALSE(drawn.meshes.empty()) << "its parts are drawn"; + for (const auto& name : drawn.meshes) + EXPECT_TRUE(name.starts_with("BREAK")) << name; + physics.remove(&car); + set.setWorld(nullptr); +} diff --git a/tests/game/test_parity_session.cpp b/tests/game/test_parity_session.cpp index 5598275..b6e2d84 100644 --- a/tests/game/test_parity_session.cpp +++ b/tests/game/test_parity_session.cpp @@ -5,6 +5,7 @@ #include "TestData.h" #include "ai/World.h" #include "city/CityData.h" +#include "city/RoomLocator.h" #include "game/Profile.h" #include "game/Strings.h" #include "game/session/CopsAndRobbers.h" @@ -499,24 +500,24 @@ TEST(ParitySession, DeferredLightsCatchUp) { // mmGame::RespawnXYZ never starts a cruise in a room whose level flags // (CityData::levelRoomFlags, lvlRoomInfo) are subterranean, covered, water of -// death or a terrain instance's (0x2E). +// death or a terrain instance's (0x2E); the room is FindRoomId's of the +// intersection's centre. TEST(ParitySession, CruiseStartsAvoidFlaggedRooms) { MM2_REQUIRE_GAME_DATA(); ASSERT_TRUE(parityRetail()); - const auto& city = parityRetail()->london; - ASSERT_TRUE(city.aiMap); - ASSERT_EQ(city.levelRoomFlags.size(), city.psdl.rooms.size()); - std::uint32_t rng = 1; - for (int i = 0; i < 200; ++i) { - const auto start = randomIntersectionStart(city, rng); - ASSERT_TRUE(start); - const city::AiIntersection* found = nullptr; - for (const auto& x : city.aiMap->intersections) - if (x.center.x == start->x && x.center.z == start->z && x.center.y + 2.0f == start->y) - found = &x; - ASSERT_NE(found, nullptr) << i; - ASSERT_LT(found->room, city.levelRoomFlags.size()); - EXPECT_EQ(city.levelRoomFlags[found->room] & 0x2E, 0) << found->room; + for (const auto* city : {&parityRetail()->london, &parityRetail()->sf}) { + ASSERT_TRUE(city->aiMap); + ASSERT_EQ(city->levelRoomFlags.size(), city->psdl.rooms.size()); + const city::RoomLocator rooms(city->psdl, city->info.mapName); + const RoomLookup findRoom = [&](const Vec3& p) { return rooms.find(p, 0); }; + std::uint32_t stream = 1; + for (int i = 0; i < 200; ++i) { + const auto start = respawnXYZ(*city, findRoom, {true, true}, stream, 1); + ASSERT_TRUE(start); + const auto& x = city->aiMap->intersections[static_cast(start->intersection)]; + EXPECT_EQ(start->position, x.center + Vec3(0.0f, 2.0f, 0.0f)); + EXPECT_EQ(city->levelRoomFlags[static_cast(findRoom(x.center))] & 0x2E, 0) << x.id; + } } } diff --git a/tests/game/test_session.cpp b/tests/game/test_session.cpp index 7813229..b1667cc 100644 --- a/tests/game/test_session.cpp +++ b/tests/game/test_session.cpp @@ -1,5 +1,6 @@ #include "TestData.h" #include "city/CityData.h" +#include "city/RoomLocator.h" #include "core/StringUtil.h" #include "game/Strings.h" #include "game/session/Gate.h" @@ -504,7 +505,10 @@ TEST(Session, CruiseWreckWaterAndFallingOut) { ASSERT_TRUE(retail()); auto s = makeSession(GameMode::Cruise, -1); ASSERT_TRUE(s); - // mmGame::RespawnXYZ: 2 m above an AI intersection. + // mmSingleRoam::InitOtherPlayers -> mmGame::RespawnXYZ: 2 m above an AI + // intersection. + const city::RoomLocator rooms(retail()->london.psdl, "london"); + ASSERT_TRUE(s->placeRespawnStart(retail()->london, 1, [&](const Vec3& p) { return rooms.find(p); })); bool atIntersection = false; for (const auto& x : retail()->london.aiMap->intersections) atIntersection |= s->playerSpawn().m3.dist(x.center + Vec3{0, 2, 0}) < 1e-3f; diff --git a/tests/ui/test_widgets.cpp b/tests/ui/test_widgets.cpp index 4d3f5dc..6093ab1 100644 --- a/tests/ui/test_widgets.cpp +++ b/tests/ui/test_widgets.cpp @@ -248,6 +248,20 @@ TEST(Slider, TwentyPositionsOnTwoPixelSegments) { EXPECT_FLOAT_EQ(value, 0.0f); } +// UISlider::Action steps mmSlider's own value, which SetValue clamped: a +// bound value above the range (a race's cop count of 3) steps from the top. +TEST(Slider, StepsFromTheClampedValue) { + float value = 3.0f; + ui::Slider s(ui::Box{450, 212, 183, 29}, [&] { return value; }, [&](float v) { value = v; }); + Fixture fx; + auto f = fx.frame(); + s.adjust(f, -1); + EXPECT_FLOAT_EQ(value, 1.0f - 1.0f / 19.0f); + value = 3.0f; + s.adjust(f, 1); + EXPECT_FLOAT_EQ(value, 1.0f); +} + TEST(TextEntry, FirstKeyReplacesTheText) { Fixture fx; ui::Menu m;