Skip to content

bug: keep Mulberry32 rngState canonical uint32 in snapshots #161

@LightAxe

Description

@LightAxe

Finding

Rng documents its internal state as uint32, but the state grows unbounded between saves:

  • src/sim/rng.ts:11-15 stores state and truncates only the constructor seed with seed | 0.
  • src/sim/rng.ts:20 advances with this.state += 0x6d2b79f5 but does not assign a truncated/wrapped value back to this.state.
  • src/sim/rng.ts:37-38 returns the raw internal value from getState().
  • src/sim/types.ts documents rngState as the Mulberry32 uint32 state, while src/platform/save.ts validates only that it is a finite integer.

A short repro is enough: create new Rng(42), call nextU32() several times, and assert getState() remains a canonical 32-bit Mulberry32 state. Today it exceeds the 32-bit range almost immediately.

Impact

Replay may still appear stable today because new Rng(world.rngState) truncates on construction, but snapshot state is non-canonical and violates the save/replay contract. Long-lived Rng instances also drift toward JavaScript precision-risk territory instead of staying in the intended uint32 ring.

Suggested direction

Wrap this.state on every advance, and make save validation enforce the canonical range/sign contract we actually want. Add a regression test that checks getState() after repeated draws and a save/load replay equivalence check around a nontrivial draw count.

Review provenance

Confirmed by an independent fresh-context review agent during the 2026-05-28 adversarial codebase review of origin/main (16e4201).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions