From 92c383e8bf03973dbdd3b3cd1924152f110df728 Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Fri, 9 Oct 2026 11:29:54 -0700 Subject: [PATCH 1/2] Clamp a driver's offset across a narrow road in MM2's order aiVehiclePhysics::CalcRoadTarget and CalcDestinationTarget clamp the target's offset across a road to +-h, h being the road's half-width less the car's side and 1 m, comparing the lower bound first: below -h gives -h, otherwise above h gives h. On a road narrower than that h is negative. OpenMM2 used std::clamp there, whose bounds must be ordered: a build with libstdc++'s assertions (a GCC 15+ debug build) aborted in a race with "Assertion '!(__hi < __lo)' failed", and an optimised one returned h where MM2 returns -h. ai::clampAcross keeps MM2's comparisons. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/ai/DrivingTargets.cpp | 14 +++++++------- src/ai/PathGeometry.h | 11 +++++++++++ tests/ai/test_parity_ai_vehicles.cpp | 23 +++++++++++++++++++++++ 3 files changed, 41 insertions(+), 7 deletions(-) diff --git a/src/ai/DrivingTargets.cpp b/src/ai/DrivingTargets.cpp index 90c7044..64ef8f2 100644 --- a/src/ai/DrivingTargets.cpp +++ b/src/ai/DrivingTargets.cpp @@ -693,7 +693,7 @@ void PhysicsDriver::calcRoadTarget(int i, Vec3& from) { -ra.x(setupIdx).x * (ra.c(ra.n - 1 - v).x - from.x); if (i == 1) { if (v >= 1) - m_roadOffset = std::clamp(off, -h, h); + m_roadOffset = clampAcross(off, h); off = m_roadOffset; } else { off = off < -h ? -h : (h < off ? h : off); @@ -780,13 +780,13 @@ void PhysicsDriver::calcRoadTarget(int i, Vec3& from) { if (!dirOf(r)) { const int last = ra.n - 1; float o = dotXZ(ra.c(last) - node.pos, -ra.x(last)); - o = std::clamp(o, -hn, hn) / hn; + o = clampAcross(o, hn) / hn; const Vec3 edge = ra.b80(0) - ra.x(0) * (m_rightSide + 1.0f); const Vec3& c = ra.c(0); node.pos = {(edge.x - c.x) * o + c.x, (edge.y - c.y) * o + c.y, (edge.z - c.z) * o + c.z}; } else { float o = dotXZ(ra.c(0) - node.pos, ra.x(0)); - o = std::clamp(o, -hn, hn) / hn; + o = clampAcross(o, hn) / hn; const int last = ra.n - 1; const Vec3 edge = ra.be4(last) + ra.x(last) * (m_rightSide + 1.0f); const Vec3& c = ra.c(last); @@ -1080,7 +1080,7 @@ void PhysicsDriver::calcDestinationTarget(int i, Vec3& viewOrigin) { const RoadArrays ls = caseRoad(leftSlot); if (rs.p && !dirOf(rightSlot) && rightV < rs.n - 1) { const float w = (rs.halfWidth - R) - 1.0f; - const float s = std::clamp(dotXZ(P - rs.c(rs.n - 1), rs.x(rs.n - 1)), -w, w); + const float s = clampAcross(dotXZ(P - rs.c(rs.n - 1), rs.x(rs.n - 1)), w); const int kk = rs.n - rightV - 1; const float off = w - w * (s / w); node.pos = rs.b80(kk) - rs.x(kk) * (R + 1.0f) - rs.x(kk) * off; @@ -1088,7 +1088,7 @@ void PhysicsDriver::calcDestinationTarget(int i, Vec3& viewOrigin) { finalV = rightV; } else if (ls.p && !dirOf(leftSlot) && leftV < ls.n - 1) { const float w = (ls.halfWidth - R) - 1.0f; - const float s = std::clamp(dotXZ(P - ls.c(ls.n - 1), ls.x(ls.n - 1)), -w, w); + const float s = clampAcross(dotXZ(P - ls.c(ls.n - 1), ls.x(ls.n - 1)), w); const int kk = ls.n - leftV - 1; const float f = (s / w + 1.0f) * ((ls.halfWidth - L) - 1.0f); node.pos = ls.be4(kk) + ls.x(kk) * (R + 1.0f) + ls.x(kk) * f; @@ -1096,14 +1096,14 @@ void PhysicsDriver::calcDestinationTarget(int i, Vec3& viewOrigin) { finalV = leftV; } else if (rs.p && dirOf(rightSlot) && rightV < rs.n - 1) { const float w = (rs.halfWidth - R) - 1.0f; - const float s = std::clamp(dotXZ(rs.c(0) - P, rs.x(0)), -w, w); + const float s = clampAcross(dotXZ(rs.c(0) - P, rs.x(0)), w); const float off = w - w * (s / w); node.pos = rs.be4(rightV) + rs.x(rightV) * (R + 1.0f) + rs.x(rightV) * off; finalSlot = rightSlot; finalV = rightV; } else if (ls.p && dirOf(leftSlot) && leftV < ls.n - 1) { const float w = (ls.halfWidth - L) - 1.0f; - const float s = std::clamp(dotXZ(ls.c(0) - P, ls.x(0)), -w, w); + const float s = clampAcross(dotXZ(ls.c(0) - P, ls.x(0)), w); if (ls.divided) { node.pos = ls.c(leftV) - ls.x(leftV) * (L + 1.0f); } else { diff --git a/src/ai/PathGeometry.h b/src/ai/PathGeometry.h index 9a6cae8..4008fe8 100644 --- a/src/ai/PathGeometry.h +++ b/src/ai/PathGeometry.h @@ -34,6 +34,17 @@ struct SharpTurn { Vec3 endDir; // +0x38 and to its end }; +// aiVehiclePhysics' clamp of an offset across a road to the half-width `h` +// the car may use (the road's half-width less the car's side and 1 m), as +// CalcRoadTarget and CalcDestinationTarget compare: the lower bound first. +// On a road narrower than that (h < 0) an offset below -h gives -h and any +// other gives h; std::clamp has no answer there, its bounds must be ordered. +inline float clampAcross(float v, float h) { + if (!(-h <= v)) + return -h; + return h < v ? h : v; +} + // The side's curb (`which` 0) or outer sidewalk edge (1): aiPath +0x80 / // +0xe4, the pair after the lane, sidewalk, tram and train vertices. const std::vector& pathBoundary(const city::AiRoadSide& side, int which); diff --git a/tests/ai/test_parity_ai_vehicles.cpp b/tests/ai/test_parity_ai_vehicles.cpp index 5b86431..834ec41 100644 --- a/tests/ai/test_parity_ai_vehicles.cpp +++ b/tests/ai/test_parity_ai_vehicles.cpp @@ -7,6 +7,7 @@ #include "ai/Driving.h" #include "ai/MapView.h" #include "ai/Opponent.h" +#include "ai/PathGeometry.h" #include "ai/Police.h" #include "ai/Traffic.h" #include "ai/World.h" @@ -16,6 +17,8 @@ #include +#include + #include #include #include @@ -77,6 +80,26 @@ city::AiMap squareBlock() { // aiPoliceForce::RegisterPerp does not check whether the cop is on the // suspect already: a second registration takes a second place. +// aiVehiclePhysics::CalcRoadTarget / CalcDestinationTarget clamp an offset +// across a road to +-h with the lower bound first. A road narrower than the +// car's side offset plus 1 m gives h < 0: MM2 then returns -h for an offset +// below -h and h for any other. (std::clamp with those bounds aborted a +// checked build in a race.) +TEST(ParityAiRoute, ClampAcrossKeepsMm2sOrderOnANarrowRoad) { + EXPECT_FLOAT_EQ(ai::clampAcross(0.5f, 2.0f), 0.5f); + EXPECT_FLOAT_EQ(ai::clampAcross(3.0f, 2.0f), 2.0f); + EXPECT_FLOAT_EQ(ai::clampAcross(-3.0f, 2.0f), -2.0f); + EXPECT_FLOAT_EQ(ai::clampAcross(-2.0f, 2.0f), -2.0f); + EXPECT_FLOAT_EQ(ai::clampAcross(2.0f, 2.0f), 2.0f); + // h = -0.5: the bounds cross. + EXPECT_FLOAT_EQ(ai::clampAcross(-1.0f, -0.5f), 0.5f); // below -h = 0.5 + EXPECT_FLOAT_EQ(ai::clampAcross(0.0f, -0.5f), 0.5f); + EXPECT_FLOAT_EQ(ai::clampAcross(0.7f, -0.5f), -0.5f); // not below -h: h + EXPECT_FLOAT_EQ(ai::clampAcross(0.5f, -0.5f), -0.5f); + // A NaN offset fails the first comparison, as in MM2. + EXPECT_FLOAT_EQ(ai::clampAcross(std::nanf(""), 2.0f), -2.0f); +} + TEST(ParityAiPolice, RegisterPerpCountsARepeatedCopTwice) { ai::PoliceForce force; EXPECT_TRUE(force.registerPerp(1, 100)); From cd5ae295fcc0382a8bf757fafe79be2efd6bcd1a Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Fri, 9 Oct 2026 11:39:47 -0700 Subject: [PATCH 2/2] Call this version 0.3.1 Co-Authored-By: Claude Opus 5.5 (1M context) --- CMakeLists.txt | 2 +- packaging/linux/io.github.lowlevelmetal.OpenMM2.metainfo.xml | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index cac790f..b098e0e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,7 +1,7 @@ cmake_minimum_required(VERSION 3.25...4.0) project(OpenMM2 - VERSION 0.3.0 + VERSION 0.3.1 DESCRIPTION "Open source reimplementation of Midtown Madness 2" HOMEPAGE_URL "https://github.com/lowlevelmetal/OpenMM2" LANGUAGES C CXX) diff --git a/packaging/linux/io.github.lowlevelmetal.OpenMM2.metainfo.xml b/packaging/linux/io.github.lowlevelmetal.OpenMM2.metainfo.xml index 2b936ab..56be0ec 100644 --- a/packaging/linux/io.github.lowlevelmetal.OpenMM2.metainfo.xml +++ b/packaging/linux/io.github.lowlevelmetal.OpenMM2.metainfo.xml @@ -44,6 +44,11 @@ 768 + + +

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

+
+

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