SimHacker / SimHacker/MicropolisCore

Intermittent `memory access out of bounds` in `simTick()` — unconstructed `std::string` in `SimSprite`

Open
#11 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
TypeScript
Stars
194
Forks
27
PR merge metrics
No merged PRs in 30d

Description

Running a builtin city forward crashes the WASM engine with RuntimeError: memory access out of bounds. It reproduces on kobe and (depending on seeding) haight, and it's intermittent — the same command crashes on some runs and not others.

The root cause is an uninitialized std::string: Micropolis::newSprite() allocates SimSprite with malloc and then assigns to its name member, which was never constructed.

Environment
  • main @ 2894fe4
  • committed WASM artifacts in apps/micropolis/src/lib/ (no rebuild needed)
  • Node v22.23.2, macOS 14.2.1 arm64
Reproduction

I've attached repro.mjs.txt — it's self-contained and uses only the committed engine artifacts, so there's nothing to build and no dependencies to install. Rename it to repro.mjs (GitHub won't accept a bare .mjs attachment, and the extension matters — it's what makes Node treat the script's top-level await as an ES module), drop it in the repo root, and run:

node repro.mjs deadwood 240
node repro.mjs kobe 30
FREEZE_CLOCK=1 node repro.mjs kobe 30

It takes <city> <turns> and exits non-zero on a trap, so the intermittency is easy to measure:

for i in $(seq 12); do node repro.mjs deadwood 240; done

Measured on main:

Command Result
node repro.mjs deadwood 240 ×12 8 CRASH, 4 OK
node repro.mjs kobe 30 ×10 4 CRASH, 6 OK
FREEZE_CLOCK=1 node repro.mjs kobe 30 ×5 5 CRASH (deterministic)
Why it's intermittent, and how to make it deterministic

The engine seeds its RNG from the wall clock before anything JS-side can seed it. initWillStuff() calls randomlySeedRandom() (random.cpp), which seeds from gettimeofday(); this happens during both init() and loadCity(). The doSimInit() mapScans that follow run the zone simulation over the freshly loaded city, drawing getRandom() and mutating map tiles. Every process therefore starts from a slightly different world, so a latent bad access is hit on only some runs. Calling seedRandom() afterwards can't help — the divergence already happened.

The engine's only route to wall-clock time is emscripten_date_now, which the generated glue defines as () => Date.now(). Freezing Date.now across setup pins that seed and makes the crash fire every run — that's what FREEZE_CLOCK=1 does in the attached script, and it's what made this debuggable.

Root cause

Building the engine with -g -O0 -s SAFE_HEAP=1 -s ASSERTIONS=2 turns the raw offsets into a precise chain:

doSpecialZone → doAirport → generateCopter → makeSprite → newSprite
  → std::string::__assign_no_alias → segfault

In packages/micropolis-engine/src/sprite.cpp, Micropolis::newSprite():

sprite = (SimSprite *)newPtr(sizeof (SimSprite));   // newPtr == malloc
...
sprite->name = name;                                 // std::string::operator=

SimSprite has a std::string name member (micropolis.h), but the object is allocated with raw malloc and never constructed, so operator= dereferences whatever garbage the string's internal pointers hold. It only appears to work when the heap happens to be zero-filled. The path needs an airport to spawn a helicopter, which is why city maps without one never hit it.

A second, related bug

Same class of problem, worth fixing together: Micropolis::callback is never initialized — not by the constructor, not by init() — yet setCallback() does if (callback != NULL) delete callback. The first Micropolis works only because a fresh WASM heap is zeroed; a second instance allocated over freed memory deletes a stale pointer and traps with RuntimeError: table index is out of bounds. Repro: construct, setCallback, init, delete(), then repeat in the same module.

Expected vs. actual

Expected: ticking any builtin city forward completes without trapping.
Actual: RuntimeError: memory access out of bounds, on a run-dependent subset of attempts.

Fix

I have both fixes working and will open a PR.

Contributor guide

No contributing guide indexed for this repository

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start with the repro.mjs attachment and run the listed Node commands to observe the trap. Read packages/micropolis-engine/src/sprite.cpp, the SimSprite definition in micropolis.h, and the Micropolis initialization paths, including callback handling. Done means repeated builtin-city ticks complete without traps and repeated Micropolis instances can be created and deleted safely.

Written by the indexing model from the issue text.

Assessment

Tech stack
cpp, wasm
Domain
game-dev
Issue type
Bug
Difficulty
3/5
Estimated time
1-2 days
Activity status
Quiet
Clarity
Clearly specified
Newbie friendliness
35/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.