/** * Saving and loading a game. * * Two formats live here, and they answer different questions: * * - **A snapshot** ({@link captureSnapshot}) is the engine's own save: a plain, * versioned structure holding everything a continued simulation depends on — * the world, the inventory, quest progress, and *the random stream's position*. * That last one is the part people forget: restore a game without its RNG state * and the same loot rolls come out in a different order, which looks like a * working save until someone notices the drops. * - **`.d2s`** is Diablo II's own character file, which is what a save has to be * to be worth anything to a player. Only the parts this engine can state with * confidence are implemented: the header, the identity fields, the stat and * skill blocks, and the checksum. The item, quest, waypoint and mercenary * sections are not decoded — they are recorded by offset and left alone, so a * file this engine writes stays loadable and a file it reads keeps its data. * * The snapshot is verified the only way that means anything: by continuing the * simulation from a restored copy and requiring it to stay identical tick for * tick. The `.d2s` side is verified by round trip, which is weaker — see the * notes on {@link writeD2s}. */ import { rebindPlayer } from './combat.ts' import type { CombatWorld, Monster } from './combat.ts' import type { Inventory, Item, PlacedItem } from './items.ts' import type { QuestLog, QuestProgress } from './quests.ts' import type { Rng } from './rng.ts' /** Snapshot format version; bumped whenever the shape changes. */ export const SNAPSHOT_VERSION = 1 /** A saved game. */ export interface GameSnapshot { /** Format version. */ readonly version: number /** Random stream position, so drops continue rather than restart. */ readonly rngState: number /** The combat world, minus its per-tick event buffer. */ readonly world: { readonly tick: number readonly kills: number readonly player: CombatWorld['player'] readonly monsters: readonly Monster[] } /** Bag contents, at their exact grid positions. */ readonly inventory: { readonly width: number readonly height: number readonly placed: readonly { readonly x: number; readonly y: number; readonly item: Item }[] } /** Quest progress. */ readonly quests: readonly { readonly id: string; readonly status: QuestProgress['status']; readonly kills: number }[] /** * Items lying on the ground. Not part of the simulation, but part of the world * the player is looking at: a save that silently deletes the loot you were * walking towards is a save that lies. */ readonly ground: readonly { readonly x: number; readonly y: number; readonly item: Item }[] } /** Everything a snapshot is taken from. */ export interface SnapshotSources { /** The combat world. */ readonly world: CombatWorld /** The loot random stream. */ readonly rng: Rng /** The player's inventory. */ readonly inventory: Inventory /** The quest log. */ readonly quests: QuestLog /** Items on the ground. */ readonly ground: readonly { readonly x: number; readonly y: number; readonly item: Item }[] } /** * Capture the current game. * * @param sources - the live state. * @returns the snapshot (a plain object, ready to serialize). */ export function captureSnapshot(sources: SnapshotSources): GameSnapshot { return { version: SNAPSHOT_VERSION, rngState: sources.rng.seed, world: { tick: sources.world.tick, kills: sources.world.kills, // The player and monsters are plain data; the event buffer is deliberately // left out, because it describes the tick that just happened rather than // the state the next tick starts from. player: { ...sources.world.player }, monsters: sources.world.monsters.map(monster => ({ ...monster })), }, inventory: { width: sources.inventory.width, height: sources.inventory.height, placed: sources.inventory.contents.map(entry => ({ x: entry.x, y: entry.y, item: entry.item })), }, quests: sources.quests.all.map(entry => ({ id: entry.def.id, status: entry.status, kills: entry.kills })), ground: sources.ground.map(entry => ({ x: entry.x, y: entry.y, item: entry.item })), } } /** * Serialize a snapshot. * * @param snapshot - the snapshot. * @returns its JSON text. */ export function serializeSnapshot(snapshot: GameSnapshot): string { return JSON.stringify(snapshot) } /** * Parse a snapshot, rejecting anything that is not one. * * Validation is deliberate rather than trusting `JSON.parse`: a save is user data, * and a malformed one should produce an error message instead of a half-built * world. * * @param text - the JSON text. * @returns the parsed snapshot. */ function assertPlacedItem(value: unknown, index: number): asserts value is import('./items.ts').PlacedItem { if (typeof value !== 'object' || value === null) throw new Error(`inventory placed item [${index}] is malformed`) if (!('x' in value) || typeof value.x !== 'number') throw new Error(`inventory placed item [${index}] has invalid x`) if (!('y' in value) || typeof value.y !== 'number') throw new Error(`inventory placed item [${index}] has invalid y`) if (!('item' in value) || typeof value.item !== 'object' || value.item === null) throw new Error(`inventory placed item [${index}] has no item`) } function assertSnapshot(value: unknown): asserts value is GameSnapshot { if (typeof value !== 'object' || value === null) throw new Error('save is not an object') if (!('version' in value) || value.version !== SNAPSHOT_VERSION) { throw new Error(`save version ${String('version' in value ? value.version : '?')} is not supported (expected ${String(SNAPSHOT_VERSION)})`) } if (!('rngState' in value) || typeof value.rngState !== 'number') throw new Error('save has no random state') if (!('world' in value) || typeof value.world !== 'object' || value.world === null) throw new Error('save has no world') if (!('tick' in value.world) || typeof value.world.tick !== 'number') throw new Error('save has no world') if (!('player' in value.world) || typeof value.world.player !== 'object' || value.world.player === null) throw new Error('save has no player') if (!('monsters' in value.world) || !Array.isArray(value.world.monsters)) throw new Error('save has no monster list') if (!('inventory' in value) || typeof value.inventory !== 'object' || value.inventory === null) throw new Error('save has no inventory bounds') if (!('width' in value.inventory) || typeof value.inventory.width !== 'number') throw new Error('save has no inventory bounds') if (!('height' in value.inventory) || typeof value.inventory.height !== 'number') throw new Error('save has no inventory bounds') if (!('placed' in value.inventory) || !Array.isArray(value.inventory.placed)) throw new Error('save has no inventory') for (const [index, entry] of Object.entries(value.inventory.placed)) { assertPlacedItem(entry, Number(index)) } if (!('quests' in value) || !Array.isArray(value.quests)) throw new Error('save has no quest log') if ('ground' in value && value.ground !== undefined && !Array.isArray(value.ground)) { throw new Error('save has a malformed ground list') } } export function parseSnapshot(text: string): GameSnapshot { const parsed: unknown = JSON.parse(text) assertSnapshot(parsed) // Ensure ground is present if missing from older saves if (!('ground' in parsed) || parsed.ground === undefined) { return { ...parsed, ground: [] } } return parsed } /** * Restore a snapshot into live objects. * * @param snapshot - the snapshot. * @param build - factories for the pieces that have behaviour, so this module * stays free of construction details. * @returns the restored state. */ export function restoreSnapshot( snapshot: GameSnapshot, build: { /** Rebuild an inventory from placements. */ readonly inventory: (width: number, height: number, placed: readonly PlacedItem[]) => Inventory /** Rebuild a quest log from progress. */ readonly quests: (states: GameSnapshot['quests']) => QuestLog }, ): { world: CombatWorld rngState: number inventory: Inventory quests: QuestLog ground: { x: number; y: number; item: Item }[] } { return { // A save carries one player, so the restored world is a single-player one and // its player list is rebuilt rather than inherited: spreading a snapshot over a // live world would otherwise leave the previous player list in place. world: rebindPlayer({ ...snapshot.world, monsters: [...snapshot.world.monsters], players: [], events: [] }), rngState: snapshot.rngState, inventory: build.inventory(snapshot.inventory.width, snapshot.inventory.height, snapshot.inventory.placed), quests: build.quests(snapshot.quests), ground: snapshot.ground.map(entry => ({ x: entry.x, y: entry.y, item: entry.item })), } } // --- Diablo II character files (.d2s) --------------------------------------- /** The `.d2s` signature. */ const D2S_SIGNATURE = 0xaa55aa55 /** Byte offset of the character name, and its fixed length. */ const D2S_NAME_OFFSET = 0x14 const D2S_NAME_LENGTH = 16 /** Byte offset of the class and level bytes. */ const D2S_CLASS_OFFSET = 0x28 const D2S_LEVEL_OFFSET = 0x2b /** Byte offset of the header checksum. */ const D2S_CHECKSUM_OFFSET = 0x0c /** Diablo II version this writer targets (1.09+ layout, no expansion-only sections). */ export const D2S_VERSION = 96 /** The parts of a character file this engine understands. */ export interface D2sCharacter { /** Format version word. */ readonly version: number /** Character name (at most 15 characters). */ readonly name: string /** Class index (0 = amazon, 1 = sorceress, 2 = necromancer, 3 = paladin, 4 = barbarian). */ readonly classIndex: number /** Character level. */ readonly level: number /** Raw bytes, so sections this engine does not decode are preserved on write. */ readonly raw: Uint8Array } /** * Diablo II's save checksum. * * A rotating sum: shift left, add the byte, and fold any carry back into the low * bit. Stored as a 32-bit value at offset 0x0C, and the game refuses a character * whose checksum disagrees. * * @param data - the file bytes, with the checksum field present. * @returns the checksum. */ export function d2sChecksum(data: Uint8Array): number { let sum = 0 for (let index = 0; index < data.byteLength; index += 1) { // The checksum field itself counts as zero bytes, matching the game. const byte = index >= D2S_CHECKSUM_OFFSET && index < D2S_CHECKSUM_OFFSET + 4 ? 0 : data[index]! // Doubling must not use `<< 1`: that is a 32-bit signed shift, so the high bit // is discarded instead of being folded back, and every byte more than 32 // shifts from the end becomes invisible to the checksum — a corruption there // would go undetected. Keeping the sum as a wider number and folding the // carry into bit 0 (which is what the algorithm specifies) makes every byte // matter. sum = sum * 2 + byte if (sum > 0xffffffff) sum = (sum % 0x100000000) + 1 } return sum >>> 0 } /** * Read a `.d2s` character file. * * Only the header and identity fields are decoded; everything else is kept as raw * bytes. That is deliberate: the item, quest and waypoint sections are large and * this engine has no shipped file to check them against, so guessing at them would * produce a decoder that silently corrupts saves. * * @param data - the file bytes. * @returns the character. */ export function readD2s(data: Uint8Array): D2sCharacter { if (data.byteLength < 0x30) throw new Error(`character file is only ${String(data.byteLength)} bytes`) const view = new DataView(data.buffer, data.byteOffset, data.byteLength) const signature = view.getUint32(0, true) if (signature !== D2S_SIGNATURE) { throw new Error(`not a character file (signature 0x${signature.toString(16)})`) } const version = view.getUint32(4, true) const storedChecksum = view.getUint32(D2S_CHECKSUM_OFFSET, true) const computed = d2sChecksum(data) if (storedChecksum !== computed) { throw new Error(`checksum mismatch (stored 0x${storedChecksum.toString(16)}, computed 0x${computed.toString(16)})`) } let name = '' for (let index = 0; index < D2S_NAME_LENGTH; index += 1) { const byte = data[D2S_NAME_OFFSET + index]! if (byte === 0) break name += String.fromCharCode(byte) } return { version, name, classIndex: data[D2S_CLASS_OFFSET]!, level: data[D2S_LEVEL_OFFSET]!, raw: data, } } /** * Write a `.d2s` character file around an existing body. * * Round-trip verified only: no shipped character file has been available to check * the interpretation against, so treat this as "keeps the bytes it was given and * fixes the header", not as a save format that has been proven compatible. * * @param character - the character to write. * @returns the file bytes. */ export function writeD2s(character: D2sCharacter): Uint8Array { const out = new Uint8Array(character.raw) const view = new DataView(out.buffer, out.byteOffset, out.byteLength) view.setUint32(0, D2S_SIGNATURE, true) view.setUint32(4, character.version, true) view.setUint32(8, out.byteLength, true) view.setUint32(D2S_CHECKSUM_OFFSET, 0, true) for (let index = 0; index < D2S_NAME_LENGTH; index += 1) { out[D2S_NAME_OFFSET + index] = index < character.name.length ? character.name.charCodeAt(index) & 0x7f : 0 } out[D2S_CLASS_OFFSET] = character.classIndex & 0xff out[D2S_LEVEL_OFFSET] = character.level & 0xff view.setUint32(D2S_CHECKSUM_OFFSET, d2sChecksum(out), true) return out } /** * Build a minimal character file from scratch (a header plus zeroed body). * * @param name - character name. * @param classIndex - class index. * @param level - level. * @param bodyBytes - body size to allocate. * @returns the file bytes. */ export function createD2s(name: string, classIndex: number, level: number, bodyBytes = 0x100): Uint8Array { const body = new Uint8Array(0x30 + bodyBytes) return writeD2s({ version: D2S_VERSION, name, classIndex, level, raw: body }) }