Changelog

All notable changes to this project are recorded here. The format follows Keep a Changelog and the project uses Semantic Versioning. From 1.0.0, a minor release only adds to the API, a patch release only fixes, and there is no 2.0.0. A release that changes results is a minor release, and its entry says what changes. A snapshot or journal tape is read within the minor version that wrote it and refused across minor versions.

1.3.1 (2026-11-05)

This release makes the compiler check that the library never drops the result of one of its own internal bool functions by mistake. Nothing a program sees changes: no golden line moves, and the snapshot and journal formats keep their versions.

Fixed

tools/check_nodiscard.py keeps every new internal bool function marked.

1.3.0 (2026-11-05)

This release adds four calls and settings from the 1.0 API review: a motor joint's budgets change at run time, a water volume's game data changes after creation, a chain's material reads back, and each body has its own sleep threshold. It also makes M2_NODISCARD warn under GCC 14's C23. Every golden line moves, but only by value: no result moves. M2_SNAPSHOT_VERSION goes from 8 to 9 and M2_JOURNAL_VERSION from 9 to 10. As in every minor release, snapshots and tapes written by 1.2 are refused with m2_errorVersion.

Changed

Line 1.2.2 1.3.0
BENCH_CHAIN2000 2554521beecb135f d4b1f43baa177ee7
BENCH_CHAIN30 c9fd2db3a2d15e08 068decfc106c5ea2
BENCH_JOINTFARM 043d06bccfc7f9b2 4d97fda24df98301
BENCH_PYRAMID15 6b9d8f0f43cf9b65 d16323c55ce611c3
BENCH_PYRAMID30 68a747f0547301f5 03c4be656058313a
BENCH_WATER 12a22f226b422370 49f8b8fd5d013281
CONTACTS be1739f64f0dffe3 6d21a40bb3c38814
FLUID 8543bafd11159c27 89566f4410d4b11c
JOURNAL 47d5b04946d9c7fa 3479f22aa69c1583
JOURNAL_TAPE 404c15ec9f9ac99d 5a657a1f11dfaf87
ROLLBACK 8d7a22907589d94c 5c83e1c8a5f16470
ROLLBACK_SNAPSHOT a8990daefb7deb76 9efe5ede9e675fd2
STACK ee8019b97eba97d3 b3238ceb722bb991

Added

Fixed

1.2.2 (2026-11-04)

This release makes a body whose rotation is locked hold still whatever spin the host gives it, and makes m2World_Restore refuse three more kinds of state that the step would act on wrongly. No golden line moves, and the snapshot and journal formats keep their versions. One kind of snapshot written by 1.2.0 is now refused: see the frozen pairs below.

Changed

Fixed

1.2.1 (2026-11-04)

This release makes a static shape that a host moves into a sleeping body touch it on the next step, and makes m2World_Restore refuse contact pairs the broadphase never makes. No golden line moves, and the snapshot and journal formats keep their versions.

Changed

Fixed

1.2.0 (2026-11-04)

This release makes a water volume lift a body the same wherever the body is. Far from the world origin, buoyancy used to lose most of its digits: 100 km out, a floating box was thrown hundreds of meters into the air. Four golden lines move, and the snapshot and journal formats keep their versions, but as in every minor release, snapshots and tapes written by 1.1.0 are refused with m2_errorVersion.

Changed

Fixed

x 1.1.0 1.2.0
0 -0.122 -0.122
10,000 -0.049 -0.122
100,000 765.8 -0.122
10,000,000 -19.8 -0.122

1.1.0 (2026-11-03)

This release changes how m2DecomposeOutline triangulates an outline, so the long, thin triangles its ear clipping cuts no longer drop out of the pieces. Most concave outlines get new pieces of the same area, and fewer of them. No golden line moves, and the snapshot and journal formats keep their versions, but as in every minor release, snapshots and tapes written by 1.0.1 are refused with m2_errorVersion.

Changed

Fixed

Outlines 1.0.1 1.1.0
200,000 star-shaped, 4 to 16 corners, 0.1 m grid 201 lose area, the worst 0.5% 0 measured
20,000 star-shaped, 3 to 64 corners, off the grid 357 lose area, the worst 0.7% 0 measured

The flips always end. On the hardest 64-corner outline measured, a decomposition took 325 flips and 382 µs, against 345 µs without them.

1.0.1 (2026-11-03)

This release keeps every piece of an outline that m2DecomposeOutline splits when three of its corners lie on one straight line, as they do in a sprite outline traced on a grid. No golden line moves.

Changed

Fixed

1.0.0 (2026-11-03)

This release settles maul2d at 1.0.0. No golden line moves, and no library behavior changes. What changes is the promise (#293): from 1.0.0, a minor release only adds to the API, a patch release only fixes, and there is no 2.0.0. A release that changes results is a minor release, and its entry says what changes. A snapshot or journal tape is read within the minor version that wrote it and refused across minor versions. The other pull requests in this release change no library code.

Changed

0.11.4 (2026-11-02)

This release makes the world queries between steps see every shape where the last step left it. Results change: 7 of the 13 golden lines move, because the world hash covers each shape's broadphase box and moved flag. The simulation does not change: body and particle state is bit-identical, and no format version changes.

Changed

Fixed

Comparison Before After
m2World_OverlapAabb against a brute-force test of every shape's m2Shape_GetAabb, 9800 random queries over a world of 60 boxes on static, kinematic and dynamic bodies moving at up to 40 m/s per axis, every seventh teleported every five steps, over 40 steps 165 leave a shape out 0
A ray through the center of a ball falling at 20 m/s, after each of 30 steps misses after 24 0
m2World_CastRayAll against m2Shape_CastRay on every shape, 9800 random rays in the same world 237 miss a shape 0

0.11.3 (2026-11-02)

This release stops a bullet from passing a wall when more than 64 shapes crowd its path, and makes m2World_CollideMover with a short array hold the planes of the lowest shape slots, whatever order the broadphase trees yield them. No golden line moves.

Changed

Fixed

0.11.2 (2026-11-02)

This release keeps the contacts of a particle near more than 32 shapes, and speeds up a spring fill. No golden line moves.

Changed

Fixed

0.11.1 (2026-11-02)

This release stops two shapes without area from colliding: a segment or a chain segment never pairs with another, as in Box2D. Such a pair could not hold a body anyway: it touched for a step before the body sank through. One golden line moves, and the snapshot and journal formats stay as they were.

Changed

Fixed

0.11.0 (2026-11-02)

This release makes a rollback keep the end events a destroy or a disable queued between steps. To carry them, the snapshot and journal formats change, so 0.11.0 refuses 0.10.0's snapshots and tapes. No simulation result changes.

Changed

Fixed

0.10.0 (2026-11-01)

This release makes two kinds of argument refusals stricter, keeps a refused journal replay from changing anything a program can see, keeps host code from destroying the world it runs in, and stops m2ComputeHull from shrinking fine curves. A program that relied on the two old leniencies below needs to change. No golden value moves, so the same calls step to the same bits as on 0.9.0.

Changed

Call A negative capacity, or a null array with a capacity above 0
The 19 list reads (the id lists, m2Shape_GetSensorOverlaps, m2World_GetContactData, the overlap and cast-all queries, m2World_CollideMover, m2World_OverlapParticlesAabb) return 0 and count one misuse, instead of measuring
m2DecomposeOutline m2_errorInvalid, instead of succeeding
m2World_Snapshot and m2World_StartJournal (a negative capacity) m2_errorInvalid with one misuse, instead of m2_errorCapacity

A program that measured with NULL and a capacity other than 0 passes 0. m2World_Restore and m2World_ReplayJournal still answer a negative size with m2_errorFormat and no misuse, as for any bytes that are not a snapshot or tape. - Breaking: m2CreateWorld checks the particle settings (radius, density, gravity scale and the eight strengths) even when particleCapacity is 0 (#233). A def with a setting outside its range, NaN included, is refused in a world without particles too. Such a world used to be created and then refuse its own snapshots and tapes with m2_errorFormat. - Results and formats: no golden value moves. The snapshot (version 7) and journal tape (version 8) formats are unchanged. As before 1.0.0, 0.10.0 reads only its own snapshots and tapes, and refuses 0.9.0's with m2_errorVersion.

Fixed

0.9.0 (2026-10-31)

This release settles the API ahead of 1.0.0. The step and the target transform now return a status. The diagnostics and the water volume take maul3d's names, and the public kinds have fixed sizes. Every call clears its outputs the same way when it fails. A program needs the changes below to build again. No golden value moves, so the same calls step to the same bits as on 0.8.0.

Changed

Input Status
a null world m2_errorInvalid, no misuse
dt NaN, 0 or below m2_errorInvalid
a positive dt outside M2_MIN_STEP to M2_STEP_LIMIT, infinity included m2_errorLimit
a substep count below 1 m2_errorInvalid
a substep count above M2_MAX_SUBSTEPS m2_errorLimit
a call from host code the world is running m2_errorBusy

Check the status; a refused step leaves the last step's contact events in place. - Breaking: m2Body_SetTargetTransform returns m2Result and is marked M2_NODISCARD (#195). It returns m2_errorStale for a stale id and m2_errorInvalid for a static body or a bad pose. For a dt outside M2_MIN_STEP to M2_STEP_LIMIT, it returns m2_errorLimit and counts a misuse. For a pose too far to reach within M2_SPEED_LIMIT in one step, it returns m2_errorLimit and counts no misuse, since a correct program can meet that. - Breaking: the m2Counters and m2Profile fields take the family's names, as in maul3d (#203). A count ends in Count, work lost without telling the caller in Dropped, and calls refused with a status in Refused:

Old name New name
bodies bodyCount
awakeBodies awakeCount
shapes shapeCount
joints jointCount
pairs contactCount
touchingPairs touchingCount
constraints constraintCount
graphColors colorCount
overflowConstraints overflowCount
pairOverflow pairsDropped
particlePairOverflow particlePairsDropped
particleBodyOverflow particleBodyContactsDropped
particlePoolFull particleCreatesRefused
misuse misuseCount
m2Profile.stepMs m2Profile.step
m2Profile.pairsMs m2Profile.broadphase
m2Profile.contactsMs m2Profile.narrowphase
m2Profile.solveMs m2Profile.solve
m2Profile.sleepMs m2Profile.sleep

stepCount keeps its name, and the profile is still in milliseconds. awakeCount now counts only enabled, awake dynamic bodies. awakeBodies also counted static, kinematic and disabled bodies, which never sleep. - Breaking: the buoyancy volume is a water volume, as in maul3d, so its name no longer suggests the particle fluids (#199). Every m2FluidVolume name is now m2WaterVolume: the id, m2_nullWaterVolumeId, the def, m2DefaultWaterVolumeDef, the create and destroy calls and m2WaterVolume_*. m2WorldDef.fluidVolumeCapacity is waterVolumeCapacity. m2WaterVolumeDef takes its box as bounds, an m2WorldAabb, in place of regionLower and regionUpper. m2WorldAabb is now declared in core_math.h. - Breaking: m2CreateWaterVolume refuses a box without area, with m2_errorInvalid and one misuse (#201). A box with area has its upper corner above the lower on both axes; a volume without one could never act. The default def's box is one point at the origin, so set bounds before creating. - Breaking: m2BodyType, m2JointType and m2ShapeType are int32_t typedefs, and m2ParticleFlags is a uint32_t typedef, each with its constants in an anonymous enum (#184). Their sizes no longer depend on the compiler. Code that names enum m2BodyType and the like uses the typedef. The constants keep their names and values. m2ParticleDef.flags is an m2ParticleFlags, and m2Particle_GetFlags returns one. m2IsCpuBackendSupported returns bool, and m2Hash64 takes an int64_t byte count. - Breaking: a call that fails clears its outputs, by the family rule: - Refused list reads zero the caller's array over its whole capacity, instead of leaving it as it was (#183). These are the id lists, m2Shape_GetSensorOverlaps, m2World_GetContactData, the overlap and cast-all queries, m2World_CollideMover and m2World_OverlapParticlesAabb. A read that succeeds still writes only the items it reports. - A failed m2World_Snapshot zeroes its buffer over its capacity, m2_errorCapacity included (#183). A ring of snapshot buffers sizes each one with m2World_GetSnapshotSize first, or snapshots into a spare and swaps it in on success. - The closest casts (m2World_CastRayClosest, m2World_CastCircleClosest, m2World_CastCapsuleClosest, m2World_CastPolygonClosest) and m2Shape_CastRay zero hitOut, with the null shape id, on a miss and on a refusal (#166). A program that kept an earlier hit in hitOut across a miss keeps its own copy. - A call that the world's own host code makes, and that is refused with m2_errorBusy, leaves its count as it was. This covers m2Body_Shatter's countOut (#173), m2World_StopJournal's bytesOut (#178) and m2CreateParticlesInPolygon's countOut (#179). Such a call with a null count returns m2_errorBusy instead of m2_errorInvalid. - m2Body_Shatter fills bodiesOut with null ids on every failure, a null countOut included (#173). - Breaking: m2Shape_GetSensorOverlaps on a live shape that is not a sensor counts one misuse and zeroes the array over its capacity, as for a stale id (#225). - m2World_OverlapParticlesAabb refuses an inverted box, with one misuse and a zeroed list, as m2World_OverlapAabb already did (#201). A flat or one-point box is still a fair query. - Results and formats: the same calls step to the same bits as on 0.8.0, and no golden value moves. The snapshot (version 7) and journal tape (version 8) formats are unchanged. As before 1.0.0, 0.9.0 reads only its own snapshots and tapes, and refuses 0.8.0's with m2_errorVersion.

Removed

Fixed

0.8.0 (2026-10-29)

This release renames the API by the Maul family's naming rule, which maul3d follows too. Every change is a rename, so a program builds again once it uses the new names below, and nothing it computes changes.

Changed

Old name New name
m2World_ShatterBody m2Body_Shatter
m2World_SnapshotSize m2World_GetSnapshotSize
m2TaskFn m2TaskFunction
m2EnqueueTaskFn m2EnqueueTaskFunction
m2FinishTaskFn m2FinishTaskFunction
m2ResultName m2GetResultName
m2CpuSupportsBackend m2IsCpuBackendSupported

The m2WorldDef fields enqueueTask and finishTask keep their names. - Breaking: the body switches are named as in maul3d (#159). A body switched on and off as a whole is m2Body_Enable and m2Body_IsEnabled, and a bullet is a part switched on and off, m2Body_EnableBullet and m2Body_IsBulletEnabled:

Old name New name
m2Body_SetEnabled m2Body_Enable
m2BodyDef.isEnabled m2BodyDef.enable
m2Body_SetBullet m2Body_EnableBullet
m2Body_IsBullet m2Body_IsBulletEnabled
m2BodyDef.isBullet m2BodyDef.enableBullet

m2Body_IsEnabled keeps its name. - Breaking: the shape geometry getters each read one shape type and refuse every other, so they name it (#162):

Old name New name
m2Shape_GetCircle m2CircleShape_GetCircle
m2Shape_GetCapsule m2CapsuleShape_GetCapsule
m2Shape_GetPolygon m2PolygonShape_GetPolygon
m2Shape_GetSegment m2SegmentShape_GetSegment
m2Shape_GetChainSegment m2ChainSegmentShape_GetChainSegment

m2Shape_SetCircle and the other geometry setters keep their names: they take a shape of any type and change it. - The arrays the list calls fill are named with Out in the headers (idsOut, hitsOut, planesOut, overlapsOut, dataOut, piecesOut, and bufferOut for m2World_Snapshot). No call changes. - Results and formats: the same calls, renamed, step to the same bits as on 0.7.0, and no golden value moves. The snapshot (version 7) and journal tape (version 8) formats are unchanged, but as before 1.0.0, 0.8.0 reads only its own snapshots and tapes and refuses 0.7.0's with m2_errorVersion.

0.7.0 (2026-10-28)

This release ends the API audit before 1.0.0. Every change is a rename or a new parameter order, so a program builds again once it follows the items below, and nothing it computes changes.

Changed

Old name New name
M2_MOVER_PLANES M2_MAX_MOVER_PLANES
m2RatchetJointDef.ratchet m2RatchetJointDef.toothAngle
m2RatchetJoint_GetRatchet m2RatchetJoint_GetToothAngle
maxMotorTorque (revolute and wheel defs) maxMotorEffort
maxMotorForce (prismatic def) maxMotorEffort
m2Joint_SetMaxMotor m2Joint_SetMaxMotorEffort
m2Joint_GetMaxMotor m2Joint_GetMaxMotorEffort

A motor's maxMotorEffort is still a torque on revolute and wheel joints and a force on prismatic joints. - Results and formats: the same calls, renamed and reordered, step to the same bits as on 0.6.0, and no golden value moves. The snapshot (version 7) and journal tape (version 8) formats are unchanged, but as before 1.0.0, 0.7.0 reads only its own snapshots and tapes and refuses 0.6.0's with m2_errorVersion.

0.6.0 (2026-10-28)

This release settles most of the API before 1.0.0, so nearly every program needs changes to build against it. Each Breaking item says what to change.

Added

Changed

Old name New name
M2_HUGE M2_LOCAL_LIMIT
M2_MAX_SPEED M2_SPEED_LIMIT
M2_MAX_ACCELERATION M2_ACCEL_LIMIT
M2_MAX_GRAVITY_SCALE M2_GRAVITY_SCALE_LIMIT
M2_MAX_RATE M2_RATE_LIMIT
M2_MAX_COEFFICIENT M2_COEFFICIENT_LIMIT
M2_MAX_DENSITY M2_DENSITY_LIMIT
M2_MAX_RATIO M2_RATIO_LIMIT
M2_MAX_MASS M2_MASS_LIMIT
M2_MAX_INERTIA M2_INERTIA_LIMIT
M2_MAX_FORCE M2_FORCE_LIMIT
M2_MIN_TIME_STEP M2_MIN_STEP
M2_MAX_TIME_STEP M2_STEP_LIMIT
M2_MAX_ANGLE M2_ANGLE_LIMIT
M2_JOINT_REACH M2_REACH_LIMIT
- Breaking: joints use one spring and constraint vocabulary (#123).
- Rename the def fields:
- hertz and dampingRatio become springHertz and springDampingRatio on distance, wheel, motor and mouse joints;
- they become constraintHertz and constraintDampingRatio on revolute and prismatic joints;
- weld linearHertz, linearDampingRatio, angularHertz and angularDampingRatio become linearSpringHertz, linearSpringDampingRatio, angularSpringHertz and angularSpringDampingRatio.
- A distance joint is a spring only with enableSpring on, and is a rigid rod with it off. A revolute joint's angular spring now needs the def's new enableSpring. Turn enableSpring on wherever a spring used to be on through its hertz alone.
- m2Joint_SetSpringHertz and m2Joint_SetSpringDampingRatio set a revolute joint's spring, not its pin, and refuse prismatic, weld, gear, pulley and ratchet joints. Use m2Joint_SetConstraintHertz and m2Joint_SetConstraintDampingRatio for the pin.
- Breaking: one cast hit type (#121).
- m2CastHit replaces m2RayCastResult and m2RayHit, and has no hit flag.
- m2World_CastRayClosest, m2Shape_CastRay, m2World_CastCircleClosest, m2World_CastCapsuleClosest and m2World_CastPolygonClosest return bool and write the hit through a new last parameter, m2CastHit* hitOut. A miss leaves hitOut as it was, and a null hitOut counts one misuse.
- The casts that list every hit fill m2CastHit arrays.
- Breaking: sensor events have their own types, m2SensorBeginEvent and m2SensorEndEvent: {sensorShapeId, visitorShapeId, step} (#125).
- Read the sensor and the visitor directly, instead of testing both shapes of a contact pair.
- A sensor begin no longer carries a normal, contact points or an approach speed.
- Breaking: snapshot and journal sizes are int64_t (#127).
- m2World_SnapshotSize and m2World_GetJournalBaseSize return it.
- m2World_Snapshot, m2World_Restore, m2World_StartJournal, m2World_StopJournal and m2World_ReplayJournal take int64_t sizes and int64_t* outputs.
- M2_MAX_SNAPSHOT_BYTES is gone, and m2CreateWorld no longer refuses a large world for its snapshot size. The one exception is a 32-bit target where a size_t cannot hold the size.
- Breaking: particles are created like other objects (#109).
- m2CreateParticle replaces m2World_EmitParticle.
- m2CreateParticlesInPolygon replaces m2World_FillPolygonWithParticles. It returns m2Result and reports its count through countOut.
- A fill the pool cuts short keeps what it made and returns m2_errorCapacity.
- Breaking: names follow one rule (#114, #119, #98, #100):
- m2WorldDef.enableSleeping becomes enableSleep, with m2World_EnableSleep and m2World_IsSleepEnabled;
- every joint def's collideConnected becomes enableConnectedCollision, and m2Joint_GetCollideConnected becomes m2Joint_IsConnectedCollisionEnabled;
- m2Body_SetEnabled(world, body, bool) replaces m2Body_Enable and m2Body_Disable;
- m2AabbResult becomes m2WorldAabb, and m2World_OverlapAabb and m2World_OverlapParticlesAabb take one m2WorldAabb instead of two corners;
- m2Joint_GetLength becomes m2DistanceJoint_GetLength, and m2Joint_GetLimits no longer reads a distance joint;
- m2Body_GetRotationalInertia becomes m2Body_GetCentralInertia, and m2Body_GetMass moves to maul2d/body.h.
- Breaking: a pool sized 0 is full from the start (#143, #117).
- m2CreateParticle, m2CreateParticlesInPolygon and m2CreateFluidVolume return m2_errorCapacity and count no misuse in a world whose pool for them is sized 0. They used to return m2_errorInvalid.
- m2CreateChain returns m2_errorCapacity when the shape pool cuts a chain short. chainOut still names the shorter chain.
- Breaking: m2ComputeHull hulls the whole cloud, up to M2_MAX_HULL_POINTS points. It returns m2_errorLimit for a larger cloud, or for one whose hull has more than M2_MAX_POLYGON_VERTICES vertices (#141).
- Breaking: both formats change, so 0.6.0 refuses the snapshots and tapes of 0.5.0 with m2_errorVersion.
- Snapshot version 7. The header holds the world's particle radius, and a snapshot restores only into a world with the same capacities and particle radius (#105). Each chain's loop flag is part of the state (#114).
- Journal tape version 8. The header is 24 bytes and a restore record's size is 64 bits (#127). The body and revolute records follow their new defs (#100, #123).
- Results: a program that makes the same calls, with the renames above and enableSpring turned on wherever a spring was on, steps its bodies, contacts and particles to the same bits as on 0.5.0. Only the fixes below change results. m2World_Hash covers each joint's spring switch, so a world whose joints now turn enableSpring on hashes differently from 0.5.0, even though it steps the same.

Removed

Fixed

Security

0.5.0 (2026-10-27)

Changed

Fixed

0.4.0 (2026-10-26)

Changed

Fixed

0.3.0 (2026-10-25)

Changed

Fixed

0.2.0 (2026-10-25)

Changed

Fixed

0.1.1 (2026-10-25)

Fixed

0.1.0 (2026-10-25)

Changed

(#17, #22, #23, #24, #25, #26, #32, #34, #40) - Breaking: what a restore or replay accepts. m2World_Restore refuses a snapshot in any of these cases, and leaves the world as it was: - its arrays do not fit together (free queues, shape lists, owners, broadphase trees); - it holds shape geometry the world would not build; - its mass data is not what the body's shapes give it; - a body rotation in it is not of unit length.

Replay refuses a tape record whose bool flags are not 0 or 1. Snapshots are format 3 and journal tapes format 4, so those written by 0.0.1 are refused. (#23, #27, #29, #31, #39) - Breaking: the world hash. m2World_Hash and m2World_GetHashParts now also cover: - every setting the step reads; - the live slots, keyed by index; - the id pools; - the broadphase's fat boxes.

So two worlds with the same hash step on to the same bits. Every hash value changes, and m2WorldHashParts gains a volumes part. Queries capped at a number of hits keep the lowest shape slots, so they choose the same shapes in every world that hashes the same. (#37, #38) - Breaking: m2World_ShatterBody returns an m2Result and hands the count back through countOut, and m2IsNormalizedRot returns bool. (#19, #20)

Added

Fixed

[0.0.1] - 2026-09-24

The first release of the reworked library.

Added

Changed

Removed

Fixed