diablo2-web/src/server/save/save.ts

528 lines
21 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 '../engine/combat.ts'
import type { CharacterClassCode, CombatPlayer, CombatWorld, Monster } from '../engine/combat.ts'
import type { Inventory, Item, PlacedItem } from '../../common/items/items.ts'
import type { QuestLog, QuestProgress } from '../../common/world/quests.ts'
import type { Rng } from '../../common/rng/d2-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 low 32-bit position, so drops continue rather than restart. */
readonly rngState: number
/** Random stream high 32-bit carry position (64-bit D2Rng). */
readonly rngHighState?: number | undefined
/** Cast RNG low 32-bit state. */
readonly castRngState?: number | undefined
/** Cast RNG high 32-bit carry state. */
readonly castRngHighState?: number | undefined
/** Hydra sequence counter. */
readonly hydraGroupSeqCounter?: number | undefined
/** GroundItemManager sequential ID counter. */
readonly groundItemIdCounter?: number | undefined
/** 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 gold?: number | undefined
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
readonly groundItemId?: string | undefined
}[]
}
/** Everything a snapshot is taken from. */
export interface SnapshotSources {
/** The combat world. */
readonly world: CombatWorld
/** The loot random stream. */
readonly rng: Rng
/** Optional cast RNG stream. */
readonly castRng?: Rng | undefined
/** Optional Hydra sequence counter. */
readonly hydraGroupSeqCounter?: number | undefined
/** Optional GroundItemManager sequence counter. */
readonly groundItemIdCounter?: number | undefined
/** 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
readonly groundItemId?: string | undefined
}[]
}
/**
* 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,
rngHighState: sources.rng.hi,
...(sources.castRng !== undefined ? { castRngState: sources.castRng.seed, castRngHighState: sources.castRng.hi } : {}),
...(sources.hydraGroupSeqCounter !== undefined ? { hydraGroupSeqCounter: sources.hydraGroupSeqCounter } : {}),
...(sources.groundItemIdCounter !== undefined ? { groundItemIdCounter: sources.groundItemIdCounter } : {}),
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,
gold: sources.inventory.dedicatedGold,
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,
...(entry.groundItemId !== undefined ? { groundItemId: entry.groundItemId } : {}),
})),
}
}
/**
* 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('../../common/items/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 ('rngHighState' in value && value.rngHighState !== undefined && typeof value.rngHighState !== 'number') {
throw new Error('save has invalid rngHighState')
}
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
rngHighState: number
castRngState?: number | undefined
castRngHighState?: number | undefined
hydraGroupSeqCounter?: number | undefined
groundItemIdCounter?: number | undefined
inventory: Inventory
quests: QuestLog
ground: { x: number; y: number; item: Item; groundItemId?: string | undefined }[]
} {
const inventory = build.inventory(snapshot.inventory.width, snapshot.inventory.height, snapshot.inventory.placed)
if (typeof snapshot.inventory.gold === 'number') {
inventory.dedicatedGold = snapshot.inventory.gold
}
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 >>> 0,
rngHighState: (snapshot.rngHighState ?? 0) >>> 0,
...(snapshot.castRngState !== undefined ? { castRngState: snapshot.castRngState >>> 0 } : {}),
...(snapshot.castRngHighState !== undefined ? { castRngHighState: snapshot.castRngHighState >>> 0 } : {}),
...(snapshot.hydraGroupSeqCounter !== undefined ? { hydraGroupSeqCounter: snapshot.hydraGroupSeqCounter } : {}),
...(snapshot.groundItemIdCounter !== undefined ? { groundItemIdCounter: snapshot.groundItemIdCounter } : {}),
inventory,
quests: build.quests(snapshot.quests),
ground: snapshot.ground.map(entry => ({
x: entry.x,
y: entry.y,
item: entry.item,
...(entry.groundItemId !== undefined ? { groundItemId: entry.groundItemId } : {}),
})),
}
}
// --- 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 })
}
export interface RuntimeSavePayload {
readonly classCode: CharacterClassCode
readonly player: {
readonly level: number
readonly xp: number
readonly str: number
readonly dex: number
readonly vit: number
readonly ene: number
readonly statPoints: number
readonly skillPoints: number
readonly hp: number
readonly maxHp: number
readonly mana: number
readonly maxMana: number
readonly stamina: number
readonly maxStamina: number
}
readonly gold: number
readonly bagItems: readonly PlacedItem[]
readonly beltSlots: readonly { readonly col: number; readonly row: number; readonly item: any }[]
readonly allocatedSkills: readonly [number, number][]
}
export function serializeSaveData(input: {
readonly classCode: CharacterClassCode
readonly player: CombatPlayer
readonly bag?: { readonly gold: number; readonly contents: readonly PlacedItem[] } | undefined
readonly belt?: { getSlot?: (col: number, row: number) => any } | undefined
readonly skillPoints?: Map<number, number> | Record<number, number> | number | undefined
}): string {
const p = input.player
const beltSlots: { col: number; row: number; item: any }[] = []
if (input.belt && typeof input.belt.getSlot === 'function') {
for (let row = 0; row < 4; row += 1) {
for (let col = 0; col < 4; col += 1) {
const item = input.belt.getSlot(col, row)
if (item) beltSlots.push({ col, row, item })
}
}
}
const allocatedSkills: [number, number][] =
input.skillPoints instanceof Map
? Array.from(input.skillPoints.entries())
: typeof input.skillPoints === 'object' && input.skillPoints !== null
? Object.entries(input.skillPoints).map(([k, v]) => [Number(k), Number(v)])
: []
const payload: RuntimeSavePayload = {
classCode: input.classCode,
player: {
level: p.level ?? 1,
xp: p.xp ?? 0,
str: p.str ?? p.strength ?? 10,
dex: p.dex ?? p.dexterity ?? 25,
vit: p.vit ?? p.vitality ?? 10,
ene: p.ene ?? p.energy ?? 35,
statPoints: p.statPoints ?? 0,
skillPoints: p.skillPoints ?? 0,
hp: p.hp,
maxHp: p.maxHp,
mana: p.mana,
maxMana: p.maxMana,
stamina: p.stamina ?? 100,
maxStamina: p.maxStamina ?? 100,
},
gold: input.bag?.gold ?? 0,
bagItems: input.bag?.contents ?? [],
beltSlots,
allocatedSkills,
}
return JSON.stringify(payload)
}
export function deserializeSaveData(raw: string): RuntimeSavePayload | null {
try {
const parsed = JSON.parse(raw) as RuntimeSavePayload
if (!parsed || typeof parsed !== 'object' || !parsed.player) return null
return parsed
} catch {
return null
}
}
export function applySaveDataToRuntime(
data: RuntimeSavePayload,
target: {
readonly player: CombatPlayer
readonly bag?: { gold: number; add?: (item: any) => any } | undefined
readonly belt?: { setSlot?: (col: number, row: number, item: any) => any; clear?: () => void } | undefined
readonly skillPoints?: Map<number, number> | undefined
readonly setClassCode?: ((classCode: CharacterClassCode) => void) | undefined
},
): void {
target.setClassCode?.(data.classCode)
const p = target.player
p.classCode = data.classCode
p.level = data.player.level
p.xp = data.player.xp
p.str = data.player.str
p.dex = data.player.dex
p.vit = data.player.vit
p.ene = data.player.ene
p.statPoints = data.player.statPoints
p.skillPoints = data.player.skillPoints
p.maxHp = data.player.maxHp
p.hp = data.player.hp
p.maxMana = data.player.maxMana
p.mana = data.player.mana
p.maxStamina = data.player.maxStamina
p.stamina = data.player.stamina
if (target.bag) {
target.bag.gold = data.gold ?? 0
}
if (target.belt && typeof target.belt.setSlot === 'function') {
for (const slot of data.beltSlots ?? []) {
target.belt.setSlot(slot.col, slot.row, slot.item)
}
}
if (target.skillPoints instanceof Map) {
target.skillPoints.clear()
for (const [k, v] of data.allocatedSkills ?? []) {
target.skillPoints.set(k, v)
}
}
}