341 lines
14 KiB
TypeScript
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 })
|
|
}
|