From 45dba95678d831afa24801abd9926b492e16863f Mon Sep 17 00:00:00 2001 From: sneak Date: Mon, 6 Jul 2026 18:17:48 +0200 Subject: [PATCH] Add ARCHITECTURE.md Part 2: Go OOP port design Design for the Go port (module sneak.berlin/go/rogue): - RogueGame instance type owns all former globals; no package globals - THING union split into Creature (Player/Monster) and Object - Slices replace THING linked lists; list.c is not ported - Daemon function pointers become serializable DaemonID enum + dispatch - tcell/v2 (sole third-party dep) replaces curses; Window cell buffers preserve the screen-as-data-structure idiom - encoding/gob snapshot replaces the 2,134-line state.c serializer - Exact-LCG RNG for seed-compatible dungeon generation, with golden tests - Per-file porting map, naming conventions, porting order, drop list --- ARCHITECTURE.md | 612 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 612 insertions(+) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 8392dc7..3b93e08 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -882,3 +882,615 @@ typedef struct sc_ent SCORE; ``` Declares `rd_score(SCORE*)` / `wr_score(SCORE*)` (implemented in save.c). + +--- + +# Part 2 — The Go Port Design + +Module: **`sneak.berlin/go/rogue`** + +## 1. Goals and principles + +1. **Faithful gameplay.** The port reproduces Rogue 5.4.4 behavior exactly: + same dungeon generation for a given seed, same combat math, same item + tables, same messages. The unit of porting is the C function; every C + function becomes a Go method (or function) with a recognizable name, ported + line by line unless a rule below says otherwise. +2. **No globals.** All formerly-global state lives in a single instance type, + **`RogueGame`**, which owns per-game state and exposes the game's main + entrypoint (`Run`). Two `RogueGame` values are two independent games. + File-scope C statics (pagination counters, shadow copies, scratch buffers) + become fields too — either on `RogueGame` or on the subsystem struct that + replaces their file. +3. **Modern data structures.** Basic containers are NOT ported. The THING + doubly-linked lists become slices (`[]*Object`, `[]*Monster`). `list.c` + disappears; where the C code's shape depends on list semantics + (attach-to-front, detach), small compat helpers keep the ported call sites + readable. calloc/free vanish under GC. +4. **OOP where the domain has objects, not everywhere.** The THING union + splits into `Object` and `Creature` (with `Player` and `Monster` variants). + Behavior that is intrinsically about one entity (naming an object, testing + a flag) is a method on that entity. Everything that touches wider game + state (most of the game) is a method on `*RogueGame`, mirroring the C + call graph one-to-one. +5. **Standard library first.** Exactly one third-party dependency: + `github.com/gdamore/tcell/v2` — the de facto standard Go terminal cell + library — replacing curses. Everything else (saves, scores, RNG, signals, + options) is stdlib. +6. **Dead weight is dropped, not ported.** `mdport.c`'s 900-line keyboard + decoder (tcell events replace it), `xcrypt.c`'s DES (wizard mode gates on + an env var instead), load-average checks, tty dsusp-char juggling, and the + XOR save encryption all disappear. Each drop is documented in §9. + +## 2. Repository and package layout + +The C sources remain in the repository root as the reference implementation. +Go code lives in subdirectories (Go will not build a package directory that +contains foreign `.c` files): + +``` +go.mod module sneak.berlin/go/rogue +cmd/rogue/main.go package main — flag parsing, constructs RogueGame, calls Run +game/ package game — the port, one .go file per .c file + game.go RogueGame struct, NewGame, Run (main.c) + rng.go seeded LCG (main.c: rnd/roll + RN macro) + types.go Coord, Stats, flags, constants (rogue.h) + object.go Object type (rogue.h THING _o arm) + creature.go Creature/Monster/Player (rogue.h THING _t arm) + level.go Place, Level, map access (rogue.h places[] + macros) + tables.go monsters[26], obj_info tables, e_levels, rainbow/stones/wood/metal (extern.c) + init.go per-game randomization (init.c) + command.go command dispatch (command.c) + io.go message/status line (io.c) + screen.go Screen: tcell wrapper + Window (curses + mdport display shims) + newlevel.go level orchestration (new_level.c) + rooms.go room/maze generation (rooms.c) + passages.go corridors (passages.c) + move.go player movement, traps (move.c) + chase.go monster AI (chase.c) + monsters.go monster creation (monsters.c) + fight.go combat (fight.c) + things.go item naming/creation (things.c) + pack.go inventory (pack.c) + potions.go, scrolls.go, rings.go, sticks.go, weapons.go, armor.go + misc.go look(), eat, direction input (misc.c) + daemon.go scheduler (daemon.c) + daemons.go the 11 callbacks (daemons.c) + options.go options screen + ROGUEOPTS (options.c) + wizard.go wizard commands, identify (wizard.c) + rip.go tombstone, victory, scores (rip.c) + save.go save/restore via gob (save.c + state.c) + score.go scoreboard (score.h + rip.c/save.c score I/O) +``` + +`mach_dep.c`, `mdport.c`, `xcrypt.c`, `state.c`, `list.c`, `vers.c` have no +counterpart files: their few live responsibilities are absorbed (signals → +`game.go`; score file locking → `score.go`; version string → `game.go`; +serialization → `save.go`). + +## 3. The `RogueGame` type + +`RogueGame` is deliberately a god object: it is the C global namespace given +a receiver. Fields are grouped into embedded sub-structs where a subsystem is +coherent, but everything is reachable from `g`. + +```go +// RogueGame is one complete game of Rogue: all state that was global in the +// C implementation, plus the terminal it plays on. Construct with NewGame, +// then call Run. +type RogueGame struct { + // --- identity / RNG --- + Rng *Rng // the LCG; seed == C `seed` + Dnum int // dungeon number (the seed as shown to the user) + Whoami string // player name + Fruit string // favorite fruit + Home string // home directory (save default) + FileName string // save file path + Wizard bool // debug mode + NoScore bool // wizard was used / scoring disabled + + // --- the world --- + Player Player + Level Level // map, rooms, passages, level objects, monsters + Depth int // C `level` + MaxDepth int // C `max_level` + Amulet bool + SeenStairs bool + + // --- turn/command engine --- + Playing bool + After bool // this action consumes a turn + Again bool // repeating last command + Count int // command repeat count + NoCommand int // turns asleep + NoMove int // turns held + Quiet int // consecutive restful turns (heal rate) + Running bool + RunCh byte + DoorStop, Firstmove, MoveOn bool + ToDeath, Kamikaze, HasHit bool + MaxHit int + Take byte // glyph of thing picked up this move + Delta Coord // last direction from GetDir + DirCh byte + LastComm, LastDir byte // command repeat state ('a' command) + LLastComm, LLastDir byte + LastPick, LLastPick *Object + countCh, direction, newCount byte // command.c statics (as bool for newCount) + + // --- daemons/fuses --- + Daemons DaemonList // d_list[20] + `between` counter + + // --- messages / UI --- + scr *Screen // tcell-backed terminal (stdscr + hw scratch window) + Msgs MsgLine // msgbuf/newpos/mpos/huh/save state + Options Options // terse, jump, seefloor, passgo, flush, tombstone, inv_type... + StatMsg bool + InvDescribe bool + Oldpos Coord // hero position before last look() + Oldrp *Room + + // --- item identity (per-game randomized appearance + discovery) --- + Items ItemLore // p_colors, s_names, r_stones, ws_made, ws_type, + // the 7 ObjInfo tables, `group` counter + + // --- scores --- + LastScore int + AllScore bool + ScorePath string +} +``` + +Player-centric C globals move onto `Player`, world-centric onto `Level` +(see §4/§5); the remainder sit directly on `RogueGame` with the original +semantics. The command.c/io.c/things.c file-statics become lowercase fields +of `RogueGame`, `MsgLine`, and the inventory pagination helper respectively. + +### Entrypoints + +```go +func NewGame(opts Config) *RogueGame // Config: seed, name, ROGUEOPTS string, score path, wizard +func (g *RogueGame) Run() error // main.c main()+playit(): init tables, first level, daemons, loop +func (g *RogueGame) command() // one turn (command.c) +func Restore(path string, opts Config) (*RogueGame, error) // save.c restore() +``` + +`cmd/rogue/main.go` is a thin shell: flags (`-s` scores, `-d` death demo, +save-file arg, `SEED`/`ROGUEOPTS` env), `NewGame(...)` or `Restore(...)`, +`Run()`, exit code. + +## 4. Core types + +### 4.1 Geometry and stats + +```go +type Coord struct{ X, Y int } // value type; C `ce(a,b)` == `a == b` + +type Stats struct { + Str int // str_t; 3..31 + Exp int + Lvl int + Arm int // armor class, lower is better + HP int // s_hpt + Dmg string // damage dice "1x4/1x2" — kept as string, parsed by rollEm + MaxHP int +} +``` + +### 4.2 The THING union → two types + +```go +// Creature is the _t arm of the C THING union: the player or a monster. +type Creature struct { + Pos Coord + Turn bool // slowed: is it our turn + Type byte // 'A'..'Z' monster letter; '@' for the player + Disguise byte // what a xeroc shows as + OldCh byte // map char underneath + Dest *Coord // chase target — aliases hero pos, another monster's pos, or room gold + Flags CreatureFlags + Stats Stats + Room *Room + Pack []*Object +} + +type Monster struct{ Creature } + +// Player embeds Creature and owns the player-only state that was global in C. +type Player struct { + Creature + CurArmor *Object // worn armor + CurWeapon *Object // wielded weapon + CurRing [2]*Object // LEFT, RIGHT + PackUsed [26]bool // inventory letters in use + Inpack int // pack item count + Purse int // gold + FoodLeft int + HungryState int // 0..3 + NoFood int // levels since food seen + MaxStats Stats // potential (restore-strength target) + VfHit int // venus flytrap grip counter +} +``` + +The `hero`/`pstats`/`pack`/`proom`/`max_hp` macros become +`g.Player.Pos` / `g.Player.Stats` / `g.Player.Pack` / `g.Player.Room` / +`g.Player.Stats.MaxHP`. + +`Dest *Coord` keeps the C aliasing on purpose: monster targeting works by +pointer identity (`m.Dest == &g.Player.Pos` means "hunting the hero"), which +both the AI and the save format rely on. + +### 4.3 Object + +```go +type Object struct { + Type byte // POTION '!', SCROLL '?', WEAPON ')', ... + Pos Coord + Text string // scroll gibberish + Launch int // launcher weapon index, -1 none + PackCh byte // inventory letter + Damage string // wield dice + HurlDmg string // thrown dice + Count int // stack size + Which int // subtype (P_HEALING, MACE, R_PROTECT, ...) + HPlus, DPlus int + Arm int // OVERLOADED, as in C: armor class | charges | gold value + Flags ObjFlags + Group int // stack group id + Label string // player-applied name +} + +func (o *Object) Charges() int { return o.Arm } // sticks +func (o *Object) SetCharges(n int) { o.Arm = n } +func (o *Object) GoldVal() int { return o.Arm } // gold piles +``` + +The overload stays (one field, named accessors) so ported arithmetic like +"rust: `o.Arm++`" keeps its original shape. + +### 4.4 Flags + +```go +type CreatureFlags uint32 +type ObjFlags uint32 +type RoomFlags uint16 +type PlaceFlags uint8 + +const ( + CanHuh CreatureFlags = 1 << iota // creature can confuse + CanSee; IsBlind; IsCanc; IsFound; IsGreed; IsHaste; IsTarget + IsHeld; IsHuh; IsInvis; IsMean; IsRegen; IsRun; IsFly; IsSlow + // hero aliases sharing C bit values are separate named constants: + IsLevit = IsCanc; IsHalu = IsMean; SeeMonst = IsFly +) + +func (f CreatureFlags) Has(b CreatureFlags) bool +func (f *CreatureFlags) Set(b CreatureFlags) +func (f *CreatureFlags) Clear(b CreatureFlags) +``` + +C's `on(player, ISHALU)` ports to `g.Player.Flags.Has(IsHalu)`. The C bit +collisions (ISCANC/ISLEVIT, ISMEAN/ISHALU, ISFLY/SEEMONST) are preserved as +equal values — they are disjoint by owner (monster vs hero), and the save +format keeps working. + +### 4.5 Level and Place + +```go +type Place struct { + Ch byte // base map character + Flags PlaceFlags // FPass|FSeen|FDropped|FReal|FPnum|FTmask + Monst *Monster +} + +// Level is the current dungeon level. It is reset in place by NewLevel, +// matching the C reuse of the global arrays. +type Level struct { + Places [MaxLines * MaxCols]Place // same column-major INDEX(y,x) layout + Rooms [MaxRooms]Room + Passages [MaxPass]Room + Objects []*Object // lvl_obj + Monsters []*Monster // mlist + Stairs Coord + NTraps int +} + +func (l *Level) At(y, x int) *Place // INDEX +func (l *Level) Char(y, x int) byte // chat +func (l *Level) SetChar(y, x int, ch byte) +func (l *Level) FlagsAt(y, x int) *PlaceFlags // flat +func (l *Level) MonsterAt(y, x int) *Monster // moat +func (l *Level) VisibleChar(y, x int) byte // winat: disguise if monster, else Ch +``` + +### 4.6 Room + +```go +type Room struct { + Pos Coord // upper-left + Max Coord // size + Gold Coord + GoldVal int + Flags RoomFlags // IsDark | IsGone | IsMaze + Exits []Coord // r_exit[12] + r_nexits → slice +} +``` + +### 4.7 Static tables + +`tables.go` holds the immutable data as package-level **constants and +`var` tables that are never written after init** (the only permitted +package-level data — they are ROM, not state): `monsterTable [26]MonsterKind` +(name, carry %, flags, stats), `initDam` weapon table, base `objInfo` tables +(copied into `RogueGame.Items` at NewGame so per-game mutation — probability +resumming, `oi_know`, `oi_guess` — stays instance-local), `eLevels`, +`rainbow`, `stones`, `woods`, `metals`, `sylls`, trap names, help text, +`lvlMons`/`wandMons` spawn strings, `hNames`/`mNames` combat messages. + +```go +type ObjInfo struct { + Name string + Prob int // cumulative after initProbs + Worth int + Guess string // player's "call it" label + Know bool +} + +type ItemLore struct { + PotColors [MaxPotions]string // p_colors + ScrNames [MaxScrolls]string // s_names + RingStones [MaxRings]string // r_stones + WandMade [MaxSticks]string // ws_made + WandType [MaxSticks]string // ws_type ("wand"/"staff") + Things [NumThings]ObjInfo + Potions [MaxPotions]ObjInfo + Scrolls [MaxScrolls]ObjInfo + Rings [MaxRings]ObjInfo + Sticks [MaxSticks]ObjInfo + Weapons [MaxWeapons + 1]ObjInfo + Armors [MaxArmors]ObjInfo + Group int // stack group counter +} +``` + +## 5. Subsystems + +### 5.1 RNG — exact LCG compatibility + +```go +type Rng struct{ Seed int32 } + +func (r *Rng) next() int { // the RN macro + r.Seed = r.Seed*11109 + 13849 // 32-bit wraparound, as C int + return int(r.Seed>>16) & 0xffff +} +func (g *RogueGame) Rnd(rng int) int // rnd(): 0 if range==0 else abs(RN)%range +func (g *RogueGame) Roll(n, sides int) int +func (g *RogueGame) Spread(n int) int // misc.c: n ± 10% +``` + +Same seed → same dungeon, byte for byte. This is the port's master +correctness oracle (§10). + +### 5.2 Daemons and fuses — function pointers → identifiers + +C identifies daemons by function pointer; the save format already maps them +to small integers. The port makes that mapping primary: + +```go +type DaemonID int +const ( + DRollwand DaemonID = iota + 1 // matches state.c save ids + DDoctor; DStomach; DRunners; DSwander + DNohaste; DUnconfuse; DUnsee; DSight + DVisuals; DComeDown; DLand // extra fuses beyond the C save map +) + +type delayedAction struct { + Type int // EMPTY / BEFORE / AFTER + Func DaemonID + Arg int + Time int // DAEMON sentinel or fuse countdown +} + +type DaemonList struct { + List [MaxDaemons]delayedAction + Between int // daemons.c rollwand static +} + +func (g *RogueGame) StartDaemon(f DaemonID, arg, typ int) +func (g *RogueGame) Fuse(f DaemonID, arg, time, typ int) +func (g *RogueGame) Lengthen(f DaemonID, xtime int) +func (g *RogueGame) Extinguish(f DaemonID) +func (g *RogueGame) KillDaemon(f DaemonID) +func (g *RogueGame) DoDaemons(flag int) // dispatches via one switch on DaemonID +func (g *RogueGame) DoFuses(flag int) +``` + +The 11 callbacks in daemons.go become methods (`g.doctor()`, `g.stomach()`, +...) invoked from the dispatch switch. This is more robust than function +values because it is comparable and serializable — the two properties the C +design needed pointers for. + +### 5.3 Screen — curses → tcell + +```go +// Screen wraps tcell and exposes the curses vocabulary the ported code speaks. +type Screen struct { + T tcell.Screen + Std *Window // stdscr equivalent (the game map is drawn here) + Hw *Window // the scratch window for inventory/help overlays +} + +// Window is an in-memory cell buffer with a cursor, standout attribute, and +// the handful of curses ops the game uses. +type Window struct{ ... } +func (w *Window) Move(y, x int); func (w *Window) AddCh(ch byte) +func (w *Window) MvAddCh(y, x int, ch byte); func (w *Window) MvAddStr(y, x int, s string) +func (w *Window) Printw(format string, a ...any); func (w *Window) Inch(y, x int) byte +func (w *Window) Clear(); func (w *Window) Clrtoeol(); func (w *Window) Standout(on bool) +func (s *Screen) Refresh(w *Window) // blit buffer to tcell +func (s *Screen) ReadChar() byte // event loop → C char codes (arrows→hjkl, ^C→quit) +``` + +Ported drawing code keeps its structure: `mvaddch(y, x, ch)` → +`g.scr.Std.MvAddCh(y, x, ch)`. Curses `mvinch` reads come from the Window +buffer, preserving the "screen is a data structure" idiom without touching +the real terminal. `md_readchar`'s escape decoding is deleted; tcell's +`EventKey` provides decoded keys and we translate to the byte codes +`command()` already handles (KeyUp → 'k', etc.). SIGTSTP/resume and resize +are handled by tcell; SIGHUP/SIGTERM → `autoSave` via `os/signal`. + +### 5.4 Messaging + +```go +type MsgLine struct { + buf strings.Builder // msgbuf/newpos + Mpos int // cursor col on the message line + Huh string // last message (Ctrl-R repeat) + SaveMsg bool; LowerMsg bool; MsgEsc bool +} +func (g *RogueGame) Msg(format string, a ...any) bool // msg(); returns ESC-pressed +func (g *RogueGame) AddMsg(format string, a ...any) +func (g *RogueGame) EndMsg() bool +``` + +C's `%s`+`vowelstr` message assembly ports directly on `fmt`. The io.c +status-line shadow statics become fields of an unexported `statusCache` +struct on `RogueGame`. + +### 5.5 Options + +```go +type Options struct { + Terse, FightFlush, Jump, SeeFloor, PassGo, Tombstone bool + InvType int // INV_OVER / INV_SLOW / INV_CLEAR +} +``` + +`ROGUEOPTS` parsing (`parse_opts`) and the interactive option screen port +as-is; the C per-option get/put function pointers become a slice of option +descriptors holding closures. + +### 5.6 Save/restore — gob replaces state.c + +The 2,134-line hand-rolled serializer exists because C cannot reflect. Go +can: the save file is `encoding/gob` of a `SaveState` snapshot struct +(version-tagged, gzip-compressed). C-format saves are **not** readable — the +formats were never cross-version compatible anyway (the file embeds the +exact version string). + +What still needs explicit code is exactly what state.c's `rs_fix_thing` did: +pointers that alias other structures are encoded as indices — + +```go +type SaveState struct { + Version string + Game RogueGame // gob-friendly: exported fields + // pointer fixups: + CurArmorIdx, CurWeaponIdx int // index into Player.Pack, -1 = nil + CurRingIdx [2]int + MonstDest []DestRef // per monster: {Kind: hero|monster|object|gold, Index int} + PlaceMonst []int // per map cell: index into Level.Monsters +} +``` + +Save-file hygiene keeps the C behavior: the file is deleted on restore, and +`Run` exits after a successful save. The XOR encryption and symlink checks +are dropped (they were 1980s shared-machine anti-cheat). + +### 5.7 Scoreboard + +`SCORE` → `ScoreEnt` struct; the score file is a gob (or JSON) list at +`~/.rogue_scores` (overridable). Locking via `os.OpenFile(O_CREATE|O_EXCL)` +lock file with stale-age takeover, porting `lock_sc`/`unlock_sc` semantics. +Tombstone/victory screens port verbatim from rip.c. + +## 6. C construct → Go construct map + +| C construct | Go translation | +|---|---| +| global variable | `RogueGame` field (or Player/Level/subsystem field) | +| file-scope static | unexported field on `RogueGame` or subsystem struct | +| `THING` union | `Creature` / `Object` structs | +| linked lists (`l_next/l_prev`, attach/detach) | slices + `attach(&s, x)`-style helpers (prepend, remove by identity) | +| `coord*` aliasing (t_dest) | `*Coord` pointers into live structs (kept) | +| function pointers (daemons, options, nameit) | enum IDs + dispatch switch (daemons, save-relevant); closures (options, nameit) | +| `when`/`otherwise`/`until` macros | `case`/`default`/`for !cond` | +| `goto` (retry loops in pack.c, move.c, passages.c) | labeled loops / restructured `for` | +| char-indexed tables (`monsters[type-'A']`) | same, `monsterTable[t.Type-'A']` | +| damage strings `"3x4/1x2"` | kept as strings; parsed by `rollEm` (`strings`/`strconv`) | +| curses stdscr/hw windows | `Window` cell buffers over tcell | +| `mvinch` screen reads | `Window.Inch` from the buffer | +| signal handlers (SIGHUP autosave, SIGTSTP, SIGINT) | `os/signal` goroutine → channel checked in ReadChar; tcell handles TSTP/resize | +| `setjmp`-free exits (`my_exit`, `exit()` everywhere) | error returns from `Run` + a small `gameOver` sentinel; no `os.Exit` inside game logic | +| `vsprintf` message building | `fmt.Sprintf` | +| XOR-encrypted binary saves (state.c) | gob snapshot (§5.6) | +| DES wizard password | `ROGUE_WIZARD=1` env check | +| `md_*` platform shims | stdlib (`os`, `os/user`, `time`) or deleted | + +**Exit discipline** deserves a note: the C code calls `exit()` from deep +inside call chains (death, victory, save). The port threads a +`g.gameOver(reason)` that sets `Playing=false` and unwinds via normal +returns to `Run` — the one structural change made everywhere, since +`os.Exit` would skip tcell teardown and defers. + +## 7. Naming conventions + +- C function `do_move` → method `(g *RogueGame) DoMove(dy, dx int)`; the C + name is preserved in CamelCase so cross-referencing Part 1 is mechanical. + Statics/internal helpers are lowerCamel (`g.doadd` → `g.addMsgV`). +- Entity-intrinsic functions become entity methods where they read no game + state: `inv_name(obj, drop)` needs ItemLore → `(g *RogueGame) InvName(o + *Object, drop bool) string`; but `is_magic(obj)` → `(o *Object) IsMagic() + bool`. +- Boolean C `bool`/`int` returns stay `bool`; `char` returns become `byte`. +- `coord*` out-params become `Coord` returns (or `(Coord, bool)` for + find-style functions like `fallpos`/`find_floor`). + +## 8. Porting order (each step compiles, is committed, and is testable) + +1. **Foundation:** `go.mod`, types.go, object.go, creature.go, level.go, + rng.go, tables.go, game.go skeleton. Unit tests: RNG sequence vs C + formula, table sanity (probabilities sum to 100). +2. **Dungeon generation:** rooms.go, passages.go, newlevel.go + enough of + misc.go/monsters.go/things.go to place things. Test: fixed-seed level + render to text, eyeballed against C build. +3. **Items:** things.go, pack.go, init.go + ItemLore. +4. **Creatures & combat:** monsters.go, chase.go, fight.go, daemon(s).go. +5. **UI:** screen.go, io.go, options.go, misc.go look(), command.go, move.go. + First playable build. +6. **Effects:** potions.go, scrolls.go, rings.go, sticks.go, weapons.go, + armor.go, wizard.go. +7. **Endgame:** rip.go, score.go, save.go, cmd/rogue. Full game loop, save, + scores. + +## 9. Dropped C functionality (deliberate) + +| Dropped | Why | Replacement | +|---|---|---| +| `md_readchar` escape decoding | tcell decodes keys | key-event translation table | +| XOR save/score encryption | obscurity, not security | plain gob (file perms 0600) | +| save-file symlink/hardlink checks | single-user era anti-cheat | none | +| DES crypt wizard password | ditto | `ROGUE_WIZARD` env var | +| load-average / user-count gating (`too_much`, `ucount`, CHECKTIME) | 1980s timesharing courtesy | none | +| tty dsusp/ltc character juggling | tcell owns the tty | none | +| shell escape (`!`) setuid dance | no privileges to drop | plain `os/exec` shell | +| curses window save in save file | screen is derivable | redraw on restore | + +## 10. Testing strategy + +- **RNG oracle:** golden test locking the LCG sequence for known seeds. +- **Generation goldens:** for a set of seeds, render generated levels + (map chars + room/passage metadata) to text fixtures; any diff = a porting + bug in rooms/passages/newlevel. Fixtures generated once from instrumented + C, or accepted from the first correct Go run and guarded thereafter. +- **Combat math tests:** `swing`/`rollEm` against hand-computed cases, + str_plus/add_dam table spot checks. +- **Determinism test:** scripted input sequence against a fixed seed must + produce identical final state across runs (guards hidden nondeterminism — + map iteration, time dependence). +- **Save round-trip:** NewGame → play N scripted turns → save → restore → + compare full state. +- `go vet` + `gofmt` clean at every commit.