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 { p := &g.Player st := &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(), } // 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 } // the player st.Player = 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 { st.Player.Body.Pack = append(st.Player.Body.Pack, *o) } // 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) ref := destRef{} switch { case m.Dest == nil: case m.Dest == &p.Pos: ref = destRef{Kind: 1} default: for mi, om := range g.Level.Monsters { if m.Dest == &om.Pos { ref = destRef{Kind: 2, Idx: mi} } } if ref.Kind == 0 { for _, oo := range g.Level.Objects { if m.Dest == &oo.Pos { ref = destRef{Kind: 3, Idx: objAt[oo]} } } } if ref.Kind == 0 { for ri := range g.Level.Rooms { if m.Dest == &g.Level.Rooms[ri].Gold { ref = destRef{Kind: 4, Idx: ri} } } } } st.Dests = append(st.Dests, ref) } return st } // applySnapshot rebuilds live game state from a snapshot. func (g *RogueGame) applySnapshot(st *SaveState) { p := &g.Player 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.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 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) } // the 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 // monsters, their map index, and their chase targets 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) } for i, ref := range st.Dests { m := g.Level.Monsters[i] switch ref.Kind { case 1: m.Dest = &p.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 } } g.scr.Std.SetContents(st.Screen) } // saveGame implements the "save game" command (save.c save_game). The // labeled prompt loop and the useDefault flag stand in for the C // goto over/gotfile flow. func (g *RogueGame) saveGame() { g.Msgs.Mpos = 0 prompt: for { useDefault := false if g.FileName != "" { var c byte for { g.msg("save file (%s)? ", g.FileName) c = g.readchar() g.Msgs.Mpos = 0 if c == Escape { g.msg("") return } if c == 'n' || c == 'N' || c == 'y' || c == 'Y' { break } g.msg("please answer Y or N") } if c == 'y' || c == 'Y' { g.addstr("Yes\n") g.refresh() useDefault = true } } for { var buf string if useDefault { buf = g.FileName useDefault = false } else { g.Msgs.Mpos = 0 g.msg("file name: ") if g.getStr(&buf, g.scr.Std) == Quit { g.msg("") return } g.Msgs.Mpos = 0 } // test to see if the file exists _, statErr := os.Stat(buf) if statErr == nil { for { g.msg("File exists. Do you wish to overwrite it?") g.Msgs.Mpos = 0 c := g.readchar() if c == Escape { g.msg("") return } if c == 'y' || c == 'Y' { break } if c == 'n' || c == 'N' { continue prompt // the C goto over: start again } g.msg("Please answer Y or N") } g.msg("file name: %s", buf) _ = os.Remove(g.FileName) // best effort, as in C (md_unlink) } g.FileName = buf err := g.saveFile(g.FileName) if err != nil { g.msg("%s", err.Error()) continue } break prompt } } g.myExit() } // 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") // 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, cfg Config) (*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 } g := &RogueGame{ data: newGameData(), Rng: &Rng{}, Playing: true, ScorePath: cfg.ScorePath, FileName: path, rogueOpts: cfg.RogueOpts, restored: true, } g.scr = NewScreen(cfg.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 }