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:
- Options - settings needed to reproduce the session
- Startup - initial game state with full RNG log
- Steps - sequence of keystrokes with per-step RNG and state changes
- 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):
- For new or renamed session files, keep the full filename
(
<name>.session.json) at 56 characters or fewer. - Prefer compact intent tokens over long prose in
<description>. - Legacy longer names may exist; do not introduce new long names.
- Keep a small set of oldest tiny sessions unconsolidated as smoke tests.
Examples:
seed42_gameplay.session.jsonseed1_chargen_valkyrie.session.jsonseed100_castle.session.json
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) |
actionremoved: v3 readers/writers must ignore and omitsteps[].action. Replay/debug tooling should usekey(and actual RNG/screen/state evidence), never heuristic action labels.
Screen Semantics (Important)
- In v3,
screenis the primary terminal capture and includes ANSI/control data. - Plain text screen comparison is derived by stripping ANSI/control sequences.
screenAnsiis deprecated for v3 and should be removed from new captures. The canonical v3 field isscreen.- DECgraphics normalization is applied via SO/SI (
\x0e/\x0f) state with a standard DEC-special-graphics to Unicode correspondence before glyph/color comparisons. Example correspondences:a -> U+2592(checkerboard/open door),~ -> U+00B7(middle dot),lqkxmjntuvw -> box-drawing.
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
>funcname- entering function<funcname- exiting function
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:
- localize screen-only divergences where RNG/events already match
- explain exactly when C makes status/topline changes visible
- 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:
- repaint parity is diagnostic-only
- sessions may omit repaint entries entirely
- 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:
after_level_init- initial level structureafter_map- after map drawingafter_wallification- after wall type assignmentafter_levregions_fixup- after stairs/portals placed
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:
Tterrain (typ)Fflags low bitsHhorizontal/alias byteLlitRroomnoOobject sparse list (x,y,otyp,quan)Mmonster sparse list (x,y,mndx,mhp)Ktrap sparse list (x,y,ttyp)
Extended sections (new writers should emit; comparison is backward-compatible if absent in old fixtures):
Wwall info mirror (Crm.wall_infoalias of lowflagsbits)Uhero vector format:ux,uy,uhp,uhpmax,uen,uenmax,multi,utrap,utraptype,move,moves,conf,stun,blind,hallu,fumblingAanchor vector format:moves,hero_seqQobject detail sparse listNmonster detail sparse listJtrap detail sparse listEengraving sparse list (x,y,type,textLen,nowipeout,guardobjects)
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:
- Format:
count:charfor runs, or justcharfor single cells charis0-9for values 0-9,a-zfor 10-35- Trailing zeros are omitted (row fills to 80 with 0)
- All-zero rows are empty (just
||)
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:
- Trailing spaces stripped - implicit fill to column 80
- 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:
- 5 spaces (5 bytes) →
\e[5C(4 bytes) - saves 1 byte - 10 spaces (10 bytes) →
\e[10C(5 bytes) - saves 5 bytes - 4 spaces remains as 4 literal spaces (no savings)
Decompression for Comparison
The session reader expands cursor codes to a 24x80 grid of cells, where each cell has:
- Glyph (character)
- Foreground color
- Background color
- Attributes (bold, underline, etc.)
Comparison happens on the expanded grid - compression is transparent.
- Row 0: Message line
- Rows 1-21: Map area (DECgraphics encoding with ANSI colors)
- Rows 22-23: Status lines
When to Include Screen
Include screen on a step when:
- There’s a message (row 0 non-empty)
- A menu is displayed
- Level changes
- Combat or interaction occurs
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
- Unified format - All session types use the same structure
- Full RNG logs - Every RNG call logged with source location and midlog context
- Step-by-step - Every keystroke captured with its RNG delta
- Full checkpoints - Complete state at each phase (no diff optimization)
- Self-documenting - Options object makes sessions reproducible
- 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:
startupobject - Now the first step withkey: nullturnin steps - Single keystroke can span multiple turns; use RNG deltarngCallscount - Redundant withrngarray lengthrngFingerprint- Use fullrngarray insteadtypefield at top level - Useregen.modeinstead- Separate
characterobject - Useoptionsinstead screenas array of strings - Now a single compressed string
“The scroll crumbles to dust, but its wisdom remains.”