/** * Bake character and monster animations into web-native indexed PNG and raw R8 atlases (Schema 2). * * Pre-packing entity animations into `samples/d2-packs/entities/` and `samples/d2-packs/act/entities/` * eliminates runtime HTTP Range requests to `d2char.mpq` / `d2data.mpq` / `d2exp.mpq` * and removes JS-side DCC/COF decompression & compositing during level boot. * * Schema 2 provides: * - Multi-mode animations for player (11 modes: wl, nu, rn, a1, a2, sc, gh, dt, dd, tn, tw). * - Multi-mode animations for monsters (filtered by MonStats2.txt m* bits). * - Exact 1.13c fractional speeds and chronological frame events from AnimData.D2. * - Per-frame 6-tuples [x, y, w, h, ax, ay] with exact sprite-space anchor offsets. * - Backward-compatibility aliases (walk, stand, walkOffset, standOffset). */ import { mkdir, writeFile } from 'node:fs/promises' import { existsSync, readFileSync } from 'node:fs' import { join } from 'node:path' import { MpqArchive } from '../src/mpq/archive.ts' import { fileSource } from '../src/mpq/file-source.ts' import { MountedArchives } from '../src/mpq/mount.ts' import { decodePl2 } from '../src/formats/pl2.ts' import { loadCharacterSheet, type CharacterSheet } from '../src/game/character.ts' import { compositeMonsterAnimation, type MonsterAnimationSheet } from '../src/game/monster-art.ts' import { ACT_MONSTER_SPECS, MONSTER_ART_MAP, resolveMonsterArtSpec } from '../src/game/monster-mapping.ts' import { CANONICAL_SUPER_UNIQUE_LANDMARKS, CANONICAL_SUPERUNIQUES_TABLE, readSuperUniques, } from '../src/game/monsters.ts' import type { SpriteSheet } from '../src/formats/sprite.ts' import { encodeIndexedPng } from './png.ts' const MOUNTS = ['d2char.mpq', 'd2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq'] as const const PAGE_SIZE = 4096 const PLAYER_MODES = ['wl', 'nu', 'rn', 'a1', 'a2', 'sc', 'gh', 'dt', 'dd', 'tn', 'tw'] as const const LOOPING_PLAYER_MODES = new Set(['nu', 'wl', 'rn', 'tn', 'tw']) const TARGET_MONSTER_MODES = ['wl', 'nu', 'rn', 'a1', 'a2', 'sc', 'gh', 'dt', 'dd'] as const const LOOPING_MONSTER_MODES = new Set(['nu', 'wl', 'rn']) function findOpaqueBlackIndex(palette: Uint8Array): number { let bestIdx = 1 let bestDist = Infinity for (let i = 1; i < 256; i += 1) { const r = palette[i * 3]! const g = palette[i * 3 + 1]! const b = palette[i * 3 + 2]! const dist = r * r + g * g + b * b if (dist < bestDist) { bestDist = dist bestIdx = i if (dist === 0) break } } return bestIdx } export interface PackedEntitySheet { readonly png: Uint8Array readonly width: number readonly height: number readonly groups: [number, number, number, number, number, number][][] } export interface PackedEntityR8 { readonly r8: Uint8Array readonly width: number readonly height: number readonly groups: [number, number, number, number, number, number][][] } /** * Shelf-pack all frames of a SpriteSheet into a single raw 8-bit index buffer (`gl.R8`). * Index 0 represents transparency; opaque pixels with original index 0 are remapped to 172 (`rgb(4,4,4)`). * Each frame tuple stores [x, y, width, height, anchorX, anchorY] (Schema 2). */ export function packSheetToR8(sheet: SpriteSheet, opaqueBlack = 172): PackedEntityR8 { const frames = sheet.groups.flatMap(g => g.frames) const widest = Math.max(1, ...frames.map(f => f.width)) const area = frames.reduce((sum, f) => sum + (f.width + 1) * (f.height + 1), 0) let pageWidth = Math.min(PAGE_SIZE, Math.max(widest + 2, 512)) while (pageWidth < PAGE_SIZE && pageWidth * pageWidth < area * 1.15) { pageWidth *= 2 } pageWidth = Math.min(PAGE_SIZE, pageWidth) let cursorX = 0 let shelfY = 0 let shelfHeight = 0 const placements: { x: number; y: number; width: number; height: number }[] = [] for (const frame of frames) { const w = Math.max(1, Math.min(frame.width, pageWidth)) const h = Math.max(1, Math.min(frame.height, PAGE_SIZE)) if (cursorX + w > pageWidth) { shelfY += shelfHeight + 1 shelfHeight = 0 cursorX = 0 } placements.push({ x: cursorX, y: shelfY, width: w, height: h }) cursorX += w + 1 shelfHeight = Math.max(shelfHeight, h) } const pageHeight = Math.max(1, shelfY + shelfHeight) const r8 = new Uint8Array(pageWidth * pageHeight) let idx = 0 for (const frame of frames) { const place = placements[idx]! idx += 1 for (let row = 0; row < place.height; row += 1) { const fromRow = row * frame.width const toRow = (place.y + row) * pageWidth + place.x for (let col = 0; col < place.width; col += 1) { const srcAt = fromRow + col if (frame.mask[srcAt] === 0) { r8[toRow + col] = 0 } else { const val = frame.indices[srcAt]! r8[toRow + col] = val === 0 ? opaqueBlack : val } } } } const groups: [number, number, number, number, number, number][][] = [] let consumed = 0 for (const group of sheet.groups) { const gList: [number, number, number, number, number, number][] = [] for (let i = 0; i < group.frames.length; i += 1) { const p = placements[consumed]! const f = group.frames[i]! consumed += 1 gList.push([p.x, p.y, p.width, p.height, f.anchorX ?? 0, f.anchorY ?? 0]) } groups.push(gList) } return { r8, width: pageWidth, height: pageHeight, groups } } /** * Shelf-pack all frames of a SpriteSheet into a single indexed PNG buffer. */ export function packSheetToPng(sheet: SpriteSheet, palette: Uint8Array): PackedEntitySheet { const opaqueBlack = findOpaqueBlackIndex(palette) const packed = packSheetToR8(sheet, opaqueBlack) const png = encodeIndexedPng({ width: packed.width, height: packed.height, pixels: packed.r8, palette, transparentIndex: 0, }) return { png, width: packed.width, height: packed.height, groups: packed.groups } } export async function bakeEntities(archiveDir = 'samples/d2', outDir = 'samples/d2-packs'): Promise { const archives = new MountedArchives() for (const name of MOUNTS) { try { const archive = await MpqArchive.open(await fileSource(join(archiveDir, name))) archives.add(name, archive) } catch (err) { console.warn(`skip ${name}: ${(err as Error).message}`) } } // 0. Load timing and mode tables from anim/ const animDataJsonPath = join(outDir, 'anim', 'animdata.json') const monstats2JsonPath = join(outDir, 'anim', 'monstats2.json') if (!existsSync(animDataJsonPath) || !existsSync(monstats2JsonPath)) { throw new Error(`AnimData or MonStats2 JSON missing in ${join(outDir, 'anim')}. Run 'npm run pack:animdata' first.`) } const animData = JSON.parse(readFileSync(animDataJsonPath, 'utf8')) const monstats2 = JSON.parse(readFileSync(monstats2JsonPath, 'utf8')) // 1. Load character sheets for all 11 player modes console.log('Composing Sorceress (so) 11 animation modes...') const charSheets: CharacterSheet[] = [] const charClips: Record = {} let charGroupOffset = 0 let totalCharMembers = 0 for (const mode of PLAYER_MODES) { const sheet = await loadCharacterSheet(archives, 'so', mode, 'hth') charSheets.push(sheet) totalCharMembers += sheet.members.length const cofName = `SO${mode.toUpperCase()}HTH` const animRecord = animData.clips[cofName] const speed = animRecord?.speed ?? 256 const events = (animRecord?.events ?? []).map(([f, c]: [number, number]) => ({ f, c })) charClips[mode] = { group: charGroupOffset, directions: sheet.directions, frames: sheet.framesPerDirection, speed, loop: LOOPING_PLAYER_MODES.has(mode), events, } charGroupOffset += sheet.sheet.groups.length } const charJoined: SpriteSheet = { groups: charSheets.flatMap(s => s.sheet.groups), width: null, } // 2. Collect unique monster (token, weapon) specs across Acts 1..5 const uniqueSpecs = new Map() for (let act = 1; act <= 5; act += 1) { const specs = ACT_MONSTER_SPECS[act] ?? [] for (const spec of specs) { if (!spec.token || spec.token.toLowerCase() === 'xx') continue const key = `${spec.token.toLowerCase()}-${spec.weapon.toLowerCase()}` if (!uniqueSpecs.has(key)) { uniqueSpecs.set(key, spec) } } } // Include SuperUnique landmark bosses and minions const superUniques = readSuperUniques(CANONICAL_SUPERUNIQUES_TABLE) const landmarkMinionIds = new Set() for (const landmark of CANONICAL_SUPER_UNIQUE_LANDMARKS) { if (landmark.minionMonsterId) { landmarkMinionIds.add(landmark.minionMonsterId) } } const allSuperUniqueMonsterIds = new Set() for (const su of superUniques) { allSuperUniqueMonsterIds.add(su.monsterId) } for (const minionId of landmarkMinionIds) { allSuperUniqueMonsterIds.add(minionId) } for (const monsterId of allSuperUniqueMonsterIds) { const spec = resolveMonsterArtSpec(monsterId) if (spec && spec.token && spec.token.toLowerCase() !== 'xx') { const key = `${spec.token.toLowerCase()}-${spec.weapon.toLowerCase()}` if (!uniqueSpecs.has(key)) { uniqueSpecs.set(key, spec) } } } // Map allowed modes for each spec from MonStats2.txt const specAllowedModes = new Map>() for (const [key, spec] of uniqueSpecs) { const modes = new Set() for (const [id, s] of Object.entries(MONSTER_ART_MAP)) { if (s.token.toLowerCase() === spec.token.toLowerCase() && s.weapon.toLowerCase() === spec.weapon.toLowerCase()) { const row = monstats2.rows[id.toLowerCase()] if (row?.modes) { for (const m of Object.keys(row.modes)) { modes.add(m.toLowerCase()) } } } } // Every active monster must at least have 'nu' modes.add('nu') specAllowedModes.set(key, modes) } // Composite monster animations (concurrency limit 6) const compositedMonsters = new Map sheet: SpriteSheet }>() const entries = [...uniqueSpecs.entries()] let cursor = 0 const workers = Array.from({ length: Math.min(6, entries.length) }, async () => { for (;;) { const idx = cursor cursor += 1 if (idx >= entries.length) return const [key, spec] = entries[idx]! if (!spec.token || spec.token.toLowerCase() === 'xx') continue const allowed = specAllowedModes.get(key) ?? new Set(['nu']) const modesToProbe = TARGET_MONSTER_MODES.filter(m => allowed.has(m)) const monSheets: { mode: string; sheet: MonsterAnimationSheet }[] = [] for (const mode of modesToProbe) { try { const anim = await compositeMonsterAnimation(archives, spec.token, mode, spec.weapon) monSheets.push({ mode, sheet: anim }) } catch { // Mode COF not found for this monster; skip } } if (monSheets.length === 0) { console.warn(`No animations could be composited for monster ${key}`) continue } const clips: Record = {} let groupOffset = 0 let baseDirections = 8 for (const { mode, sheet } of monSheets) { const animKey = `${spec.token.toUpperCase()}${mode.toUpperCase()}${spec.weapon.toUpperCase()}` const animHthKey = `${spec.token.toUpperCase()}${mode.toUpperCase()}HTH` const animRecord = animData.clips[animKey] ?? animData.clips[animHthKey] const speed = animRecord?.speed ?? 256 const events = (animRecord?.events ?? []).map(([f, c]: [number, number]) => ({ f, c })) clips[mode] = { group: groupOffset, directions: sheet.directions, frames: sheet.framesPerDirection, speed, loop: LOOPING_MONSTER_MODES.has(mode), events, } if (mode === 'nu') { baseDirections = sheet.directions } groupOffset += sheet.sheet.groups.length } if (!clips.nu) { console.warn(`Monster ${key} has no 'nu' clip! Skipping.`) continue } const joinedSheet: SpriteSheet = { groups: monSheets.flatMap(s => s.sheet.sheet.groups), width: null, } compositedMonsters.set(key, { key, token: spec.token, weapon: spec.weapon, directions: baseDirections, walkOffset: clips.wl ? clips.wl.group : undefined, standOffset: clips.nu.group, walkFrames: clips.wl ? clips.wl.frames : undefined, standFrames: clips.nu.frames, clips, sheet: joinedSheet, }) } }) await Promise.all(workers) console.log(`Composited ${compositedMonsters.size} unique monster specs across Acts 1..5.`) // 3. Emit global act-independent raw 8-bit (.r8) index textures into outDir/entities const globalEntityDir = join(outDir, 'entities') await mkdir(globalEntityDir, { recursive: true }) const packedCharR8 = packSheetToR8(charJoined, 172) await writeFile(join(globalEntityDir, 'char-so.r8'), packedCharR8.r8) await writeFile( join(globalEntityDir, 'char-so.json'), JSON.stringify({ schema: 2, file: 'char-so.r8', format: 'r8', width: packedCharR8.width, height: packedCharR8.height, directions: charClips.wl.directions, clips: charClips, walk: charClips.wl.group, stand: charClips.nu.group, members: totalCharMembers, groups: packedCharR8.groups, }), ) const packedMonstersR8 = new Map() for (const [key, m] of compositedMonsters) { const packedMonR8 = packSheetToR8(m.sheet, 172) packedMonstersR8.set(key, packedMonR8) await writeFile(join(globalEntityDir, `monster-${key}.r8`), packedMonR8.r8) await writeFile( join(globalEntityDir, `monster-${key}.json`), JSON.stringify({ schema: 2, token: m.token, weapon: m.weapon, file: `monster-${key}.r8`, format: 'r8', width: packedMonR8.width, height: packedMonR8.height, directions: m.directions, clips: m.clips, walkOffset: m.walkOffset, standOffset: m.standOffset, walkFrames: m.walkFrames, standFrames: m.standFrames, groups: packedMonR8.groups, }), ) } console.log(`Global R8 entities: packed char-so.r8 + ${packedMonstersR8.size} monster .r8 atlases into ${globalEntityDir}`) // 4. Encode indexed PNGs per Act palette (legacy fallback) for (let act = 1; act <= 5; act += 1) { try { const pl2 = decodePl2(await archives.read(`data\\global\\palette\\act${String(act)}\\pal.pl2`)) const palette = pl2.rgb const actEntityDir = join(outDir, `act${String(act)}`, 'entities') await mkdir(actEntityDir, { recursive: true }) // Pack character PNG from cached R8 const charPng = encodeIndexedPng({ width: packedCharR8.width, height: packedCharR8.height, pixels: packedCharR8.r8, palette, transparentIndex: 0, }) const charPngName = 'char-so.png' await writeFile(join(actEntityDir, charPngName), charPng) await writeFile( join(actEntityDir, 'char-so.json'), JSON.stringify({ schema: 2, file: charPngName, width: packedCharR8.width, height: packedCharR8.height, directions: charClips.wl.directions, clips: charClips, walk: charClips.wl.group, stand: charClips.nu.group, members: totalCharMembers, groups: packedCharR8.groups, }), ) // Pack monsters used in this act const actSpecs = ACT_MONSTER_SPECS[act] ?? [] let packedCount = 0 for (const spec of actSpecs) { const key = `${spec.token.toLowerCase()}-${spec.weapon.toLowerCase()}` const m = compositedMonsters.get(key) const packedMonR8 = packedMonstersR8.get(key) if (!m || !packedMonR8) continue const monPng = encodeIndexedPng({ width: packedMonR8.width, height: packedMonR8.height, pixels: packedMonR8.r8, palette, transparentIndex: 0, }) const monPngName = `monster-${key}.png` await writeFile(join(actEntityDir, monPngName), monPng) await writeFile( join(actEntityDir, `monster-${key}.json`), JSON.stringify({ schema: 2, token: m.token, weapon: m.weapon, file: monPngName, width: packedMonR8.width, height: packedMonR8.height, directions: m.directions, clips: m.clips, walkOffset: m.walkOffset, standOffset: m.standOffset, walkFrames: m.walkFrames, standFrames: m.standFrames, groups: packedMonR8.groups, }), ) packedCount += 1 } console.log(`Act ${act}: packed char-so + ${packedCount} monster atlases into ${actEntityDir}`) } catch (err) { console.warn(`Act ${act} entity packing skipped: ${(err as Error).message}`) } } } if (import.meta.url === `file://${process.argv[1]}`) { const archiveDir = process.argv[2] ?? 'samples/d2' const outDir = process.argv[3] ?? 'samples/d2-packs' bakeEntities(archiveDir, outDir).catch(err => { console.error(err) process.exit(1) }) }