Skip to content

Complete snapshot capture of per-env state + datadir reset speedup + arena memory-map - #5

Merged
liujonathan24 merged 7 commits into
mainfrom
engine-snapshot-completeness
Jun 25, 2026
Merged

Complete snapshot capture of per-env state + datadir reset speedup + arena memory-map#5
liujonathan24 merged 7 commits into
mainfrom
engine-snapshot-completeness

Conversation

@liujonathan24

Copy link
Copy Markdown
Owner

Engine-side changes (the harness PR liujonathan24/NetHack-engine#21 bumps the submodule to this branch).

Snapshot completeness (fixes a SIGSEGV + observation divergence). nle_fr_snapshot captures the arena + coroutine stack + rl mirror, but ~21 lazy per-env *_state structs were calloc'd on the libc heap and escaped it. The worst, nle_pline_state, holds _you_buf (an ARENA pointer): after a restore rewinds the arena that pointer dangles and the next You_hear/pline writes its message over a reused slot (a live monster's data) -> SIGSEGV in passivemm. Fix: arena-allocate all *_state structs, and serialize the rl mirror's cached inventory_ + WIN_MESSAGE last_msg in NetHackRL::save_mirror/load_mirror.

Reset speedup. nle_settings gains a read-only datadir; read-only file prefixes resolve there so the harness no longer copytree's the 3.7MB dat tree per reset (35ms -> ~1.3ms).

Debug tool. nle_dbg_memmap dumps the whole-game arena memory map (named buffers, fmon/fobj chains, monster grid) — used to root-cause the corruption.

Forward-game behavior byte-identical (these only relocate where per-env state lives). Differential snapshot test passes 40 seeds x 30 rounds (0 crash / 0 divergence).

liujonathan24 and others added 6 commits June 23, 2026 03:35
…jection

Adds three ctypes entry points for the curriculum-learning traversal:

- nle_goto_abs(dnum, dlevel): a branch-unpinned variant of nle_goto_depth.
  Unlike goto_depth (which pins dest.dnum = u.uz.dnum and clamps to the
  Dungeons-of-Doom branch, ~27 levels), this schedules a deferred goto_level
  to an arbitrary (dnum, dlevel), so a curriculum can jump DoD 1-3 -> Gehennom
  47-50 or into the Elemental Planes. Reuses goto_level's cross-branch move +
  on-demand mklev. Grants the Amulet when targeting the endgame so the
  goto_level gate routes the hero to the Plane of Earth.
- nle_num_dungeons / nle_dungeon_info: expose the dungeon table (name,
  depth_start, num_dunlevs) so the caller maps absolute Dlvl N -> (dnum,
  dlevel) and locates Gehennom / the Elemental Planes by name.
- nle_set_state now accepts str/dex/con/int/wis/cha (NetHack-encoded value),
  setting acurr + amax, for the stats-only deep-jump upgrade.

Validated: seed 19 reaches Gehennom depths 47-50 (real generated floors,
hero alive, steppable); Earth/Air/Water planes reached with no bubble panic;
attributes round-trip. Existing engine suite 28/28 green.
The reveal_map render-time overlay filled obs->glyphs/chars/colors but not
obs->tty_chars. Harness consumers (render_map_view, feature/hostile
extraction) render the agent's map from tty_chars, so full vision never
reached the agent. Overlay the revealed terrain + monsters into the tty
buffer's map region (level (x,y) -> tty row y+1, col x-1; tty_chars is filled
by the TMT callback before fill_obs). Guarded to reveal_map>0, so default obs
is unchanged (golden parity holds).
…et dat copy)

nle_settings gains an optional `datadir`. When set, nle_start points the
read-only file prefixes (DATA/HACK/SYSCONF/CONFIG) at it and keeps only the
writable prefixes (LEVEL/SAVE/BONES/SCORE/LOCK/TROUBLE) under `hackdir`.

The engine opens the bulk game data (the DLB nhdat, data/oracles/rumors,
config, help) strictly O_RDONLY, so many envs can share one read-only data
directory while each keeps only a tiny per-env writable hackdir. Previously the
binding copied the whole ~3.7MB dat tree into a fresh temp dir on every reset
(~29ms, the dominant reset cost); with this split no copy is needed.

datadir empty => every prefix resolves under hackdir, exactly reproducing the
prior single-directory behavior (verified byte-identical game traces across 30
seeds x 200 steps, old build vs new).
Adds nle_dbg_memmap(ctx, path): writes the per-env arena layout to a file —
arena base/used/cap, every named per-env buffer by arena offset, the live
monster (fmon) and object (fobj) chains, and the monster grid
(level.monsters[x][y]) annotated with fmon-membership and data-validity per
cell. Chain/grid walks are bounded and arena-range-guarded so it is safe to
call even on a corrupted state.

Lets an arbitrary arena pointer be classified (which buffer/monster/object it
falls in) and surfaces dangling pointers directly: grid entries not in fmon
(stale grid pointers) and monsters whose ->data is outside &mons[]. Built while
investigating the goto_depth+snapshot+reseed monster-corruption crash.
nle_dbg_memmap now leads with a "whole-game regions" section listing every
region that holds per-env game state and that a snapshot must cover: the
nle_ctx_t struct, the per-env arena (all dynamic NetHack state), the fcontext
coroutine stack, and the rl display mirror (libc-allocated, outside the arena).
The arena named-buffer + fmon/fobj/grid detail follows as before.

Also adds ASAN-build support to nle_fast_reset.c (guarded by __SANITIZE_ADDRESS__,
no-op otherwise): unpoison the fcontext stack region around the snapshot/restore
memcpys (ASAN otherwise reports false stack-buffer-underflows on the fiber
stack), and #undef the NetHack free macro so the malloc'd snapshot scratch
buffers are freed through ASAN's interceptor instead of __libc_free. Lets the
engine run under -fsanitize=address for memory-safety debugging.
…ergence)

Two snapshot-completeness bugs, both from per-env state that lived OUTSIDE the
arena and so was missed by nle_fr_snapshot (which captures arena + coroutine
stack + rl mirror):

1) CRASH (SIGSEGV). nle_pline_state (pline.c) was libc-calloc'd but holds
   `_you_buf`, an ARENA pointer. After a restore rewinds the arena, that stale
   pointer aliases a reused arena offset, so the next You_hear/pline writes its
   message string over whatever now lives there — observed corrupting a live
   monster's `data` field, then SIGSEGV'ing in passivemm during pet combat.
   Reproduced deterministically via snapshot/restore/reseed branching after
   modify(goto_depth). Root-caused with a per-env arena memory map +
   non-allocating invariant probes (mon->data == &mons[mnum]); ASAN ruled out
   buffer overflows (the write is arena-internal). Fix: arena-allocate
   nle_pline_state so the snapshot captures it.

2) OBSERVATION DIVERGENCE. The same class affected ~20 other lazy-init per-env
   state structs (apply/botl/cmd/display/dokick/.../uhitm/windows) and the rl
   display mirror's cached inventory_ + WIN_MESSAGE last_msg. Uncaptured, they
   leaked the prior branch's state through a restore, so replaying a fixed
   action line after a divergent branch produced different glyphs/blstats/
   message/inv_* than the first replay. Fix: route every per-env *_state struct
   through nle_arena_calloc, and serialize inventory_ + WIN_MESSAGE last_msg in
   NetHackRL::save_mirror/load_mirror (the only obs-relevant rl-instance state
   not already mirrored).

Forward-game behavior is unchanged (these moves only relocate where per-env
state lives / what the snapshot captures): byte-identical observation traces
across 30 seeds x 200 steps, pre vs post. With both fixes the differential
snapshot test (snapshot -> divergent reseeded branch -> restore -> replay fixed
line == first replay) passes all 40 seeds x 30 rounds: 0 crashes, 0 divergence.
…t overflow)

The vision_radius tune knob set u.nv_range = (int) vision_radius with no bound.
nv_range indexes circle_data[] via circle_ptr() (valid only up to MAX_RADIUS=15),
so a large knob value (e.g. 1e9, or inf which is UB to cast) walked off the table
-> SIGSEGV in vision_recalc at the first docrt()/newgame(). The hard-sight-limit
path also computed vr*vr in int, overflowing for large vr.

Clamp both uses to [1, MAX_RADIUS], testing `>= MAX_RADIUS` on the double before
the (int) cast so inf/huge are caught safely. Values in the normal 1..14 range
are unchanged (golden parity preserved). Found by fuzzing every tune knob with
extreme values (0/neg/huge/inf/nan); vision_radius was the only one that crashed.
@liujonathan24
liujonathan24 merged commit 33d76c4 into main Jun 25, 2026
1 check passed
liujonathan24 added a commit that referenced this pull request Jun 26, 2026
fix(nle): sync top-level include/nleobs.h with src/include (ABI fix, missed by PR #5)
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.

1 participant