Skip to content

fix(snapshot): restore refuses edge flags its mesh would not bake - #416

Merged
siracozmen01 merged 1 commit into
mainfrom
fix/mesh-edge-flags-restore
Nov 5, 2026
Merged

siracozmen01 merged 1 commit into
mainfrom
fix/mesh-edge-flags-restore

Conversation

@siracozmen01

Copy link
Copy Markdown
Collaborator

Problem

A triangle mesh's edge flags, one byte per triangle, are derived from its geometry (#410). m3BakeMeshEdgeFlags sets 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_Restore took them from the snapshot as stored. Its mesh check (MeshContentFits in src/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 writes 06 03, and patched the first triangle's byte to ff and to 01. Both restored with m3_success.

Change

MeshEdgesFit in src/snapshot.c runs after the links and masses checks, before any byte lands. For each mesh in the snapshot's tail, it does three things:

  1. copies the vertices and indices to aligned scratch;
  2. bakes the flags with m3BakeMeshEdgeFlags, the function every create uses;
  3. refuses the snapshot with m3_errorFormat when 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 +, -, *, / and sqrtf (through m3Normalize3), 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.c has 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 MeshEdgesFit removed, both forged cases restore and the test fails on each. With it, gcc-14 Debug with -DMAUL3D_WERROR=ON passes 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:

    restore
    without the check 22.0 to 22.2 ms
    with the check 25.9 to 28.9 ms

    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 pr passes in the main checkout on this head: 78 and 76 tests, and fuzz with no finding.

Closes #410

@siracozmen01 siracozmen01 left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review at 505c1b0: ready, with one point not verified. No findings. Read, not run.

(a) Same inputs:

  • MeshEdgesFit walks the same tail MeshTailEnd validated, 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 of vertexBytes + indexBytes + 2·tc + 4 + table equals MeshTailEnd's content, and the flags it compares sit at content + vertexBytes + indexBytes, where MeshContentFits puts the paint's start minus tc.
  • It runs after TailFits, so every index is already known to be below vc and 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, then m3MeshDataAlloc) and calls m3BakeMeshEdgeFlags, which reads only vertexCount, triangleCount, vertices, indices and the scratch, and clears edgeFlags before 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:

  • FlagEdge uses m3Sub3, m3Cross3, m3Dot3 (plain *, +, - in core_math.h) and m3Normalize3, which is sqrtf and 1.0f / length with 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=off under 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.

@siracozmen01 siracozmen01 changed the title fix(snapshot): restore refuses mesh edge flags its geometry would not bake fix(snapshot): restore refuses edge flags its mesh would not bake Nov 5, 2026
@siracozmen01
siracozmen01 merged commit d0de593 into main Nov 5, 2026
29 of 30 checks passed
@siracozmen01
siracozmen01 deleted the fix/mesh-edge-flags-restore branch November 5, 2026 12:25
@rawframe-release rawframe-release Bot mentioned this pull request Nov 5, 2026
siracozmen01 pushed a commit that referenced this pull request Nov 8, 2026
🤖 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))
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

restore takes mesh edge flags that disagree with the mesh's geometry

1 participant