Repository navigation
fix(snapshot): restore refuses edge flags its mesh would not bake - #416
Conversation
siracozmen01
left a comment
There was a problem hiding this comment.
Review at 505c1b0: ready, with one point not verified. No findings. Read, not run.
(a) Same inputs:
MeshEdgesFitwalks the same tailMeshTailEndvalidated, with the same layout. It reads vertices (vc × 12), indices (6 × tc), the flags (tc), and then the paint (4 + the material table + tc group bytes). Its advance ofvertexBytes + indexBytes + 2·tc + 4 + tableequalsMeshTailEnd'scontent, and the flags it compares sit atcontent + vertexBytes + indexBytes, whereMeshContentFitsputs the paint's start minus tc.- It runs after
TailFits, so every index is already known to be belowvcand every vertex within the geometry limit, and the bake cannot read out of range. - It copies the vertices and indices into a fresh
m3MeshData(zeroed, thenm3MeshDataAlloc) and callsm3BakeMeshEdgeFlags, which reads onlyvertexCount,triangleCount,vertices,indicesand the scratch, and clearsedgeFlagsbefore setting bits 0 to 2. So the answer depends on exactly the stored geometry. - A stored byte with bit 3 set can never match, and neither can a flipped bit 0.
(b) Cross-platform holds:
FlagEdgeusesm3Sub3,m3Cross3,m3Dot3(plain*,+,-in core_math.h) andm3Normalize3, which issqrtfand1.0f / lengthwith a fixed degenerate fallback. All of these are correctly rounded IEEE operations.- The library builds with
-ffp-contract=off -fno-fast-math -fno-unsafe-math-optimizations(and/clang:-ffp-contract=offunder clang-cl), so no FMA contraction or reassociation can differ between builds. - The neighbor search since #413 is integer only.
- The same bake already runs at every create, and its results feed contacts that the cross-platform golden hashes pin.
(c) Scratch: the check takes the mesh copy and m3MeshEdgeScratchCount ints from world->heap, one mesh at a time, and frees both before the next. A failure is m3_errorCapacity before any byte lands, as the masses check does. For the largest mesh that is about 0.9 MB of copy plus 1 MB of scratch, transient.
Not verified, since it is outside the diff and I cannot search the tree:
- that create bakes on the arrays it stores, after any conversion of the def's vertices or indices;
- that nothing after create writes
edgeFlags(no setter, no fracture or rescale path), so every stored byte is what this bake gives; - that create takes its scratch from the same heap.
A search for writers of edgeFlags and callers of m3BakeMeshEdgeFlags would settle all three. Also, if world->heap can be capped by a budget the program sets, a restore into a nearly full world now needs about 2 MB more at its peak than before, and could be refused with capacity where it was not.
Test: both forged cases are refused only by this check. The body says that with the call removed both restore, so each case fails on main for the right reason. MeshFlagAt finds the scene's mesh by its vertex and index bytes, so it is independent of the tail layout.
🤖 I have created a release *beep* *boop* --- ## [1.1.0](v1.0.5...v1.1.0) (2026-11-05) ### Changed - **1.1.0 refuses snapshots, tapes and containers from 1.0.x.** The snapshot keeps format 6, the journal tape format 7 and the replay container format 4, but they carry `M3_ABI_VERSION`, which now names 1.1, so 1.1.0 refuses a snapshot, tape or container that 1.0.x wrote with `m3_errorVersion`. `m3CreateWorld` refuses a def from 1.0 headers the same way: rebuild against the 1.1 headers. The shared library's `SOVERSION` is `1.1`. - **A blast inside a shape pushes its body toward its centre of mass.** When the blast of `m3World_Explode` lay inside a shape, its rounding included, the body was pushed from the blast toward a point of that shape, with the impulse applied there. That point was a sphere's centre, the middle of a capsule's segment, or the average of a hull's vertices. A lopsided hull, or a body with several shapes, therefore spun for no physical reason. A blast exactly at that point pushed along the shape's own x axis. Now the push runs from the blast to the body's centre of mass, as maul2d's does, so it spins nothing, and a blast at the centre of mass itself pushes nothing; the body still wakes. `m3World_Explode`, and a replay of a journaled explosion, can give a different velocity to a body one of whose shapes holds the blast. A shape the blast is outside of is pushed as before. ([#418](#418)) ### Fixed - **A mesh create no longer takes seconds on a fan.** The edge flags a mesh bakes on create found each edge's neighbor by scanning every triangle around one of its vertices, so a fan of triangles around one vertex took time quadratic in its size: about 5.4 s for 65533 triangles. The bake now takes time linear in the mesh, about 0.025 s for the same fan, and gives the same flags byte for byte, so no contact or result changes. The same holds for a mesh create that `m3World_ReplayJournal` replays. ([#413](#413)) - **Restore refuses a voxel chunk whose cells disagree.** An occupied cell always has a fill of 1 to 255, and an empty cell has fill 0 and payload 0. `m3World_Restore` refused neither an occupied cell with fill 0 nor an empty cell with a fill or a payload, and `m3World_ReplayJournal` took the same states from a chunk create record. Both now refuse such a chunk with `m3_errorFormat`, and restore leaves the world as it was. No chunk the library writes is refused. ([#409](#409)) - **Restore refuses a wheel direction that is not unit length.** A vehicle create takes a wheel direction whose squared length lies between 0.81 and 1.21 and stores it normalized, but restore took any stored direction in that band, so a restored wheel could push along a scaled direction. Restore now requires the squared length within 1e-5 of 1, about 24 times what normalizing leaves, and refuses anything else with `m3_errorFormat`. ([#415](#415)) - **Restore refuses mesh edge flags the mesh would not bake.** A mesh's edge flags are derived from its geometry, and contact welding reads them, but restore took them as stored. Restore now bakes each mesh's flags again and refuses a snapshot whose flags differ, with `m3_errorFormat`, or `m3_errorCapacity` when it cannot allocate the bake's scratch. No snapshot the library writes is refused. On the largest mesh the limits allow, 65522 triangles, a restore took about 26 to 29 ms with the check against 22 ms without it. ([#416](#416))
Problem
A triangle mesh's edge flags, one byte per triangle, are derived from its geometry (#410).
m3BakeMeshEdgeFlagssets bit k when edge k is a boundary or a convex ridge, and the contact welding treats a clear bit as a ghost edge it may silence. The library bakes them on every mesh create. The geometry never changes after that, and only bits 0 to 2 are ever set.m3World_Restoretook them from the snapshot as stored. Its mesh check (MeshContentFitsinsrc/snapshot.c) reads the vertices, the indices and the paint, but not the flags. So a snapshot could carry flags that disagree with the mesh's own geometry, and the restored world then welded contacts differently from any world the library builds. A probe on main took a flat two-triangle mesh, whose bake writes06 03, and patched the first triangle's byte toffand to01. Both restored withm3_success.Change
MeshEdgesFitinsrc/snapshot.cruns after the links and masses checks, before any byte lands. For each mesh in the snapshot's tail, it does three things:m3BakeMeshEdgeFlags, the function every create uses;m3_errorFormatwhen they differ from the stored bytes.A refused restore leaves the world as it was. When the scratch can't be allocated, restore returns
m3_errorCapacity, as the masses check does.Restore now refuses only snapshots whose stored flags disagree with their own geometry, which the library never writes. Every other snapshot restores, and the step is unchanged.
The bake gives the same bits on every platform a snapshot moves between. It uses only
+,-,*,/andsqrtf(throughm3Normalize3), which are correctly rounded. It runs under the library's float environment with contraction off (-ffp-contract=off), and it is the same function the create ran when the snapshot's world was built.The alternative was to leave the derived flags out of the snapshot and rebake them on restore. That gives one source of truth at the same cost, but it changes the snapshot format and the golden hashes; the check keeps both.
Verification
test/test_restore_links.chas two new forged cases on the fuzz scene's mesh: a flag byte with bit 3 set, which the bake never sets, and a flag with one edge bit flipped. Each is refused, and the world is left as it was.With the call to
MeshEdgesFitremoved, both forged cases restore and the test fails on each. With it, gcc-14 Debug with-DMAUL3D_WERROR=ONpasses all 78 tests.The check's cost on the largest mesh the limits allow by triangles, a 182 by 182 vertex grid with ridges and 65522 triangles: the snapshot is 5352470 bytes. The best restore of 20, gcc-14 Release, in three runs each:
The bake is linear in the mesh since fix(shape): bake mesh edge flags in linear time #413, so the check adds a few milliseconds to a restore that already copies the mesh.
tools/check.sh prpasses in the main checkout on this head: 78 and 76 tests, and fuzz with no finding.Closes #410