Session Format (v3)

“You carefully read the scroll. It describes a unified session format.”

See also: PARITY_TEST_MATRIX.md (test reference) | COLLECTING_SESSIONS.md (session collection) | TESTING.md (testing infrastructure)

Overview

A session file is a JSON document that captures reference data from C NetHack for verifying the JS port. All sessions use a unified format with:

  1. Options - settings needed to reproduce the session
  2. Startup - initial game state with full RNG log
  3. Steps - sequence of keystrokes with per-step RNG and state changes
  4. Checkpoints - full state snapshots at key moments during level generation

This format supports all session types: gameplay, chargen, map exploration, and special level generation. Every keystroke is logged with its RNG calls, enabling precise debugging when JS diverges from C.

File Location

All sessions live in test/comparison/sessions/.

Naming convention: seed<N>_<description>.session.json

Filename length guideline (process rule):

Examples:

Top-Level Structure

{
  "version": 3,
  "seed": 42,
  "source": "c",
  "regen": { "mode": "gameplay", "moves": ":h." },
  "options": { ... },
  "steps": [ ... ]
}

Required Fields

Field Type Description
version number Schema version (3)
seed number PRNG seed for ISAAC64
source string "c" for C-generated sessions
regen object Parameters to regenerate this session
options object Settings to reproduce session
steps array Sequence of actions with RNG (first step is startup)

Regen Object

The regen object contains mode-specific parameters for regenerating the session:

// Gameplay session
{ "mode": "gameplay", "moves": ":hhlh." }

// Gameplay session with per-turn capture delay overrides (1-based step index)
{
  "mode": "gameplay",
  "moves": ":hhlh.",
  "key_delay_s": 0.05,
  "key_delays_s": { "3": 0.15, "4": 0.15 }
}

// Gameplay session with per-step capture metadata (inside steps[])
{
  "mode": "gameplay",
  "moves": ":hhlh."
}

// Wizload session (special level)
{ "mode": "wizload", "level": "castle" }

// Character generation
{ "mode": "chargen", "selections": "vhfn" }

// Interface capture
{ "mode": "interface", "keys": "O><q" }

// Option test
{ "mode": "option_test", "option": "verbose", "value": true }

Gameplay regen timing fields:

Field Type Description
key_delay_s number Default delay after each sent key during C capture (seconds)
key_delays_s object|array Optional per-turn overrides for key_delay_s; object keys are 1-based step indices, array index 0 is step 1
final_capture_delay_s number Optional extra settle delay before capturing the final step screen
record_more_spaces boolean Optional migration helper: if true, re-record can insert missing Space keys when --More-- appears and persist them into regen.moves

Per-step capture delay metadata can also be stored directly on individual step records (recommended for persistent, local annotations tied to a specific divergence moment):

{
  "key": "h",
  "capture": { "key_delay_s": 0.25 },
  "rng": ["..."],
  "screen": "..."
}

When re-recording via test/comparison/c-harness/rerecord.py, step-level capture.key_delay_s annotations are merged into NETHACK_KEY_DELAYS_S. If both regen.key_delays_s and steps[].capture.key_delay_s specify the same step, the step-level annotation takes precedence.

Options Object

The options object contains all settings needed to reproduce the session:

{
  "options": {
    "name": "Wizard",
    "role": "Valkyrie",
    "race": "human",
    "gender": "female",
    "align": "neutral",
    "wizard": true,
    "symset": "DECgraphics",
    "autopickup": false,
    "pickup_types": ""
  }
}
Field Type Description
name string Player name
role string Role (Valkyrie, Wizard, etc.)
race string Race (human, elf, dwarf, gnome, orc)
gender string "male" or "female"
align string "lawful", "neutral", or "chaotic"
wizard boolean Wizard mode enabled
symset string Symbol set ("DECgraphics")
autopickup boolean Autopickup enabled
pickup_types string Pickup filter string

Startup Step

Startup is the first step in the steps array with key: null. This captures the game state after initialization, before any player commands:

{
  "steps": [
    {
      "key": null,
      "rng": [
        "rn2(2)=1 @ o_init.c:88",
        "rn2(2)=0 @ o_init.c:91",
        ">shuffle",
        "rn2(4)=0 @ o_init.c:94",
        "<shuffle",
        ...
      ],
      "screen": "\u001b[0m\n\u001b[32Clqqqqqqk\u001b[0m\n...",
      "typGrid": "||2:0,3,3:2,9|..."
    },
    { "key": "h", ... },
    ...
  ]
}

The startup step contains:

Field Type Description
key null Always null for startup
action string Always "startup"
rng string[] Full RNG log with midlog markers
screen string ANSI-compressed terminal screen (v3 canonical)
typGrid string RLE-encoded terrain grid (gameplay mode)
checkpoints array State snapshots (wizload mode)

Steps Array

Each step represents an action and its effects. The first step has key: null and is the startup step. Subsequent steps have string keys:

{
  "steps": [
    {
      "key": null,
      "rng": [...14000 calls...],
      "screen": "...",
      "typGrid": "..."
    },
    {
      "key": ":",
      "rng": []
    },
    {
      "key": "h",
      "rng": [
        "rn2(12)=2 @ mon.c:1145",
        ">movemon",
        "rn2(12)=9 @ mon.c:1145",
        "<movemon",
        "rn2(70)=52 @ allmain.c:234"
      ],
      "screen": "..."
    },
    {
      "key": ">",
      "rng": [...],
      "typGrid": "...",
      "checkpoints": [...]
    }
  ]
}
Field Type Required Description
key string|null yes Key sent to NetHack (null for startup)
rng string[] yes RNG calls and event entries during this step (may be empty). RNG entries have fn(args)=result @ file:line format; event entries use ^event[...] format (see Event Entries section).
cursor [col, row, visible] no Terminal cursor position after this step: [col, row, 1] where col/row are 0-based, visible is always 1. Present on every step when cursor tracking is enabled.
capture object no Optional capture metadata; supports key_delay_s for per-step C capture settle timing
screen string no ANSI-compressed screen after this step (v3 canonical)
typGrid string no RLE terrain grid (on level changes)
checkpoints array no State snapshots (during level generation)

action removed: v3 readers/writers must ignore and omit steps[].action. Replay/debug tooling should use key (and actual RNG/screen/state evidence), never heuristic action labels.

Screen Semantics (Important)

Note: Turn count is not tracked per-step since a single keystroke can consume multiple game turns (e.g., running, resting). The RNG delta accurately captures what happened; turn info can be read from the status line if needed.

RNG Log Format

The rng array in each step contains three kinds of string entries mixed together: RNG calls, midlog markers, and event entries. The session test runner separates them by prefix: ^ = event, > or < = midlog marker, everything else = RNG call.

Each RNG entry is a string:

fn(args)=result @ source:line

Examples:

rn2(12)=2 @ mon.c:1145
rnd(8)=5 @ makemon.c:320
d(2,6)=7 @ weapon.c:45

Midlog Markers

Function entry/exit markers are interleaved with RNG calls to provide context:

>makemon
rn2(5)=3 @ makemon.c:100
>mksobj
rn2(10)=7 @ mkobj.c:150
<mksobj
rn2(3)=1 @ makemon.c:200
<makemon

These help identify which code path generated each RNG call.

Event Entries

Event entries use ^ prefix with compact bracket notation, interleaved with RNG calls to record game-state changes for C-vs-JS divergence diagnosis:

^place[otyp,x,y]          — object placed on floor
^remove[otyp,x,y]         — object removed from floor
^pickup[mndx@x,y,otyp]    — monster picks up object
^drop[mndx@x,y,otyp]      — monster drops object
^eat[mndx@x,y,otyp]       — pet eats object
^die[mndx@x,y]            — monster dies
^corpse[mndx,x,y]         — corpse created
^engr[type,x,y]           — engraving created
^dengr[x,y]               — engraving deleted
^wipe[x,y]                — engraving wiped (erosion attempted)
^trap[type,x,y]           — trap created
^dtrap[type,x,y]          — trap deleted

Example from a gameplay step:

>dog_move @ monmove.c:912
rn2(12)=3 @ dogmove.c:587
^eat[38@15,7,472]
rn2(100)=42 @ dogmove.c:300
<dog_move=1 #3017-3021 @ monmove.c:912

Event entries are treated as midlog entries by the RNG comparator (filtered out of RNG matching) but are compared separately via compareEvents() in comparators.js. Tools that don’t understand events simply ignore them.

Repaint Entries

Repaint entries are an optional diagnostic channel recorded in the same ordered rng trace stream using the ^repaint[...] prefix. They are not counted as RNG parity and are compared separately by filtering.

Purpose:

  1. localize screen-only divergences where RNG/events already match
  2. explain exactly when C makes status/topline changes visible
  3. distinguish gameplay parity from repaint ownership parity

Example entries:

^repaint[flush cursor=1 hp=1 botl=1 botlx=0 time=0]
^repaint[bot hp=1 botl=1 botlx=0 time=0]
^repaint[more hp=1 topl=2 row=0 col=23]
^repaint[yn hp=0 topl=3 query="Die?"]

Initial policy:

  1. repaint parity is diagnostic-only
  2. sessions may omit repaint entries entirely
  3. tooling should compare them when present, but not fail sessions on repaint mismatch yet

Identifier References

Field Meaning C source JS equivalent
otyp Object type index otmp->otyp obj.otyp
mndx Monster type index monsndx(mtmp->data) mon.mndx
x,y Map coordinates otmp->ox,otmp->oy or mtmp->mx,mtmp->my same
type (engr) Engraving type ep->engr_type (1=DUST..6=HEADSTONE) mapped from string via engrTypeNum()
type (trap) Trap type index trap->ttyp trap.ttyp

C Instrumentation

C patch: test/comparison/c-harness/patches/012-event-logging.patch

The event_log() helper in src/rnd.c writes to the same rng_logfile used by the RNG logging infrastructure. Instrumented functions:

C File Function Event
src/mkobj.c place_object() ^place
src/mkobj.c obj_extract_self() ^remove
src/mkobj.c mkcorpstat() ^corpse
src/steal.c mpickobj() ^pickup
src/steal.c mdrop_obj() ^drop
src/dogmove.c dog_eat() ^eat
src/mon.c mondead() ^die
src/engrave.c make_engr_at() ^engr
src/engrave.c del_engr() ^dengr
src/engrave.c wipe_engr_at() ^wipe
src/trap.c maketrap() ^trap
src/trap.c deltrap() ^dtrap

JS Instrumentation

Uses pushRngLogEntry() from js/rng.js. Instrumented functions:

JS File Function Event
js/floor_objects.js placeFloorObject() ^place
js/game.js removeObject() ^remove
js/mkobj.js mkcorpstat() ^corpse
js/dogmove.js dog_eat() ^eat
js/dogmove.js dog_invent() pickup/drop ^pickup, ^drop
js/uhitm.js handleMonsterKilled() ^die
js/engrave.js make_engr_at(), del_engr() ^engr, ^dengr
js/engrave.js logWipeEvent() ^wipe
js/dungeon.js mktrap(), deltrap() ^trap, ^dtrap

JS coverage gap: Runtime placement now routes through placeFloorObject() so floor object insertions should emit ^place consistently. See issue #150 for the completed placement audit.

Traced Functions

The C harness instruments these key functions:

Function Purpose
makemon Monster creation
mksobj Object creation
create_room Room creation in special levels
wallify_map Wall type assignment
mktrap Trap creation
somexy Random coordinate in room
somexyspace Random empty coordinate
rndmonnum Random monster selection
dochug Monster AI decision
m_move Monster movement
dog_move Pet movement
mattacku Monster attacks player
mfndpos Find monster positions

Checkpoints

Checkpoints are full state snapshots at key moments during level generation. They always include complete state (no “unchanged” optimization):

{
  "checkpoints": [
    {
      "phase": "after_level_init",
      "rngCallCount": 2810,
      "typGrid": [[...]],
      "flagGrid": [[...]],
      "monsters": [...],
      "objects": [...],
      "rooms": [...]
    },
    {
      "phase": "after_wallification",
      "rngCallCount": 2850,
      "typGrid": [[...]],
      "flagGrid": [[...]],
      "monsters": [...],
      "objects": [...],
      "rooms": [...]
    }
  ]
}
Field Type Description
phase string Generation phase name
rngCallCount number RNG call count at this point
typGrid number[][] 21x80 terrain types
flagGrid number[][] 21x80 tile flags
monsters array Monster positions and types
objects array Object positions and types
rooms array Room structure metadata

Checkpoint Phases

Common phases during level generation:

Compact Mapdump Checkpoints

Session files may also include top-level compact mapdump checkpoints:

{
  "checkpoints": {
    "d0l1_001": "T...\\nF...\\nH...\\nL...\\nR...\\nW...\\nU...\\nA...\\nO...\\nQ...\\nM...\\nN...\\nK...\\nJ...\\nE...\\n"
  }
}

Each checkpoint payload is newline-delimited sections. Base sections are:

Extended sections (new writers should emit; comparison is backward-compatible if absent in old fixtures):

Terrain Type Grid

The typGrid captures terrain types matching C’s levl[x][y].typ.

Terrain Codes

Code Constant Display
0 STONE (rock)
1 VWALL \|
2 HWALL -
3-12 corners/T-walls various
14 SDOOR secret door
23 DOOR +
24 CORR #
25 ROOM .
26 STAIRS < or >

Format Options

Array format (deprecated):

"typGrid": [[0, 0, 25, 25, 1, ...], ...]

A 21x80 array of integers, row-major: typGrid[y][x]. Legacy format, no longer generated.

RLE format (current default):

"typGrid": "||2:0,3,3:2,9|2:0,1,3:p,1,9:h,45:p|..."

A single string with rows separated by |. Each row uses run-length encoding:

Example: 3:0,p,5:p means “3 STONE, 1 ROOM, 5 ROOM, then zeros to column 80”.

Flag Grid and Wall Info Grid

The flagGrid (tile properties like lit, non-diggable) and wallInfoGrid (wall property flags) use the same RLE format:

"flagGrid": "||||||||55:0,1:4,1:0,1:4||56:0,1:4|..."

All three grid types use identical encoding. Mostly-zero grids become very compact since all-zero rows are just | separators.

Screen Format

Screens are captured from the C terminal in ANSI format as a single compressed string with full escape codes for colors and attributes.

Format

The screen is a single string with lines separated by \n. Each line is compressed:

  1. Trailing spaces stripped - implicit fill to column 80
  2. Internal space runs compressed - runs of 5+ spaces use cursor codes
"screen": "\u001b[0m\n\u001b[32Clqqqqqqk\u001b[0m\n\u001b[32Cx\u001b[0m.....\u001b[1;37m@\u001b[32mx\u001b[0m\n..."

Compression Codes

Code Meaning
\e[nC Cursor forward n columns (used for runs of 5+ spaces)

The cursor forward code is only used when it saves bytes:

Decompression for Comparison

The session reader expands cursor codes to a 24x80 grid of cells, where each cell has:

Comparison happens on the expanded grid - compression is transparent.

When to Include Screen

Include screen on a step when:

Omit screen on simple movement steps with no message - keeps files compact.

DECgraphics Characters

Char Meaning
l Top-left corner
q Horizontal wall
k Top-right corner
x Vertical wall
~ Room floor

Comparison Report Format

When JS is compared against a C session, the result is a JSON report:

{
  "session": "seed42_gameplay.session.json",
  "seed": 42,
  "source": "c",
  "timestamp": "2026-02-15T10:30:00Z",

  "metrics": {
    "rngCalls": { "matched": 15234, "total": 15234 },
    "keys": { "matched": 67, "total": 67 },
    "grids": { "matched": 3, "total": 3 },
    "screens": { "matched": 47, "total": 47 }
  },

  "passed": true
}

Failure Report

When there’s a mismatch, include divergence details:

{
  "session": "seed42_castle.session.json",
  "seed": 42,
  "source": "c",
  "timestamp": "2026-02-15T10:31:00Z",

  "metrics": {
    "rngCalls": { "matched": 2807, "total": 2850 },
    "keys": { "matched": 3, "total": 5 },
    "grids": { "matched": 0, "total": 1 },
    "screens": { "matched": 5, "total": 12 }
  },

  "passed": false,

  "firstDivergence": {
    "key": 4,
    "rngCall": 2808,
    "expected": "rn2(10)=7 @ sp_lev.c:450",
    "actual": "rn2(10)=3 @ sp_lev.js:382",
    "cContext": ">wallify_map >set_wall_type",
    "jsContext": ">wallify_map >set_wall_type",
    "phase": "after_wallification"
  },

  "gridDiffs": [
    { "step": 4, "cellsDifferent": 83 }
  ],

  "screenDiffs": [
    { "step": 6, "description": "message line differs" }
  ]
}

Metrics Summary

Metric Description
rngCalls Individual RNG calls that match
keys Keystrokes where all RNG calls match
grids typGrids that match cell-for-cell
screens Screens that match glyph-for-glyph

All metrics are { matched, total } pairs for easy ratio calculation.

Aggregation

Multiple session reports can be aggregated:

{
  "timestamp": "2026-02-15T10:30:00Z",
  "commit": "abc123",
  "sessions": 150,
  "passed": 142,
  "failed": 8,

  "totals": {
    "rngCalls": { "matched": 1523400, "total": 1524000 },
    "keys": { "matched": 4500, "total": 4520 },
    "grids": { "matched": 298, "total": 300 },
    "screens": { "matched": 2100, "total": 2150 }
  },

  "failures": [
    { "session": "seed42_castle.session.json", "firstDivergence": { ... } },
    ...
  ]
}

Design Principles

  1. Unified format - All session types use the same structure
  2. Full RNG logs - Every RNG call logged with source location and midlog context
  3. Step-by-step - Every keystroke captured with its RNG delta
  4. Full checkpoints - Complete state at each phase (no diff optimization)
  5. Self-documenting - Options object makes sessions reproducible
  6. Debugging-first - Format optimized for finding divergences, not file size

Generating Sessions

# Gameplay session
python3 test/comparison/c-harness/run_session.py <seed> <output.json> '<keys>'

# Special level session (via teleport/wizloaddes)
python3 test/comparison/c-harness/gen_special_sessions.py <group> --seeds 42

# Regenerate all from config
python3 test/comparison/c-harness/run_session.py --from-config

Deprecated Fields

The following fields are deprecated and should not be used:


“The scroll crumbles to dust, but its wisdom remains.”