L3 Modules
Game loop and startup
The game loop and startup code set up a game, then run turns forever in moveloop().
It lives in src/allmain.c. main() is not here: each platform layer has its own (sys/unix/unixmain.c, sys/windows/windmain.c, sys/libnh/libnhmain.c, sys/share/pcmain.c, sys/vms/vmsmain.c, sys/mac68k/macmain.c). They share only early_init() and moveloop(resuming); startup code added to one main is not in the others. unixmain.c and libnhmain.c carry copies of the same startup block, kept in step by hand.
| File | Role |
|---|---|
src/allmain.c |
early_init, newgame, welcome, moveloop, moveloop_core |
sys/unix/unixmain.c |
Unix main() and argument handling |
src/end.c |
game over, scoring, disclosure |
Startup
main() parses early options, takes the lock file (getlock()), starts the window port and the data library, then tries dorecover() first and calls newgame() only if nothing was restored. Role selection can rename the hero; the save and lock files are named after the hero, so unixmain.c jumps back to the label attempt_restore (a goto) to try again.
newgame() order is stated in comments and enforced by nothing: init_objects() before u_init(), role_init() before init_dungeons(), u_init() and init_artifacts(), init_dungeons() before u_init() so that starting tins and eggs do not get odd monsters. A reorder compiles and misbehaves.
Time
moveloop_core() is one pass, not one turn. Its first half pays for the action already taken: monsters move, and only when hero and monsters are all out of movement does a new turn start. Its second half refreshes the screen and reads the next command (rhack(0)). It is called from moveloop() and from the Lua binding nh.doturn() (doc/lua.adoc); the source gives no reason for the split.
Three time scales nest, each with a banner comment in src/allmain.c:
- Turn ("once-per-turn"):
svm.movesrises once all movement is spent. The name is misleading; it counts turns. The counter is capped at 1,000,000,000 ("The dungeon capitulates"). - Hero action ("once-per-hero-took-time"):
u.umovement -= NORMAL_SPEED. A fast hero acts several times per turn.gh.hero_seqidentifies one action and is recomputed on restore. - Player input ("once-per-player-input"): runs even when no time passed.
Put a new effect in the block that matches the scale it needs.
Speed is movement points: u_calc_moveamt() for the hero, mcalcmove() in src/mon.c for monsters. An actor moves while it has at least NORMAL_SPEED (12). Monster speed is rounded to a multiple of 12 at random, so there is no predictable free turn and no "melee kiting". The comments in src/mon.c list the guarantees to keep: shopkeepers are not outsped by a normal-speed player, and a normal-speed player cannot open a gap when fleeing a normal-speed monster. Hero speed uses another scheme: intrinsic Fast adds a move on 1 of 3 turns, Very fast on 2 of 3.
Traps in the loop
svc.context.moveis the gate for time.parse()sets it before every command; a command that takes no time must clear it. Forgetting gives monsters a free turn; clearing wrongly gives the hero free actions.- Occupations and counted commands return from
moveloop_core()early, without callingrhack(). Code added at the end of the function does not run on those passes. mvl_wtcapandmvl_changeare file-level statics that survive between passes and are not saved. A delayed polymorph pending at save time is probably lost (unverified).- With
SAFERHANGUP,hangup()only counts; the loop ends the game itself at a safe point (end_of_input()). moveloop_preamble(resuming)differs for new and restored games: a new game getsu.umovement = NORMAL_SPEED, a restore reads engravings and fixes shop damage. Luck changes for the moon phase and Friday the 13th apply to both.
Global state
Global state lives in include/decl.h and src/decl.c, in groups by first letter of the name.
Most game state is grouped in structures named after a letter: ga., gb., ... gz. for engine state "that does not need to be persisted upon game exit", and svb., svc., ... svy. for state that belongs to a saved game. Examples: svc.context (game context), svl.level (current level), svd.dungeons. The letter is the first letter of the member name, by convention; it is not a subsystem boundary (gc holds command, colour, crash, pickup and sound state side by side). The comment /* pickup.c */ inside each struct names the owner.
Not everything is in a group. The hero u, flags, iflags, a11y, disp, program_state, level_status, the worn-item pointers (uwep, uarm, ...) and the WIN_* window ids are plain globals. flags is declared in include/flag.h, not decl.h.
Saving is per field
The group prefix saves nothing. savegamestate() in src/save.c writes a list of named fields (Sfo_long(&svm.moves), Sfo_context_info(&svc.context), ...), and other sv* members are written by their own modules (src/dungeon.c, src/timeout.c, src/rumors.c, src/o_init.c, src/region.c). A new sv* field with no matching Sfo_/Sfi_ pair is silently not saved. The reverse also holds: some g* members are saved through their own routines (gi.invent, gm.migrating_objs, gm.migrating_mons, gg.gamelog).
Initialisation
decl_globals_init() assigns each group from a static const template (g_init_X, init_svX). Initialisers are positional, so field order must match decl.h. For the g groups a check catches a misaligned list: every g* struct ends in boolean havestate, the template must set it TRUE, and MAGICCHECK exits with "decl_globals_init: %s.havestate not True." The sv* structs have no such check.
Traps in global state
gh.havestatehas a second meaning insavestateinlock()(src/save.c): "a full checkpoint was written last time". Do not clear it for other reasons.decl.hstill declaresextern char tune[6], but the object issvt.tune. Usingtuneis a link error.src/restore.candutil/sfctoolcompile the same code with-DSFCTOOL; a change tosavegamestate()orrestgamestate()must also build in that reduced mode.
Commands and options
Commands turn keystrokes into game actions, and options let players change behaviour.
Commands are in src/cmd.c (about 5,700 lines, also key binding, repeat, getdir(), #travel and the # menu). Wizard-mode commands are in src/wizcmds.c, information commands in src/insight.c, position prompts in src/getpos.c. Options are defined in include/optlist.h and parsed in src/options.c (over 10,000 lines). src/cfgfiles.c reads the config file: it dispatches every statement (OPTIONS, BIND, MSGTYPE, MENUCOLOR, SYMBOLS, ...) and passes only OPTIONS lines to parseoptions().
Commands
extcmdlist[] is the table of commands: name, description, handler, flags and the text shown while a counted command runs. Its key column is only the default. commands_init() copies the keys into a runtime list, gc.Cmd.cmdbinds, and rhack() finds a typed key there (cmdbind_get). BIND= lines, the key-binding option, number_pad and rest_on_space all edit that list while the game runs. So a key does not tell you the table row, and a row does not tell you its key.
rhack() reads no key itself. It pops the command queue first and calls parse() (count and readchar()) only if the queue is empty.
Handlers return ECMD_OK, ECMD_TIME, ECMD_CANCEL or ECMD_FAIL. Only rhack() turns ECMD_TIME into game time; a handler that forgets it costs no time. ECMD_CANCEL and ECMD_FAIL wipe the queued commands. Table flags are checked in different places:
IFBURIED,WIZMODECMD,NOFUZZERCMD:can_do_extcmd().CMD_M_PREFIX,CMD_gGF_PREFIX:rhack()refuses a prefix key in front of a command without the bit.AUTOCOMPLETE: set by hand, because a completable name makes shorter prefixes ambiguous (#exploremodeis left out so#estill means#enhance).GENERALCMD: grouping for the key list only. Despite a comment ininclude/func_tab.h, it does not mean "takes no time".
NOFUZZERCMD marks anything that blocks, exits or does file I/O (#quit, #save, #shell, the loaders). The debug fuzzer feeds random keys and refuses flagged commands; a new command that exits or waits on a human needs the flag. Options have the equivalent set_wiznofuz.
Repeat (^A) replays commands, not keystrokes. Two queues exist (CQ_CANNED, CQ_REPEAT, include/hack.h). Every command appends its handler to CQ_REPEAT, and prompts append the key they read. CQ_CANNED lets code act "as if the player typed" a command (apply from the inventory menu, autodig, Lua key input).
Options
optlist.h has no code. src/options.c includes it three times with different macros (NHOPT_PROTO, NHOPT_ENUM, NHOPT_PARSE). Entry kinds: NHOPTB boolean, NHOPTC compound, NHOPTP prefix family, NHOPTO other. To add an option, follow the steps in doc/options.txt:
- A non-boolean option needs an
optfn_<name>()function, or the build fails. A boolean needs a variable (addr); side effects go in theswitchblocks ofoptfn_boolean. - Put the variable in
flags(saved; a change may needEDITLEVELininclude/patchlevel.hraised, seeinclude/flag.h) oriflags(not saved). - Update the short help (the
descrstring,option_help()),dat/opthelpand the Guidebooks (doc/Guidebook.mn,.tex,.txt). No tool checks this (unverified).
The config file, the NETHACKOPTIONS variable, the command line and the O menu all go through parseoptions(). The menu builds a string like name:value and calls it. Each option function must work in four request modes (do_init, do_set, get_val, do_handler) and in startup and play. go.opt_phase (built-in, sysconf, RC file, environment, command line, play) records who set a value last.
Traps in commands and options
- Abbreviations are computed at startup (
determine_ambiguities()): the shortest unique prefix, at least 3 characters. A new option that shares a prefix raises the minimum for the old one, and a user'sOPTIONS=autopcan stop working.parseoptions()strips a leadingnoor!, so no option name may start withno. - Comma lists are processed right to left.
windowtypemust be handled before interface options, which is why it is first inoptlist.h. #saveoptionsrewrites the whole RC file and drops comments, ordering and anything it cannot write. It is marked experimental in the code.number_padends inreset_commands(FALSE), which rebinds movement keys and backs up what was bound to them.- The
runandrushwords differ by route. The table namesG#run(context.run = 3) andg#rush(2); the direction keys are the other way round (Shift is#run<dir>, mode 1; Ctrl is#rush<dir>, mode 3). The comment ininclude/context.his out of date. - A Lua callback
cmd_beforecan veto a command by name (can_do_extcmd()); renaming a command escapes a Lua blacklist, and the veto prints no message of its own. mention_decoris applied late on purpose, after the tutorial question, by re-reading the RC file with every other option switched off (rcfile_only_this_option).- Unverified: whether the sysconf file is read twice on a normal Unix start (
initoptions_init()andinitoptions()both contain the read).
Hero and actions
Hero code covers movement, fighting, items in use, skills, spells and religion.
Spread over many files: movement and terrain in src/hack.c, melee in src/uhitm.c, throwing in src/dothrow.c, kicking in src/dokick.c, eating in src/eat.c, wearing in src/do_wear.c, equipment slots and properties in src/worn.c, wielding in src/wield.c, applying tools in src/apply.c, reading in src/read.c, potions in src/potion.c, zapping in src/zap.c, spells in src/spell.c, prayer in src/pray.c, polymorph in src/polyself.c, riding in src/steed.c, ball and chain in src/ball.c, theft in src/steal.c. Roles and races: src/role.c, starting equipment: src/u_init.c, attributes: src/attrib.c, experience: src/exper.c. Shops (src/shk.c), temples (src/priest.c) and the quest (src/quest.c, text in dat/quest.lua, read by src/questpgr.c) react to the hero's actions.
The hero is two structures
The hero is struct you (include/you.h), global u, and also a struct monst, gy.youmonst. Shared monster routines (attack effects, armour, light, hiding) are called with &gy.youmonst. Role and race are gu.urole and gu.urace. While polymorphed (Upolyd) hit points are u.mh and u.mhmax, not u.uhp. Movement points are in u.umovement, deliberately not in youmonst.movement. The split is known to be unfinished (umonst in you.h).
Design
- Direction and target travel through globals, not arguments:
u.dx,u.dy,u.dzandgb.bhitpos.do_attack()states "u.dx and u.dy must be set". A caller that reuses an attack routine without setting them (src/dig.cdoes) attacks the wrong square. - A multi-turn action is
nomul(-n)plus one global completion function,ga.afternmv. There is room for exactly one. Other code finds out what the hero is busy with by comparingafternmvto a function (do_wear.c,weight_cap()inhack.c). Anything that interrupts must clear it first (stop_donning(),cancel_don(),steal.c).unmul()clears it before the call because the function may set a new one. - Armour is slotted at the start of donning (
setworn()) but its effects wait forArmor_on()at the end. Levitation boots need a special case inweight_cap(); an interrupted donning has its own cleanup. setworn()andsetnotworn()are the only writers of the slot pointers and ofu.uprops[p].extrinsic, a bit set of the slots that grant a property. Writingu.upropsdirectly breaks the invariants that thesanity_checkoption reports as "Worn-slot insanity".w_blocks()allows one blocking item per property.domove_core()is an ordered chain of early refusals (luggage, swallowed, ice, confusion, trap avoidance, sticky monster, attack, iron bars, web, steed, rooted,test_move, ball and chain). The order decides which refusals cost time (svc.context.move = 0makes a step free). Moving a check across another changes that. The position changes in two steps: tentatively, thenu_on_newpos()once a pet swap is known to work.overexertion()(hunger, HP loss when heavily encumbered) is charged indo_attack(), afterattack_checks(), so declining to hit a peaceful costs nothing. Kicks, throws and spells do not pay it.- Safe-pet protection is in
do_attack(). Moving into a pet or peaceful monster swaps places unless a listed exception holds (Punished, a 1 in 7 roll, a long worm tail, hero in rock, pet in a tended shop). It is off while blind, confused, hallucinating or stunned. Stormbringer ignores it. - One attack-effect table serves three directions. The
mhitm_ad_*handlers insrc/uhitm.ctake(magr, mattk, mdef, mhm)and branch on hero attacks monster, monster attacks hero, monster attacks monster.mhitu.candmhitm.ccallmhitm_adtyping(). Editing one handler changes all three.gazemm()passes a NULL result struct tomhitm_ad_blnd. - Hit processing is split so that the message is chosen before damage is applied (
struct _hitmon_datainyou.h).first_weapon_hit()must run beforexkilled()to keep the gamelog in order, andhmon()may destroy the weapon, so callers save what they need first. - Skill numbers in
include/skills.hare an ordered contract: positive for hand-to-hand weapons and launchers, negative for ammunition, ranges tested with<=, andweapon.candoc_subtypdepend on the order. No compile-time check was found (unverified). - Attribute training (
exercise(),exerchk()insrc/attrib.c) is statistical: it runs every 800 to 999 turns, halves the stored value each time and covers only Str, Con, Dex and Wis. Adding or removing anexercise()call changes growth over thousands of turns without a local symptom. - Starting inventory is created after the first level (
u_init_inventory_attrs()called fromallmain.c) and may be rerolled by thererolloption, so it must be idempotent. Once-only effects are inu_init_skills_discoveries().ini_invrelies on object-class creation order to avoid polymorph plus polymorph control. - Prayer sets
u.uinvulnerablefor the three turns ofnomul(-3)only when the outcome is good and the hero is not in Gehennom.nomul()normally clears the flag; the early return whenmulti < nvalkeeps it alive (inferred). Trouble priorities are the order of checks inin_trouble(), "keep that code and these values synchronized". A sacrifice cannot raise Luck above the offering's value (balance rule,src/pray.c). - Spell memory is a countdown from
KEEN(20000 turns) insrc/spell.c; the status thresholds are fractions of it.
Traps in hero code
restore_attrib()insrc/attrib.cis dead code; do not use it as a model for timed attribute loss.src/uhitm.c(6,447 lines) andsrc/trap.c(7,221 lines) are both larger thansrc/shk.c(6,147).- The kick, throw, apply, eat, read, zap and potion code was not read for the deep dive. Treat its rules as unverified.
Level generation and dungeon
Levels are generated at first visit, by random code in src/mklev.c or from a Lua script.
The dungeon's shape (branches, depth ranges, special level positions) is dat/dungeon.lua, read once per game by init_dungeons() in src/dungeon.c and then saved. Special levels are built by their Lua scripts through src/sp_lev.c; ordinary levels from rooms. Timers (src/timeout.c), light sources (src/light.c) and gas regions (src/region.c) are per-level state. src/stairs.c holds the stairway list; branch placement is place_branch() in src/mklev.c.
Which generator runs
makelevel() in src/mklev.c tries, in order:
- A level in the special-level chain, except the Rogue level, goes to
makemaz(slev->proto). - A dungeon with a
protofile(Vlad's Tower) goes tomakemaz(""), with the level number appended (tower1). - A dungeon with a
lvlfill(Mines, Gehennom fillers) goes tomakemaz(fill_lvl). - Quest levels build a filler name from the role (
<role>-filaor-filb). - Gehennom, and 80 percent of main-dungeon levels below Medusa, go to
makemaz("")(unverified for the whole range). - Otherwise the room-and-corridor code runs. The Rogue level takes this path too, via
makeroguerooms().
Despite its name, makemaz() in src/mkmaze.c is the loader for every scripted level: it builds the file name, calls load_special(), and makes a random maze only if the script fails to load. A level with nlevels variants gets -N appended (minetn-3). In wizard mode SPLEVTYPE forces a variant, and a level chance (only bigrm, 40) is ignored, so wizard games always have the big room. A dungeon-level chance is implemented in init_dungeon_dungeons() but no shipped dungeon sets it.
Dungeon placement
init_dungeons() rolls each dungeon's length, then place_level() places every special level by recursive backtracking inside its base/range window (negative base counts from the bottom). If nothing fits, panic("init_dungeon: couldn't place levels"): an edit to dat/dungeon.lua windows can make every new game fail at start.
level_map[] in src/dungeon.c maps Lua level names to C globals (oracle_level, stronghold_level, ...) behind Is_medusa_level(), Is_stronghold() and similar. A level added only to dat/dungeon.lua is placed and generated but has no C identity until it is added there.
Three numbers are not the same: ledger_no() is a game-wide running number (level files, bones, save); depth() is depth_start + dlevel - 1; level_difficulty() adds a correction in "builds-up" branches (Sokoban, Vlad's Tower) and uses the deepest level reached when the hero carries the Amulet. Monster and special-level code should use level_difficulty().
Ordinary levels
The order in makelevel() is load-bearing: rooms, sort_rooms(), stairs, corridors, niches, vault, at most one special room, branch, fill.
sort_rooms()orders rooms left to right becausemakecorridors()joins room N to N+1. It rewritesroomno, so room numbers stored before the sort go stale.- Special rooms are chosen after corridors:
mkshop()needs a room with exactly one door and no stairs.do_mkroom()andmkzoo()only set the type; stocking happens at the end throughfill_special_room()insrc/sp_lev.c. - Rooms are placed from a list of free rectangles (
src/rect.c). The functions that size and test a room,create_room()andcheck_room(), and the corridor diggerdig_corridor()live insrc/sp_lev.c, notmklev.c. - Corridors: pass 1 joins a to a+1, pass 2 joins a to a+2 if not yet connected, pass 3 joins any disconnected pair. Pass 1 to 3 guarantee connectivity. A fourth pass adds dead ends and boulder corridors on purpose.
mklev_sanity_check()reports "room %i not connected?" whensanity_checkis on. - The Fort Ludios branch is created lazily by
mk_knox_portal(), from a vault on a main-dungeon level deeper than 10 and above Medusa, with a 1 in 3 roll (always in wizard mode). A game can have no way to Ludios. bound_digging()makes the outer walls non-diggable and non-phaseable, once, at the end.mineralize()seeds gold and gems only into stone with stone on all eight sides, and mostly not on special levels.topologize()stampsroomnoand shared-wall bits at the end; it skips rooms already stamped.mklev()reseeds the random number generator before and after a level (reseed_random), but only when the seed source is unguessable. The aim: a level's look must not let a player predict later random numbers.
Themed rooms
dat/themerms.lua uses the same des.* Lua API as special levels. While gi.in_mk_themerooms is set, the meaning changes: rooms default to unfilled, a room or map that does not fit sets gt.themeroom_failed instead of shifting, and later des.room and des.map calls return early. Failure is a flag, not an error, so a des.* change that works for special levels can silently break themed rooms. makerooms() gives up after 10 failed attempts.
The hooks run in a fixed order: pre_themerooms_generate, themerooms_generate (repeated), post_themerooms_generate, corridors and filling, post_level_generate, then a whole-map wallification. Rooms added in post_level_generate are not joined by corridors.
Each dungeon branch has one persistent Lua state, gl.luathemes[dnum] (see Lua integration). Scripts must reset their own variables. A failed load writes '\0' into svd.dungeons[dnum].themerms, which disables themed rooms for that branch for the rest of the game.
Special levels
- After a script runs,
load_special()flips the level at random unless the script saysnoflip,noflipxornoflipyindes.level_flags. Anything that stores absolute coordinates (C fixups,lregiondata, level-specific code) must go through the same transform. Castle, Oracle and many fillers setnoflip. fixup_special()insrc/mkmaze.cis a patch list for "what the level compiler cannot handle" (the compiler is gone; Lua replaced it): Medusa statues,baalz_fixup, graveyard flags,check_ransacked()for orc-raided Mine Town ("only works as long as orctown is minetn-1").ensure_way_out()is opt-in through theinaccessiblesflag and marked for rewrite. Onlyminetn-6.luauses it. There is no general connectivity guarantee for scripted levels.- Caves come from a script,
des.level_init({ style = "mines" }), which runsmkmap()throughsplev_initlev(). The same call requests a random maze, a swamp or Rogue-style rooms.
Lua integration
Lua scripts drive level creation, the dungeon layout and some game events, inside a sandbox.
The interpreter is vendored in nhlua/lua/ (Lua 5.5.1, updated with nhlua/nhlua-update.sh, no NetHack edits). The glue is src/nhlua.c (the nh function table, sandbox, nh.variable, nh.callback), src/nhlobj.c (object bindings) and src/nhlsel.c (selections). Level-building functions (des.*) are in src/sp_lev.c. The reference for script authors is doc/lua.adoc. The scripts in dat/ include two library files loaded into every state: dat/nhlib.lua and dat/nhcore.lua.
States
Every interpreter is made by nhl_init(&sbi). All callers pass the same policy (NHL_SB_SAFE, 1 MiB memory, 1,048,576 steps per call), written out again in each caller (src/mklev.c, src/dungeon.c, src/questpgr.c, src/sp_lev.c, src/wizcmds.c). There is no shared constant.
gl.luacore: one state for the whole game, loadsdat/nhcore.lua, holds the saved variables and runs callbacks.gl.luathemes[dnum]: one per dungeon branch, kept between levels.- A throw-away state per special level, and one in
init_dungeons()fordat/dungeon.lua. - A cached quest state, made at the first quest text.
- A
#wizloadluastate with a 16 MiB budget.
Sandbox
The sandbox opens a whole Lua library and then sets unwanted functions to nil. NHL_SB_SAFE allows only the safe base functions (ipairs next pairs pcall select tonumber tostring type xpcall) and coroutine, table, string, math, utf8. It does not include error, assert, setmetatable and friends, collectgarbage, load, dofile, io, os, package (so no require) or debug; print and warn are always removed. Scripts are written in that subset.
- The step limit counts blocks of
NHL_SB_STEPSIZE(1000) instructions. Running out always panics ("Lua time exceeded"), whateverNHLpa_*the caller chose, because the game state may be corrupt. Running out of memory is an ordinary Lua error and does follow the caller's choice. NHL_SANDBOXoff ininclude/config.hremoves more than the library filter: the step limit, the version check and the stripping, soio,osanddebugare open. Only the memory limit stays. Never do it in a build that loads untrusted scripts.- The version is pinned to exactly 5.5 (
NHL_MIN_VERSION_NUM_EXPECTEDandNHL_MAX_VERSION_NUM_EXPECTED, both 505). Another version panics with "sandbox doesn't know this Lua version". A Lua upgrade means checking thect_*function tables and both constants together. - Script errors are fatal by policy. Nearly every call into Lua uses
NHLpa_panic(thelspo_*callbacks, theme hooks,nhcorecalls, callbacks, restore).NHLpa_impossibleis used for loading a file,pre_themerooms_generate, the selection iterator and saving variables. A script is part of the program.
Traps in Lua integration
- A nested
nhl_pcallon the same state refills the step budget and leaves a stalejmp_buf.setjmp(nud->jb)and the counter are per state, and level scripts call back into Lua on the same state (des.room({ contents = function() ... end }),l_selection_iterate). The limit is therefore per nested call, and an overrun in the outer script jumps into a finished stack frame (undefined behaviour). Derived from the code, not run. dat/nhlib.luais loaded into every state. It replacesmath.randomwith the game'srn2, so scripts share the game's random stream, and it shuffles the globalaligntable at load. Creating a state therefore consumes random numbers, and the lazily created quest state makes that point depend on play. The comment innhlL_openlibssayingmath.randomuses Lua's own generator is out of date.- The only Lua data in a save file is
nh_lua_variablesinluacore. It is saved as Lua source text (get_variables_string) and run on restore inside the sandbox, which is why the save file counts as trusted input.nh.variableaccepts string, nil, boolean, number and table; a non-integer number is probably stored as 0 (unverified). Theme states, the quest state and script globals are lost on save. - Callbacks are stored twice and only one copy is saved.
nh.callbackrecords the name innh_lua_variables._CB_<list>(saved) and counts innhcb_counts[](not saved). After a restore, the counts are 0 and the callbacks never run. This is harmless today only because the one user, the tutorial, cannot be saved. Callbacks are found by name inluacore's globals, so they must be defined innhlib.luaornhcore.lua. nhl_loadlua()reads scripts throughdlbin 8 KB chunks; a line of 8 KB or more fails the load.- The file-access hooks (
io.openwith pattern checks,luapat) are dead code under#ifdef notyet, and the block refers toNHL_SB_PACKAGE, which is not defined. The examplemk_dgl_extrainfoindat/nhcore.luacallsio.openand would fail in the sandbox.
Monsters
Monsters are a table of species plus linked lists of live creatures.
The species struct is struct permonst (include/permonst.h); the data is include/monsters.h, built into mons[] by src/monst.c. Live monsters are struct monst (include/monst.h). Creation is src/makemon.c, the mover movemon() and death in src/mon.c, per-monster decisions src/monmove.c, attack loops and hit rolls against the hero src/mhitu.c and monster against monster src/mhitm.c, ranged attacks src/mthrowu.c, item use src/muse.c, spellcasting src/mcastu.c, pets src/dog.c and src/dogmove.c, helpers src/mondata.c. Special cases have their own files: shopkeepers and priests (src/shk.c, src/priest.c), the Wizard of Yendor (src/wizard.c), vault guards (src/vault.c), player-monsters (src/mplayer.c), minions (src/minion.c), lycanthropes (src/were.c), long worms (src/worm.c), theft (src/steal.c).
Where a monster is
A monster is in exactly one of three places: on the level (fmon, which is svl.level.monlist, and svl.level.monsters[x][y]), migrating (gm.migrating_mons, MON_MIGRATING) or travelling with the hero (gm.mydogs). mon_sanity_check() enforces it through mstate and mx.
- Death does not free.
mondead()callsm_detach(), which setsmhp = 0and counts the monster iniflags.purge_monsters;dmonsfree()frees it later and callsimpossible()if the counts differ. Code that walks the list must testDEADMONSTER(). - Vault guards are the exception: a dead or departed guard stays at
<0,0>(PARKEDMONSTER) until his corridor is gone. Treatingmx == 0as "invalid" breaks guards. - While a monster migrates,
migrate_to_level()reuses fields as a message tomon_arrive():mux,muyare the destination,mtrack[0..2]arrival data,wormnothe tail length. Do not read them as hero position or history. - Levels that are not occupied are not simulated.
mon_catchup_elapsed_time()insrc/dog.cpays back the elapsed time on return. A new per-turn timer field onstruct monstthat is not added there freezes while the hero is away.
Rules the code defends
mons[]is a runtime copy ofmons_init[], made bymonst_globals_init(). Nothing writes to it any more (the only writers are inside#if 0), so treat it as read-only. Per-game variation happens at use time:getmattk()insrc/mhitu.crewrites the attack on a copy before each use (seduction substitutes, no two disease attacks in one turn, cooldown degrades to a 1d6 claw, cancelled attackers turn physical). Code that readsmons[x].mattk[i]directly sees the unsubstituted attack.- The damage of an attack type is in
src/uhitm.c:hitmu()andmattackm/hitmmbuild astruct mhitm_dataand callmhitm_adtyping(), which dispatches to onemhitm_ad_<type>()with three branches inside (hero attacks, hero is hit, monster against monster). A new damage type, or a change to fire or drain, is made in all three branches of one function.mhm.doneends the attack early. - Speed is randomly rounded to multiples of 12 in
mcalcmove(); see Game loop and startup for the guarantees.worm.ccalls it withFALSE, which skips the randomisation. - Extinction counts births, not deaths.
propagate()raisessvm.mvitals[].born(auchar) and setsG_EXTINCTatmbirth_limit(); uniques are extinct as soon as they are made.unmakemon()undoes a rejected creation and refuses to decrement at 255.mvitals[].diedalso divides experience for later kills. - Monsters know the hero's position only approximately:
mux,muyis "where the monster thinks you are".dochug()callsset_apparxy()before the monster decides, becauseinrange,nearbyandscaredcome from it.onscary()uses the real position unless the monster is blind or the hero invisible. dochug()has four phases: pre-movement, special actions (tactics,find_defensive,find_misc),m_move, standard attacks. A monster that moved can still attack in the same turn if it has a ranged or weapon attack; the hero cannot. The code comment asks whether that is fair.- Monsters play by weaker rules (
src/muse.c): they read scrolls only when they can see, and innate teleportation is refused on purpose ("you could never catch them"). A first attack-wand shot always misses. - A pet is a
struct monstwithmtame > 0andEDOG(mon)inmextra.mextraalso carriesegd,epri,eshk,emin. Flagsisshk,isgd,ispriest,isminionmirror which pointer is set.include/mextra.hhas an 11-step recipe for a new extension; skippingcopy_mextra,savemonorrestmoncorrupts clones and saves.
Adding a species
include/monsters.his read three ways (thePM_enum, the table, an enum dump). Rule 1 of its header holds: a class is contiguous. Rule 2 (ascending strength) no longer does;init_mongen_order()sorts by class and difficulty at first use. Entries fromPM_LONG_WORM_TAILon are never generated randomly.- Dragons are order-bound: gray first, yellow last, the same order as dragon scale mail and scales in
include/objects.h, and shimmering follows silver fortilemap. Reordering is not a local change. difficultyis filled in by hand. Generation uses it in three unrelated formulas:rndmonst_adj()(zlevel/6 up to (zlevel + hero level)/2),mkclass_aligned()(level_difficulty() >> 1, sometimes accepting a stronger candidate) andadj_lev()for the created monster's level. Changing one does not change the others.
Objects
Objects are a table of kinds plus live items held in several chains.
The kind table is src/objects.c, built from include/objects.h; live items are struct obj (include/obj.h). Creation is src/mkobj.c, random appearances are shuffled in src/o_init.c (which also holds discoveries and per-level gem odds), names in src/objnam.c, the inventory and stack merging in src/invent.c, picking up in src/pickup.c, deletion bookkeeping in obfree (src/shk.c), Lua references in src/nhlobj.c, artifacts in src/artifact.c with data in include/artilist.h.
The kind table
objects.his included four ways: theotypenum (OBJECTS_ENUM,include/objclass.h),OBJECTS_DESCR_INITandOBJECTS_INIT(src/objects.c) and an enum dump (DUMP_ENUMS,src/earlyarg.c). The index of a kind is its position in the file; inserting or reordering shifts every laterotypand changes the meaning of old saves.OBJECT()has 15 parameters; a new field is packed intoBITS().objects[]is writable, shuffled per game and saved (savenamesinsrc/o_init.c). Do not treatoc_prob,oc_descr_idx,oc_color,oc_materialoroc_name_knownas constants.- Order is checked at startup: kinds must be in ascending
oc_classorder (init_objectspanics "not in order!"), slot 0 is the strange object, slots 1 to 17 are generic per-class dummies, and the array ends with a NULL-named fencepost. - Which kinds swap appearances is written by name in
obj_shuffle_range(src/o_init.c). Amulets, scrolls and spellbooks shuffle only up to the first non-magic or unique entry (the fake Amulet of Yendor must come before the real one); gloves keep IRON asCLR_BROWN. A helmet, glove, cloak or boot added outside the named end points is silently left out of the shuffle. No check found (unverified by full-tree search). oinit()runs on level creation and arrival and callssetgemprobs(), so gem odds depend on depth.mkobjrolls againstgo.oclass_prob_totals; anything else that changesoc_probmust update the class totals.mkobjpicks the class from a table per setting (mkobjprobs,rogueprobs,hellprobs).
Live items
- An object is in exactly one place, named by
obj->where(ten states ininclude/obj.h: floor, contained, inventory, monster inventory, migrating, buried, on a bill, and so on), each with its own chain.obj_extract_selfis the single exit from any chain; moving an object is extract, then add.obj_state_names[]insrc/mkobj.candNOBJ_STATESmust stay in step with the states. - Deletion is deferred. Do not call
dealloc_objdirectly; usedelobj()thenobfree(), which fix shop bills, leashes and contents.dealloc_objparks the object ongo.objs_deleted, or leaves it to Lua's garbage collector if Lua holds a reference (lua_ref_cnt).dobjsfree()frees it from the main loop. A freed object is overwritten withcg.zeroobjso use-after-free shows up as a panic. - Fields are overloaded.
spehas about fifteen meanings by type;corpsenm,usecount,oeroded,owornmaskandomidserve several purposes each.splitobjandbill_dummy_objectcopy the whole struct, then reset a hand-picked list (oextra,timed,lamplit,owornmask,lua_ref_cnt,pickup_prev). A new field that must not be inherited goes into both. include/obj.hends with an 11-step checklist for a newoextrastructure (init_oextra,size_objinsrc/cmd.c,dealloc_oextra,copy_oextra,restobj,saveobj). Missing a step leaks the data, loses it on split or on save.mergable()insrc/invent.cis the only definition of "same stack" (about 25 properties). Candles merge only ifage/25matches; hatching eggs, reviving corpses and lit oil never merge; knowledge bits matter only while Blind or Hallucinating. Thenomergebit keeps prize items (Mines, Sokoban) apart until picked up.next_ident()addsrnd(2), not 1, on purpose: with#adjusta player could otherwise learn the low id bit of the next object and wish for one that behaves well.- Name strings (
xname,doname) come from a ring of 12 buffers (NUMOBUF,src/objnam.c). A pointer is valid for the next 11 naming calls only. - Artifact existence is a second registry:
artiexist[]insrc/artifact.c(saved) stops a second copy. When an artifact object is destroyed, callartifact_exists(obj, name, FALSE, ...)to "un-create" it, assrc/mkobj.cdoes for monster inventories; otherwise it can never be generated again.include/artilist.his compiled four ways likeobjects.h.
Display and vision
Display decides what character or tile each map square shows; vision decides what the hero can see.
The design is stated at the top of src/display.c: vision decides what can be seen, display decides what is shown, and the window port decides how to draw it. The core never writes characters; it writes glyph integers.
Files: src/display.c (what is shown; the hero's memory), src/vision.c (line of sight, lit squares), src/light.c (light sources), src/glyphs.c (glyph names and player customisation of symbols and colours), src/drawing.c (default tables), src/symbols.c (loading and switching symbol sets), src/coloratt.c, src/utf8map.c; the selectable symbol sets are in dat/symbols. A glyph is an integer for one thing to draw (include/display.h). Symbol definitions are one macro list, include/defsym.h, included with different macros for the enums, the config parser, the default tables and the tile names.
Display
- The hero's memory is one glyph per square,
levl[x][y].glyphinstruct rm(include/rm.h), next totyp(what is really there). Monsters are never in it; they "float above". Exceptions: the remembered unseen-monster glyph, and mimics, which overwrite the memory with their fake appearance on purpose (display_monster()does the mimic check first). - Use
newsym()to update a square; it follows the in-sight rules. Themap_background/object/trap/engraving/invisiblefunctions change memory permanently ("sticky") and are not for temporary effects. Temporary effects useshow_glyph()ortmp_at(). Never callprint_glyph()directly. All of this is a convention; nothing enforces it. show_glyph()writes to a third screen,gg.gbuf, and onlyflush_screen()callsprint_glyph(), for changed cells. The screen can lag the game until a flush.flush_screen(-1)toggles delayed flushing and is not a flush. A static flag stops the loop flush,print_glyph,impossible,pline, flush.waslitis set fromlev->lit, nottemplit, to avoid the "light pool" problem. The out-of-sight darkening innewsym()must stay there and not move intoback_to_glyph().- Walls are drawn from the directions the hero has seen them (
seenv) and the wall mode.wall_angle()turns type,wall_infoandseenvinto a symbol; a changedseenvforces anewsym(). The source says: take pen and paper. Wrong states (a door flag left on a wall) end inimpossible(). - The glyph enum (
glyph_offsetsininclude/display.h) is ordered:reset_glyphmap()andshow_glyph()walk the families top down and say so. Inserting a family elsewhere gives wrong symbols without an error.struct rmis saved whole, so reordering also changes old saves;EDITLEVELininclude/patchlevel.hexists for that (inferred). glyphmap[](glyph to symbol, colour, flags) is rebuilt byreset_glyphmap(trigger)on level change (the Rogue level has its own symbols), symbol change, option change, accessibility change and new game. A feature that changes appearance and does not call it shows up late.- Adding or removing a
defsymsentry needs the same change inaltlabels[]inwin/share/tilemap.c; that pairing is not generated.
Vision
- Vision is recomputed lazily.
gv.vision_full_recalcis set byblock_point,unblock_pointand light changes, and cleared invision_recalc().pline()runs the recompute first, so a message describes what the hero sees now. Code that changes terrain or light without those calls leavescansee()stale. vision_recalc(control): 0 normal, 2 "see nothing, thennewsym()everything" (used bydocrt(),goto_level, swallowing). Value 1 is documented as adjacent recalculation but is unimplemented and treated as 0, though some callers pass it.- Two line-of-sight systems exist and can disagree. The main one is "Algorithm C" over
viz_clear[][]. The second,clear_path()(a Bresenham walk), serves monsters (m_cansee) and light sources away from the hero.COULD_SEEmeans "an unobstructed line exists", not "the hero sees": it is computed even when the hero is blind, because monsters use it (m_canseeu()). - Vision code indexes
[y][x]; everything else uses[x][y](a banner at the switch insrc/vision.c).block_point(x,y)swaps on the way in. A swapped call works on some positions and fails on others. viz_clear[][]is a cache ofdoes_block()(walls, closed doors, trees, clouds, boulders, mimics, gas regions). Whatever changes these must callblock_point,unblock_pointorrecalc_block_point. The wizard-modelevl_sanity_check()compares both.- Light sources are recomputed in full on every vision recalc, on purpose: no extra memory, no tracking of movement. A moving lamp only needs
vision_full_recalc. The polymorphed hero relies onyoumonst.m_idstaying 1 andmxstaying 0. - A save made with a stale
EDITLEVELonce leftlight_basecircular (comment insrc/light.c). Changing the saved shape of light sources, timers orstruct rmwithout bumpingEDITLEVELrisks it. UNBUFFERED_GLYPHINFOis defined nowhere ininclude/,src/,win/orsys/, and its branch inrow_refresh()would not compile. Treat it as dead.
Window ports
A window port is a user interface: it implements one table of functions that the core calls to draw.
The table is struct window_procs (include/winprocs.h), copied by value into the global windowprocs. src/windows.c lists the ports compiled in (winchoices[]), and choose_windows() selects one by name, case-insensitively. The contract is doc/window.txt; its header date is old and it still says to edit makedefs.c for window_opts, which now lives in src/mdlib.c.
Ports: win/tty (default), win/curses, win/Qt, win/X11, win/win32 (the name to type for windowtype is mswin) and win/gem (Atari, kept but not for current platforms). win/macosx is not a port: it holds AppleScripts and a recover script; on macOS the game uses tty, curses, X11 or Qt. outdated/win holds ports no longer maintained. Which ports are built is a build option: WANT_WIN_* in the Unix and macOS hints files, and other switches on Windows.
The function table
struct chain_procsrepeats every member with a leadingCARGSparameter, and the comment "this MUST match chain_procs" is the only guard. Port tables use positional initialisers, so order matters, and a swapped pair of same-typed pointers compiles. Adding a member means editingwindow_procs,chain_procs, the macro list,hup_procs,chainin_procs,chainout_procs,win/chain/wc_trace.cand every port table.add_menu(),select_menu()andgetlin()are real functions insrc/windows.c(andyn_functioninsrc/cmd.c) that do common work, then call the port.add_menuapplies menu colours;select_menuandgetlindisable status redraws while they run;getlinfirst drains the command queue and, if queued keys exist, never calls the port. A port that calls its owntty_add_menudirectly skips this.- Status has two paths, chosen by
VIA_WINDOWPORT(), which tests the capability bitsWC2_HILITE_STATUSandWC2_FLUSH_STATUS. With them,bot()pushesstatus_update()calls per field; without them, the core writes two preformatted lines to anNHW_STATUSwindow, whichdoc/window.txtsays will be phased out.genl_status_updatesetsWC2_FLUSH_STATUSat run time. - Glyphs travel inside strings as
\GXXXXNNNN, written byencglyph()with the per-game numbersvc.context.rndencode, so that player-supplied text such as pet names is not decoded by accident. Never hard-code the prefix. wincaphas no free bits;wincap2has about 12.doc/window.txt("Adding a new wincap option") gives the procedure.- The
genl_*helpers are the default implementations (genl_putmixed,genl_status_*,genl_getmsghistory). Their comments are the only spec for message history (called oldest first, a null pointer ends it) and menu inversion (menuitem_invert_test). - The port is not fully behind the table. The core branches on
WINDOWPORT(x)(an enum,wp_ids) ininvent.c,files.c,options.c,wizcmds.c,report.candearlyarg.c. A new port needs awp_id.
Selection and hangup
choose_windows()skips names starting with+(chain processors) or-(chain ends), so they cannot be chosen. On a miss it first installsdef_raw_printanddef_wait_synch, because early config-error code calls them. It may run several times; each port's init routine undoes the last choice onWININIT_UNDO.windowtypeis honoured only beforeiflags.window_inited.- Hangup replaces the whole table with
hup_procs: outputs do nothing, input returns ESC. Two old routines are kept on purpose: the previousexit_nhwindows(called later byhup_exit_nhwindows) and the previousgetmsghistory, which the save needs. - The dumplog redirect (
dump_open_log,dump_redirectinsrc/windows.c, used bysrc/end.c) swaps nine slots and restores with a whole-struct copy. Any slot changed in between, such as a hangup, is undone (inferred).
Chain and shim
win/chainis not a port and cannot be chosen withwindowtype. Processors are named+traceand set through the sysconf-only optionwindowchain;commit_windowchain()inserts-chaininat the head and-chainoutat the tail. The only processor is+trace, which writestlog.<pid>.WINDOWPORT(tty)is probably false under a chain (inferred).WINCHAINis off ininclude/config.hand the platform mains callcommit_windowchain()only under#ifdef WINCHAIN.sys/unix/README.hintsdocumentsWANT_WINCHAIN=1, which no file uses; the hints useWANT_WIN_CHAIN, which only adds object files. Enabling the chain means editingconfig.h.win/shimforwards every window call to one registered callback and is the default for the library and WebAssembly builds (sys/unix/hints/include/cross-pre2.501,multiw-2.501; contract insys/libnh/README.md). With no callback every call returns 0.- Known defects in
win/chain:trace_procs_initcallssetvbufbefore the null check onfopen, and some tables use#ifdef MACwherewindow_procsusesMAC68K.
Save and restore
Saving writes the global state, the hero and every level; restoring reads them back in order.
src/save.c writes, src/restore.c reads, in matching pairs (savemon/restmon, saveobj/restobj, and so on). Underneath, src/sfbase.c dispatches and src/sfstruct.c writes raw struct dumps. Bones files (src/bones.c) store the level where a hero died; no_bones_level() says which levels never get one. The high-score list is src/topten.c.
Format
- The game always writes the
historicalformat, a raw dump of C structs. There is no build switch and no run-time option (src/files.chard-codes it).exportasciiexists only in the separatesfctoolutility, an offline step for moving a save between platforms that run the exact same version. The generatedsfdata.candsfproto.h(fromutil/sftags.c, which reads a ctags file) are built only forsfctool(sys/unix/Makefile.utl). - The stream is untagged and strictly ordered. The name strings passed to
Sfo_*/Sfi_*are ignored by the game binary. A write without the matching read shifts every later field and shows up far away as nonsense or "Read N instead of M bytes".saveobj()andrestobj()must mirror each other, including the optionaloextraparts. - File order: format byte, critical-size list, version info, player name, current level, game state, then every other level preceded by its ledger number. Restore reads the current level, the game state and the other levels, writes the others back as level files (
restlevelfile()), rewinds, and reads the current level a second time. The first read happens before the dungeon table exists (seesave_bubbles()). program_state.restoring(REST_GSTATE,REST_LEVELS,REST_CURRENT_LEVEL) changes whatgetlev()andsavelev_core()do: catch-up for time away and the level timestamp depend on it. Level code that runs during restore must not assumeu.uzis valid.- Time values are saved relative to the current turn (
moves_to_relative_time()), and each level carries its own timestamp. A new turn-valued field saved as an absolute number is wrong the next time the level loads. - Pointers are never saved. Context pointers (
victual.piece,tin.tin, ...) are cleared and their ids kept;u.ustuckandu.usteedare rebuilt fromustuck_midandusteed_midingetlev(); worn items bysetworn()after the inventory is read. A new pointer field in a saved struct needs an id-based fixup, or it restores a wild pointer. - The date-dependent luck changes (full moon, Friday the 13th) are undone in
dosave0()and re-applied at start-up. Keep the pair symmetric. - Lua state is saved as a text blob:
save_luadata()stores only thenh_lua_variablestable (see Lua integration). - Which globals are saved is decided field by field; see Global state.
Version checks
validate() and uptodate() in src/version.c read the critical-size list and struct version_info before any data is trusted. check_version() rejects the file if the version is outside [VERSION_COMPATIBILITY, current] or if the feature bits or the structure entity count differ. Different data models are rejected, not converted. include/patchlevel.h holds only the constants: EDITLEVEL, VERSION_COMPATIBILITY, SAVEFILE_REVISION_LEVEL. The authors' rule: any incompatible change to saved data (struct context, struct flag, struct rm, glyph numbering, the light list) needs EDITLEVEL raised, which invalidates old saves and bones.
include/revision.h documents a 14-step "uplift" for reading old saves through a copy of a changed struct (src/revision.c). It is prepared, not exercised: SAVEFILE_REVISION_LEVEL is 0 and the demo code is example-only.
Saving and the running game
- Saving destroys the running game.
dosave0()sets the modeWRITING | FREEING: eachsave*chn()routine writes an item and frees it, anddosave()ends the process. A failure halfway cannot be rolled back. The same routines also run withCOUNTING, plainWRITING(checkpoints, bones) orFREEINGalone, selected byupdate_file()andrelease_data(). A new save routine that always frees, or always writes, breaks one mode. - A save is deleted when restored, except in wizard and explore mode (
dorecover()). A failed restore deletes it too.CHECK_SAVE_UID(default on) refuses a save that is "not yours" for normal players. - The head of the save (pid, level number, name) is written unbuffered on purpose, so that
util/recover.cand external tools can read it; the same holds for the bones info before the level data. Changing the order or encoding of the head breaks them without a compile error. - With
INSURANCE(default) and thecheckpointoption,save_currentstate()writes the current level and the whole game state into the level-0 lock file;recoverrebuilds a save from the level files.gh.havestatemakes turning checkpoints off rewrite level 0 once. - Bones use the level writer and reader with a "ghostly" flag (ids remapped, movement zeroed, peacefulness re-rolled, stairs and branch links repaired). A change to the level format affects saves, checkpoints and bones.
Traps in save and restore
- A truncated save is only partly guarded. In the level loop a short read ends the restore cleanly at a level boundary; inside a level the remaining reads continue (
sfstruct_read_error()is empty). Struct contents are not range-checked. Inferred from the code, not run. - The real checks are header validation, the pid and level-number check in
getlev()("This game is void",trickery()) and the UID check.
Platform layers and build
The sys/ folders and the build tools in util/ adapt one source tree to many systems.
sys/unix has the Makefile templates (Makefile.top, .src, .dat, .doc, .utl, .check), setup.sh and the hints files. sys/windows has two build systems for the same tree: GNUmakefile (MinGW) and Makefile.nmake plus vs/NetHack.sln (Visual C). It builds the console and the graphical program. sys/share holds code several systems use, such as the regex implementations. sys/libnh is the library build. outdated/ (repository root) holds retired systems, but sys/amiga, sys/atari, sys/mac68k, sys/msdos and sys/vms are still in sys/, and four of them have cross-compile targets (cross-pre1.501). Being in sys/ does not mean natively supported.
Unix Makefiles
setup.shcallsmkmkfile.shonce per Makefile (top, dat, doc, src, util).mkmkfile.shwrites the hints file's#-PREpart, the template, then the#-POSTpart. A line starting#-INCLUDE namein a hints file is replaced byhints/include/name; it is executed, not a comment. Edits to a generatedMakefileare lost at the nextsetup.sh: change the template or the hints file. Run the scripts fromsys/unix.Makefile.check(viaPRECHECKinlinux.501andmacOS.501) only reports "Attention: <file> is older than ..."; it neither stops the build nor rerunssetup.sh.HINTSINCLNAMESis edited by hand when a newhints/include/*.501file is added.- The hints family is larger than
sys/unix/README.hintssays.bsd.501("legacy", "use at your own risk", BSD make syntax) andlinux-minimaldo not use the shared includes or the stale-Makefile check, so a change to the shared includes does not reach them. - Top-level parallel make is off on purpose (
.NOTPARALLEL:inMakefile.top), because several targets each start a sub-make that builds hostmakedefsand would overwrite each other. - Hints for WebAssembly and the library:
cd sys/unix,./setup.sh hints/macOS.501, thenmake WANT_LIBNH=1 allormake CROSS_TO_WASM=1 all.sys/libnh/README.mdstill namessys/libandhints/wasm, which do not exist.
makedefs and data
util/makedefs.cmakes the data files:data(fromdat/data.base),rumors,oracles,epitaph,engrave,bogusmon,options. It no longer makesonames.handpm.h, and nothing includes them.makedefs -vstill writesinclude/date.hfor reference only.doc/makedefs.txtis out of date againstdo_makedefs()in the code (it lists-mand-qwhere the code uses-vand-r); trust the code.- Version, build date and git hash come from
src/date.c, filled at run time.date.ois deliberately built last and kept out of the object lists, so it is rebuilt after any other object (HOBJinMakefile.src,COREOBJSin theGNUmakefile). Adding it to a list builds fine and ships a stale build time. makedefslinksmonst.o,objects.o,alloc.o,date.oandpanic.o, and#includessrc/mdlib.cdirectly. These objects must be built first (FIRSTOBJ), and the rules formonst.oandobjects.odelete themakedefsbinary to force a relink. In a cross-compile they exist twice, for HOST and TARGET, and must not share names or directories.util/dlb_main.cpacksdat/files intonhdatonly whenDLBis defined: off ininclude/config.h, on in the Linux, macOS and cross hints. After the build,check-dlbinMakefile.topgrepsdat/optionsfor "librarian", written bymakedefs -vfromsrc/mdlib.c. The install layout therefore follows what the compiledmakedefsbelieves. Changing-DDLBwithout rebuildingmakedefsanddat/optionsinstalls the wrong layout.- Declared principle (
Cross-compilingA4,Porting5.4): the build must not produce files that depend on word size, byte order or struct layout. Levels, dungeon and quest text are Lua read at run time; makedefs outputs are text. A binary, layout-dependent build output breaks cross builds without breaking a native one.
The mains
There is no shared main(). Each platform main parses its own options, then calls early_init() first and moveloop() last; see Game loop and startup. early_init() in src/allmain.c runs decl_globals_init, objects_globals_init, monst_globals_init, sys_early_init() and runtime_info_init. sys/libnh/libnhmain.c copies much of unixmain.c.
Sound
Sound libraries are optional plug-ins that play effects and music for game events.
Two unrelated things share the word. src/sounds.c is mostly (about 1,500 of 2,200 lines) the game's in-world noises: dosounds() ("You hear..." for fountains, vaults, temples), monster noises, and the #chat and #tiphat commands. They work with no library. The plug-in interface starts at the library registry (soundprocs) near line 1,700. The interface is include/sndprocs.h, described in doc/sound.txt (partly wrong, see below). Libraries are in sound/: FMOD, windsound, macsound, mac68ksound. Qt's sound code is in win/Qt/qt_bind.cpp and qt_bind.h; sound/qtsound/ holds only a README. sound/wav holds sample files, stored uuencoded (*.uu) and listed in SNDWAVS in sys/unix/hints/include/multisnd2-pre.501.
How events work
- Events are raised by macros in
include/sndprocs.h(Soundeffect,Hero_playnotes,SoundAchievement,SoundSpeak,SetVoice,Play_usersound), called from many files. With noSND_LIB_*define they expand to nothing, so arguments are not evaluated in a no-sound build and a variable used only as a sound argument needsSOUNDLIBONLYto avoid an unused warning. - Every event passes a gate:
iflags.sounds, then!Deaf, then the function pointer exists, then the library'ssound_triggersbit is set. A library leaves bits clear for functions it leaves empty. FMOD and Qt claim onlySOUND_TRIGGER_USERSOUNDS. - Deafness is the hero's, achievements are the player's:
SoundeffectandHero_playnotesare suppressed whenDeaf,SoundAchievementis not. The macro you pick decides whether a deaf hero gets the event. - Sound effects are one X-macro list,
include/seffects.h, giving the idse_<name>and the file namese_<name>.wav. Adding one means a line there, aSoundeffect(se_x, vol)call and a wav file inSNDWAVS. Id 0 (se_zero_invalid) is reserved. - Speech compiles in only with
-DSND_SPEECH(WANT_SPEECH=1).SetVoice()before aplinesets the speaker in the globalgv.voice; forgetting it speaks the next line with the previous voice (inferred).
Selecting a library
- A library exists only if the build defined
SND_LIB_<NAME>(hints files,SNDCFLAGS). There is no run-time discovery. The Unix main hints only macsound; the Windows main prefers FMOD over windsound. CI builds macsound only (WANT_MACSOUND=1); the others are not built in CI. - Activation has two phases.
assign_soundlib()records a hint;activate_chosen_soundlib(), called frominit_sound_disp_gamewindows(), copies the table intosoundprocsand initialises it. The later hint wins, which matters for Qt, whose sound is part of its window port. Thesoundliboption only sets the hint, so it takes effect at the next start; an unknown name silently falls back tonosound. enum soundlib_idsis also the index intosoundlib_choices[]insrc/sounds.c. The two lists must match; a mismatch selects the wrong table without a compile error. Adding a library touches the enum, theSND_LIB_INTEGRATEDlist, the extern andsoundlib_choicesentry,soundlib_opts[]insrc/mdlib.cand the build files (doc/sound.txtsection VII).- The name builders (
get_sound_effect_filename,base_soundname_to_filename) return NULL on any doubt; callers must handle it. The first exists only underSND_SOUNDEFFECTS_AUTOMAP, user sounds (regex on message text) only underUSER_SOUNDS.
Traps in sound
SoundeffectEvenIfDeaftests the pointer backwards (&& !soundprocs.sound_soundeffect), so with a real library it does nothing. No caller exists yet.sound_ambienceargument order differs betweeninclude/sndprocs.h(ambience_action, ambienceid, proximity) anddoc/sound.txtand FMOD (ambienceid, action, proximity). Nothing calls it.doc/sound.txtdrifts from the code: it says four trigger types (six exist), namessa2_levelup(the enum hassa2_xplevelup) andiflags.voice(it isvoices), puts Qt code insound/, points tomacOS.500(the file ismacOS.501), and its sample skeleton does not compile.sndprocs.hdeclaresstruct sound_procs sndprocs, which is never defined; the variable issoundprocs.