createObj stored the raw 0-f nibble as Object.Which with no bounds check, so wizard mode -> C -> / -> f made a wand numbered 15 against a 14-entry table and panicked in fixStick; input below 'a' or '0' went negative and panicked the same way. C's create_obj() was equally unchecked, but its consumers were either switches (defined for any value) or static-array reads past the end (undefined, and survivable in practice). Since one game is now one process, the Go panic kills the game outright and leaves the terminal in raw mode. Reject at the two boundaries a bad Which can enter through. createObj now refuses an out-of-range choice with a message drawn from C's own type_name() vocabulary and adds nothing to the pack, a deliberate divergence recorded in a comment because C had no defined behavior here to be faithful to. Restore refuses a snapshot describing such an object (ErrSaveCorrupt) rather than loading a game that would explode later. Behind those, whichLimit/hasValidWhich back defensive guards at every dispatch the issue names: the quaffHandler/readHandler/zapHandler accessors return no handler instead of indexing (for wands that is exactly what non-MASTER C did, matching no case and still running o_charges--), the callIt lore lookups, identifyType, armorClass for the a_class[] reads, initWeapon against the missing init_dam[] row for WeaponFlame, fixStick's ws_type[] read, and inventoryName and objectWorth, hoisted so one check each covers the whole family of per-kind name and appraisal tables. No in-range input changes behavior and no guard consumes a random number: the rejection precedes every rnd() call. TestSeedCompatItemTables stays green untouched. New game/wizard_test.go covers the exact reproducer, a rejection sweep over every indexed kind including both negative-input forms, an acceptance sweep proving valid choices still build the right item, one no-panic test per guarded family, the fixStick crash site, the corrupt-save rejection, and a check that whichLimit still agrees with the table sizes. Each guard was confirmed load-bearing by reverting it and watching the test panic. TODO.md records the step; Next Step is deliberately left alone, since this arrived out of band via an issue.
762 lines
17 KiB
Go
762 lines
17 KiB
Go
//nolint:mnd // C-faithful literals; names hurt C-greppability (approved 2026-07-07)
|
|
package game
|
|
|
|
import (
|
|
"encoding/gob"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
)
|
|
|
|
// save.c + state.c — game persistence. The hand-written, XOR-encrypted C
|
|
// serializer becomes a gob snapshot; what remains hand-written is exactly
|
|
// what state.c's rs_fix_thing did: pointers that alias other structures
|
|
// (equipment, chase targets, room membership) are encoded as indices.
|
|
|
|
// saveFormatVersion identifies the snapshot layout; it changes whenever a
|
|
// field rename or retype would make gob silently mis-decode an older
|
|
// save ("go2": Object.Type became Kind ObjectKind; "go3": Object.Arm split
|
|
// into ArmorClass/Charges/GoldValue/Bonus, damage strings became
|
|
// DiceSpec).
|
|
const saveFormatVersion = Release + "-go3"
|
|
|
|
// destRef encodes a monster's chase target (state.c rs_write_thing's
|
|
// (kind, index) pairs).
|
|
type destRef struct {
|
|
Kind int // 0 none, 1 hero, 2 monster index, 3 level-object index, 4 room gold
|
|
Idx int
|
|
}
|
|
|
|
// savedCreature is the pointer-free image of a Creature.
|
|
type savedCreature struct {
|
|
Pos Coord
|
|
Turn bool
|
|
Type byte
|
|
Disguise byte
|
|
OldCh byte
|
|
Flags CreatureFlags
|
|
Stats Stats
|
|
RoomIdx int // 0..MaxRooms-1; 100+i for passages; -1 none
|
|
Pack []Object
|
|
}
|
|
|
|
// savedPlayer adds the player-only state. The creature half is a named
|
|
// field, not an embedded one: gob drops embedded fields of unexported
|
|
// types silently.
|
|
type savedPlayer struct {
|
|
Body savedCreature
|
|
CurArmor int // pack indices; -1 = none
|
|
CurWeapon int
|
|
CurRing [2]int
|
|
PackUsed [26]bool
|
|
Inpack int
|
|
Purse int
|
|
FoodLeft int
|
|
HungryState int
|
|
NoFood int
|
|
MaxStats Stats
|
|
VfHit int
|
|
}
|
|
|
|
// savedPlace is one map cell without its monster pointer.
|
|
type savedPlace struct {
|
|
Ch byte
|
|
Flags PlaceFlags
|
|
}
|
|
|
|
// SaveState is the complete serialized game (the field list mirrors
|
|
// state.c rs_save_file).
|
|
type SaveState struct {
|
|
Version string
|
|
|
|
Seed int32
|
|
Dnum int
|
|
Whoami string
|
|
Fruit string
|
|
Home string
|
|
Wizard bool
|
|
|
|
Player savedPlayer
|
|
|
|
Depth int
|
|
MaxDepth int
|
|
HasAmulet bool
|
|
SeenStairs bool
|
|
|
|
Places []savedPlace
|
|
Rooms [MaxRooms]Room
|
|
Passages [MaxPass]Room
|
|
Objects []Object
|
|
Monsters []savedCreature
|
|
Dests []destRef // parallel to Monsters
|
|
Stairs Coord
|
|
TrapCount int
|
|
|
|
After bool
|
|
Again bool
|
|
NoScoreF bool
|
|
Count int
|
|
NoCommand int
|
|
NoMove int
|
|
Quiet int
|
|
Running bool
|
|
RunCh byte
|
|
DoorStop bool
|
|
Firstmove bool
|
|
MoveOn bool
|
|
ToDeath bool
|
|
Kamikaze bool
|
|
MaxHit int
|
|
Take byte
|
|
Delta Coord
|
|
DirCh byte
|
|
LastComm byte
|
|
LastDir byte
|
|
LastPick int // pack index, -1
|
|
Oldpos Coord
|
|
OldrpIdx int
|
|
|
|
Daemons DaemonList
|
|
Options Options
|
|
Items ItemLore
|
|
Bestiary [26]MonsterKind
|
|
Huh string
|
|
|
|
LastScore int
|
|
AllScore bool
|
|
|
|
Screen []byte // the visible map (Std window contents)
|
|
}
|
|
|
|
// roomIdx encodes a room pointer as an index (state.c find_room_coord).
|
|
func (g *RogueGame) roomIdx(rp *Room) int {
|
|
if rp == nil {
|
|
return -1
|
|
}
|
|
|
|
for i := range g.Level.Rooms {
|
|
if rp == &g.Level.Rooms[i] {
|
|
return i
|
|
}
|
|
}
|
|
|
|
for i := range g.Level.Passages {
|
|
if rp == &g.Level.Passages[i] {
|
|
return 100 + i
|
|
}
|
|
}
|
|
|
|
return -1
|
|
}
|
|
|
|
// roomAt decodes a room index.
|
|
func (g *RogueGame) roomAt(i int) *Room {
|
|
switch {
|
|
case i >= 100:
|
|
return &g.Level.Passages[i-100]
|
|
case i >= 0:
|
|
return &g.Level.Rooms[i]
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// packIdx finds an object in the player's pack (state.c find_list_ptr).
|
|
func (g *RogueGame) packIdx(obj *Object) int {
|
|
if obj == nil {
|
|
return -1
|
|
}
|
|
|
|
for i, o := range g.Player.Pack {
|
|
if o == obj {
|
|
return i
|
|
}
|
|
}
|
|
|
|
return -1
|
|
}
|
|
|
|
// snapshot captures the complete game state.
|
|
func (g *RogueGame) snapshot() *SaveState {
|
|
st := g.snapshotHeader()
|
|
|
|
// the map, sans monster pointers (rebuilt on load)
|
|
st.Places = make([]savedPlace, len(g.Level.Places))
|
|
for i := range g.Level.Places {
|
|
st.Places[i] = savedPlace{
|
|
Ch: g.Level.Places[i].Ch,
|
|
Flags: g.Level.Places[i].Flags,
|
|
}
|
|
}
|
|
|
|
// level objects by value; remember their pointers for dest encoding
|
|
objAt := make(map[*Object]int, len(g.Level.Objects))
|
|
for i, o := range g.Level.Objects {
|
|
st.Objects = append(st.Objects, *o)
|
|
objAt[o] = i
|
|
}
|
|
|
|
st.Player = g.snapshotPlayer()
|
|
|
|
// monsters, with chase targets as (kind, index) references
|
|
for _, m := range g.Level.Monsters {
|
|
sc := savedCreature{
|
|
Pos: m.Pos, Turn: m.Turn, Type: m.Type, Disguise: m.Disguise,
|
|
OldCh: m.OldCh, Flags: m.Flags, Stats: m.Stats,
|
|
RoomIdx: g.roomIdx(m.Room),
|
|
}
|
|
for _, o := range m.Pack {
|
|
sc.Pack = append(sc.Pack, *o)
|
|
}
|
|
|
|
st.Monsters = append(st.Monsters, sc)
|
|
st.Dests = append(st.Dests, g.destRefFor(m, objAt))
|
|
}
|
|
|
|
return st
|
|
}
|
|
|
|
// snapshotHeader captures the scalar game state (the field list of
|
|
// state.c rs_save_file).
|
|
func (g *RogueGame) snapshotHeader() *SaveState {
|
|
return &SaveState{
|
|
Version: saveFormatVersion,
|
|
Seed: g.Rng.Seed,
|
|
Dnum: g.Dnum,
|
|
Whoami: g.Whoami,
|
|
Fruit: g.Fruit,
|
|
Home: g.Home,
|
|
Wizard: g.Wizard,
|
|
Depth: g.Depth,
|
|
MaxDepth: g.MaxDepth,
|
|
HasAmulet: g.HasAmulet,
|
|
SeenStairs: g.SeenStairs,
|
|
Rooms: g.Level.Rooms,
|
|
Passages: g.Level.Passages,
|
|
Stairs: g.Level.Stairs,
|
|
TrapCount: g.Level.TrapCount,
|
|
After: g.After,
|
|
Again: g.Again,
|
|
NoScoreF: g.NoScore,
|
|
Count: g.Count,
|
|
NoCommand: g.NoCommand,
|
|
NoMove: g.NoMove,
|
|
Quiet: g.Quiet,
|
|
Running: g.Running,
|
|
RunCh: g.RunCh,
|
|
DoorStop: g.DoorStop,
|
|
Firstmove: g.Firstmove,
|
|
MoveOn: g.MoveOn,
|
|
ToDeath: g.ToDeath,
|
|
Kamikaze: g.Kamikaze,
|
|
MaxHit: g.MaxHit,
|
|
Take: g.Take,
|
|
Delta: g.Delta,
|
|
DirCh: g.DirCh,
|
|
LastComm: g.LastComm,
|
|
LastDir: g.LastDir,
|
|
LastPick: g.packIdx(g.LastPick),
|
|
Oldpos: g.Oldpos,
|
|
OldrpIdx: g.roomIdx(g.Oldrp),
|
|
Daemons: g.Daemons,
|
|
Options: g.Options,
|
|
Items: g.Items,
|
|
Bestiary: g.Monsters,
|
|
Huh: g.Msgs.Huh,
|
|
LastScore: g.LastScore,
|
|
AllScore: g.AllScore,
|
|
Screen: g.scr.Std.Contents(),
|
|
}
|
|
}
|
|
|
|
// snapshotPlayer captures the player, equipment as pack indices (the
|
|
// player half of snapshot).
|
|
func (g *RogueGame) snapshotPlayer() savedPlayer {
|
|
p := &g.Player
|
|
|
|
sp := savedPlayer{
|
|
Body: savedCreature{
|
|
Pos: p.Pos, Turn: p.Turn, Type: p.Type, Disguise: p.Disguise,
|
|
OldCh: p.OldCh, Flags: p.Flags, Stats: p.Stats,
|
|
RoomIdx: g.roomIdx(p.Room),
|
|
},
|
|
CurArmor: g.packIdx(p.CurArmor),
|
|
CurWeapon: g.packIdx(p.CurWeapon),
|
|
CurRing: [2]int{
|
|
g.packIdx(p.CurRing[Left]), g.packIdx(p.CurRing[Right]),
|
|
},
|
|
PackUsed: p.PackUsed, Inpack: p.Inpack, Purse: p.Purse,
|
|
FoodLeft: p.FoodLeft, HungryState: p.HungryState, NoFood: p.NoFood,
|
|
MaxStats: p.MaxStats, VfHit: p.VfHit,
|
|
}
|
|
for _, o := range p.Pack {
|
|
sp.Body.Pack = append(sp.Body.Pack, *o)
|
|
}
|
|
|
|
return sp
|
|
}
|
|
|
|
// destRefFor encodes a monster's chase target as a (kind, index)
|
|
// reference: the hero, another monster, a level object, or room gold
|
|
// (state.c rs_write_thing).
|
|
func (g *RogueGame) destRefFor(m *Monster, objAt map[*Object]int) destRef {
|
|
switch {
|
|
case m.Dest == nil:
|
|
return destRef{}
|
|
case m.Dest == &g.Player.Pos:
|
|
return destRef{Kind: 1}
|
|
}
|
|
|
|
for mi, om := range g.Level.Monsters {
|
|
if m.Dest == &om.Pos {
|
|
return destRef{Kind: 2, Idx: mi}
|
|
}
|
|
}
|
|
|
|
for _, oo := range g.Level.Objects {
|
|
if m.Dest == &oo.Pos {
|
|
return destRef{Kind: 3, Idx: objAt[oo]}
|
|
}
|
|
}
|
|
|
|
for ri := range g.Level.Rooms {
|
|
if m.Dest == &g.Level.Rooms[ri].Gold {
|
|
return destRef{Kind: 4, Idx: ri}
|
|
}
|
|
}
|
|
|
|
return destRef{}
|
|
}
|
|
|
|
// applySnapshot rebuilds live game state from a snapshot.
|
|
func (g *RogueGame) applySnapshot(st *SaveState) {
|
|
g.applyHeader(st)
|
|
|
|
for i := range g.Level.Places {
|
|
g.Level.Places[i] = Place{
|
|
Ch: st.Places[i].Ch,
|
|
Flags: st.Places[i].Flags,
|
|
}
|
|
}
|
|
|
|
// level objects
|
|
g.Level.Objects = nil
|
|
for i := range st.Objects {
|
|
o := st.Objects[i]
|
|
g.Level.Objects = append(g.Level.Objects, &o)
|
|
}
|
|
|
|
g.applyPlayer(st)
|
|
g.applyMonsters(st)
|
|
g.applyDests(st)
|
|
|
|
g.scr.Std.SetContents(st.Screen)
|
|
}
|
|
|
|
// applyHeader restores the scalar game state (the field list of
|
|
// applySnapshot).
|
|
func (g *RogueGame) applyHeader(st *SaveState) {
|
|
g.Rng.Seed = st.Seed
|
|
g.Dnum = st.Dnum
|
|
g.Whoami = st.Whoami
|
|
g.Fruit = st.Fruit
|
|
g.Home = st.Home
|
|
g.Wizard = st.Wizard
|
|
g.Depth = st.Depth
|
|
g.MaxDepth = st.MaxDepth
|
|
g.HasAmulet = st.HasAmulet
|
|
g.SeenStairs = st.SeenStairs
|
|
g.Level.Rooms = st.Rooms
|
|
g.Level.Passages = st.Passages
|
|
g.Level.Stairs = st.Stairs
|
|
g.Level.TrapCount = st.TrapCount
|
|
g.applyTurnState(st)
|
|
}
|
|
|
|
// applyTurnState restores the in-turn command state (the second half of
|
|
// applyHeader).
|
|
func (g *RogueGame) applyTurnState(st *SaveState) {
|
|
g.After = st.After
|
|
g.Again = st.Again
|
|
g.NoScore = st.NoScoreF
|
|
g.Count = st.Count
|
|
g.NoCommand = st.NoCommand
|
|
g.NoMove = st.NoMove
|
|
g.Quiet = st.Quiet
|
|
g.Running = st.Running
|
|
g.RunCh = st.RunCh
|
|
g.DoorStop = st.DoorStop
|
|
g.Firstmove = st.Firstmove
|
|
g.MoveOn = st.MoveOn
|
|
g.ToDeath = st.ToDeath
|
|
g.Kamikaze = st.Kamikaze
|
|
g.MaxHit = st.MaxHit
|
|
g.Take = st.Take
|
|
g.Delta = st.Delta
|
|
g.DirCh = st.DirCh
|
|
g.LastComm = st.LastComm
|
|
g.LastDir = st.LastDir
|
|
g.Oldpos = st.Oldpos
|
|
g.Oldrp = g.roomAt(st.OldrpIdx)
|
|
g.Daemons = st.Daemons
|
|
g.Options = st.Options
|
|
g.Items = st.Items
|
|
g.Monsters = st.Bestiary
|
|
g.Msgs.Huh = st.Huh
|
|
g.LastScore = st.LastScore
|
|
g.AllScore = st.AllScore
|
|
g.Playing = true
|
|
}
|
|
|
|
// applyPlayer restores the player, resolving equipment pack indices
|
|
// (the player half of applySnapshot).
|
|
func (g *RogueGame) applyPlayer(st *SaveState) {
|
|
p := &g.Player
|
|
sp := &st.Player
|
|
p.Pos = sp.Body.Pos
|
|
p.Turn = sp.Body.Turn
|
|
p.Type = sp.Body.Type
|
|
p.Disguise = sp.Body.Disguise
|
|
p.OldCh = sp.Body.OldCh
|
|
p.Flags = sp.Body.Flags
|
|
p.Stats = sp.Body.Stats
|
|
p.Room = g.roomAt(sp.Body.RoomIdx)
|
|
|
|
p.Pack = nil
|
|
for i := range sp.Body.Pack {
|
|
o := sp.Body.Pack[i]
|
|
p.Pack = append(p.Pack, &o)
|
|
}
|
|
|
|
pick := func(i int) *Object {
|
|
if i < 0 || i >= len(p.Pack) {
|
|
return nil
|
|
}
|
|
|
|
return p.Pack[i]
|
|
}
|
|
p.CurArmor = pick(sp.CurArmor)
|
|
p.CurWeapon = pick(sp.CurWeapon)
|
|
p.CurRing[Left] = pick(sp.CurRing[0])
|
|
p.CurRing[Right] = pick(sp.CurRing[1])
|
|
g.LastPick = pick(st.LastPick)
|
|
p.PackUsed = sp.PackUsed
|
|
p.Inpack = sp.Inpack
|
|
p.Purse = sp.Purse
|
|
p.FoodLeft = sp.FoodLeft
|
|
p.HungryState = sp.HungryState
|
|
p.NoFood = sp.NoFood
|
|
p.MaxStats = sp.MaxStats
|
|
p.VfHit = sp.VfHit
|
|
}
|
|
|
|
// applyMonsters rebuilds the monster list and its map index from a
|
|
// snapshot (the monster half of applySnapshot).
|
|
func (g *RogueGame) applyMonsters(st *SaveState) {
|
|
g.Level.Monsters = nil
|
|
for i := range st.Monsters {
|
|
sc := &st.Monsters[i]
|
|
|
|
m := &Monster{Creature: Creature{
|
|
Pos: sc.Pos, Turn: sc.Turn, Type: sc.Type, Disguise: sc.Disguise,
|
|
OldCh: sc.OldCh, Flags: sc.Flags, Stats: sc.Stats,
|
|
Room: g.roomAt(sc.RoomIdx),
|
|
}}
|
|
for j := range sc.Pack {
|
|
o := sc.Pack[j]
|
|
m.Pack = append(m.Pack, &o)
|
|
}
|
|
|
|
g.Level.Monsters = append(g.Level.Monsters, m)
|
|
g.Level.SetMonsterAt(m.Pos.Y, m.Pos.X, m)
|
|
}
|
|
}
|
|
|
|
// applyDests re-aims the monsters' chase targets from their (kind,
|
|
// index) references (the fixup half of applySnapshot).
|
|
func (g *RogueGame) applyDests(st *SaveState) {
|
|
for i, ref := range st.Dests {
|
|
m := g.Level.Monsters[i]
|
|
|
|
switch ref.Kind {
|
|
case 1:
|
|
m.Dest = &g.Player.Pos
|
|
case 2:
|
|
m.Dest = &g.Level.Monsters[ref.Idx].Pos
|
|
case 3:
|
|
m.Dest = &g.Level.Objects[ref.Idx].Pos
|
|
case 4:
|
|
m.Dest = &g.Level.Rooms[ref.Idx].Gold
|
|
}
|
|
}
|
|
}
|
|
|
|
// saveAnswer is a yes/no/escape prompt result in the save-game flow.
|
|
type saveAnswer int
|
|
|
|
// The saveGame prompt outcomes.
|
|
const (
|
|
saveYes saveAnswer = iota
|
|
saveNo
|
|
saveAbort
|
|
)
|
|
|
|
// saveGame implements the "save game" command (save.c save_game). The
|
|
// labeled prompt loop stands in for the C goto over/gotfile flow.
|
|
func (g *RogueGame) saveGame() {
|
|
g.Msgs.Mpos = 0
|
|
|
|
prompt:
|
|
for {
|
|
useDefault := false
|
|
|
|
if g.FileName != "" {
|
|
a := g.askDefaultSave()
|
|
if a == saveAbort {
|
|
return
|
|
}
|
|
|
|
useDefault = a == saveYes
|
|
}
|
|
|
|
for {
|
|
buf, ok := g.saveFileName(useDefault)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
useDefault = false
|
|
|
|
a := g.saveCheckOverwrite(buf)
|
|
if a == saveAbort {
|
|
return
|
|
}
|
|
|
|
if a == saveNo {
|
|
continue prompt // the C goto over: start again
|
|
}
|
|
|
|
g.FileName = buf
|
|
|
|
err := g.saveFile(g.FileName)
|
|
if err != nil {
|
|
g.msg("%s", err.Error())
|
|
|
|
continue
|
|
}
|
|
|
|
break prompt
|
|
}
|
|
}
|
|
|
|
g.myExit()
|
|
}
|
|
|
|
// askDefaultSave asks whether to save to the current file name (save.c
|
|
// save_game).
|
|
func (g *RogueGame) askDefaultSave() saveAnswer {
|
|
for {
|
|
g.msg("save file (%s)? ", g.FileName)
|
|
c := g.readchar()
|
|
|
|
g.Msgs.Mpos = 0
|
|
|
|
switch c {
|
|
case Escape:
|
|
g.msg("")
|
|
|
|
return saveAbort
|
|
case 'y', 'Y':
|
|
g.addstr("Yes\n")
|
|
g.refresh()
|
|
|
|
return saveYes
|
|
case 'n', 'N':
|
|
return saveNo
|
|
}
|
|
|
|
g.msg("please answer Y or N")
|
|
}
|
|
}
|
|
|
|
// saveFileName picks the save path: the default, or a prompted one; ok
|
|
// is false when the player quit the prompt (save.c save_game).
|
|
func (g *RogueGame) saveFileName(useDefault bool) (string, bool) {
|
|
if useDefault {
|
|
return g.FileName, true
|
|
}
|
|
|
|
g.Msgs.Mpos = 0
|
|
g.msg("file name: ")
|
|
|
|
buf := ""
|
|
if g.getStr(&buf, g.scr.Std) == Quit {
|
|
g.msg("")
|
|
|
|
return "", false
|
|
}
|
|
|
|
g.Msgs.Mpos = 0
|
|
|
|
return buf, true
|
|
}
|
|
|
|
// saveCheckOverwrite guards an existing file: saveNo restarts the whole
|
|
// prompt, saveAbort quits (save.c save_game).
|
|
func (g *RogueGame) saveCheckOverwrite(buf string) saveAnswer {
|
|
// test to see if the file exists
|
|
_, statErr := os.Stat(buf)
|
|
if statErr != nil {
|
|
return saveYes
|
|
}
|
|
|
|
answer := g.askOverwrite()
|
|
if answer != saveYes {
|
|
return answer
|
|
}
|
|
|
|
g.msg("file name: %s", buf)
|
|
_ = os.Remove(g.FileName) // best effort, as in C (md_unlink)
|
|
|
|
return saveYes
|
|
}
|
|
|
|
// askOverwrite asks whether to overwrite the existing file (save.c
|
|
// save_game).
|
|
func (g *RogueGame) askOverwrite() saveAnswer {
|
|
for {
|
|
g.msg("File exists. Do you wish to overwrite it?")
|
|
g.Msgs.Mpos = 0
|
|
|
|
switch g.readchar() {
|
|
case Escape:
|
|
g.msg("")
|
|
|
|
return saveAbort
|
|
case 'y', 'Y':
|
|
return saveYes
|
|
case 'n', 'N':
|
|
return saveNo
|
|
}
|
|
|
|
g.msg("Please answer Y or N")
|
|
}
|
|
}
|
|
|
|
// saveFile writes the saved game (save.c save_file). A failed write means
|
|
// a corrupt save, so the file is removed before reporting the error.
|
|
func (g *RogueGame) saveFile(path string) error {
|
|
f, err := os.OpenFile(path, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o400) //nolint:gosec,lll // G304: user-chosen save path
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
encErr := gob.NewEncoder(f).Encode(g.snapshot())
|
|
closeErr := f.Close()
|
|
|
|
if encErr != nil || closeErr != nil {
|
|
_ = os.Remove(path) // don't leave a corrupt save behind
|
|
|
|
if encErr != nil {
|
|
return encErr
|
|
}
|
|
|
|
return closeErr
|
|
}
|
|
|
|
return os.Chmod(path, 0o400)
|
|
}
|
|
|
|
// AutoSave silently saves to the current file name; used on SIGHUP/SIGTERM
|
|
// (save.c auto_save). Best effort by design: it runs on the way out of a
|
|
// dying process.
|
|
func (g *RogueGame) AutoSave() {
|
|
if g.FileName != "" {
|
|
_ = os.Remove(g.FileName)
|
|
_ = g.saveFile(g.FileName)
|
|
}
|
|
}
|
|
|
|
// ErrSaveOutOfDate reports a save file from an incompatible version.
|
|
var ErrSaveOutOfDate = errors.New("sorry, saved game is out of date")
|
|
|
|
// ErrSaveCorrupt reports a save file whose contents are structurally
|
|
// impossible for a game this code could have written.
|
|
var ErrSaveCorrupt = errors.New("sorry, saved game is corrupt")
|
|
|
|
// validateSnapshotObjects rejects a snapshot carrying an item whose Which
|
|
// would index past the end of its kind's tables. The file is written by
|
|
// this program, so such a value can only come from corruption or
|
|
// tampering; refusing it at the door is what keeps a malformed object
|
|
// from reaching the effect tables in doZap, quaff, and readScroll, where
|
|
// the wizard-create bug (issue #10) used to put one.
|
|
func validateSnapshotObjects(st *SaveState) error {
|
|
lists := make([][]Object, 0, 2+len(st.Monsters))
|
|
lists = append(lists, st.Objects, st.Player.Body.Pack)
|
|
|
|
for i := range st.Monsters {
|
|
lists = append(lists, st.Monsters[i].Pack)
|
|
}
|
|
|
|
for _, list := range lists {
|
|
for i := range list {
|
|
obj := &list[i]
|
|
if !obj.hasValidWhich() {
|
|
return fmt.Errorf("%w: %s has out-of-range which %d",
|
|
ErrSaveCorrupt, obj.Kind, obj.Which)
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Restore restores a saved game from a file (save.c restore). The file is
|
|
// deleted, as in C, to defeat restarting from the same save.
|
|
func Restore(path string, params Params) (*RogueGame, error) {
|
|
f, err := os.Open(path) //nolint:gosec // G304: the save path is user-chosen by design
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
defer func() { _ = f.Close() }() // read-only handle
|
|
|
|
var st SaveState
|
|
|
|
decErr := gob.NewDecoder(f).Decode(&st)
|
|
if decErr != nil {
|
|
return nil, fmt.Errorf("%s: corrupt or incompatible save file: %w",
|
|
path, decErr)
|
|
}
|
|
|
|
if st.Version != saveFormatVersion {
|
|
return nil, ErrSaveOutOfDate
|
|
}
|
|
|
|
valErr := validateSnapshotObjects(&st)
|
|
if valErr != nil {
|
|
return nil, valErr
|
|
}
|
|
|
|
g := &RogueGame{
|
|
data: newGameData(),
|
|
Rng: &Rng{},
|
|
Playing: true,
|
|
ScorePath: params.ScorePath,
|
|
FileName: path,
|
|
rogueOpts: params.RogueOpts,
|
|
restored: true,
|
|
}
|
|
g.scr = NewScreen(params.Term)
|
|
g.Msgs.attach(g.scr, g.look, g.readchar)
|
|
g.applySnapshot(&st)
|
|
|
|
// defeat multiple restarting from the same place
|
|
rmErr := os.Remove(path)
|
|
if rmErr != nil {
|
|
return nil, fmt.Errorf("cannot unlink file: %w", rmErr)
|
|
}
|
|
|
|
return g, nil
|
|
}
|