Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -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)
Expand Down
5 changes: 5 additions & 0 deletions packaging/linux/io.github.lowlevelmetal.OpenMM2.metainfo.xml
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,11 @@
<display_length compare="ge">768</display_length>
</requires>
<releases>
<release version="0.3.1" date="2026-10-09" type="development">
<description>
<p>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.</p>
</description>
</release>
<release version="0.3.0" date="2026-10-09" type="development">
<description>
<p>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.</p>
Expand Down
14 changes: 7 additions & 7 deletions src/ai/DrivingTargets.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -1080,30 +1080,30 @@ 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;
finalSlot = rightSlot;
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;
finalSlot = leftSlot;
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 {
Expand Down
11 changes: 11 additions & 0 deletions src/ai/PathGeometry.h
Original file line number Diff line number Diff line change
Expand Up @@ -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<Vec3>& pathBoundary(const city::AiRoadSide& side, int which);
Expand Down
23 changes: 23 additions & 0 deletions tests/ai/test_parity_ai_vehicles.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -16,6 +17,8 @@

#include <gtest/gtest.h>

#include <cmath>

#include <algorithm>
#include <cstdio>
#include <tuple>
Expand Down Expand Up @@ -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));
Expand Down
Loading