diablo2-web/src/game/save.ts

341 lines
14 KiB
TypeScript

/**
* 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 })
}