/** * 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). * - Multi-core parallelism using Node.js worker_threads with sequential fallback (MAX_WORKERS=1). * - Atomic JSON and asset file writes via temporary files and rename. * - Deterministic ordering for entity index. */ import { mkdir, writeFile, rename } from 'node:fs/promises' import { existsSync, readFileSync } from 'node:fs' import { join } from 'node:path' import { isMainThread, Worker, parentPort, workerData } from 'node:worker_threads' import { fileURLToPath } from 'node:url' import { availableParallelism } from 'node:os' import { performance } from 'node:perf_hooks' 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, SpriteGroup } from '../src/formats/sprite.ts' import { encodeIndexedPng } from './png.ts' function makeEmptyGlowGroups(baseSheet: SpriteSheet): SpriteGroup[] { return baseSheet.groups.map(g => ({ frames: g.frames.map(f => ({ width: 1, height: 1, indices: new Uint8Array(1), mask: new Uint8Array(1), ...(f.anchorX !== undefined ? { anchorX: f.anchorX } : {}), ...(f.anchorY !== undefined ? { anchorY: f.anchorY } : {}), })), })) } 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']) async function writeFileAtomic(targetPath: string, content: string | Uint8Array): Promise { const tmpPath = `${targetPath}.tmp.${process.pid}.${Math.random().toString(36).slice(2)}` await writeFile(tmpPath, content) await rename(tmpPath, targetPath) } async function writeJsonAtomic(targetPath: string, data: unknown): Promise { await writeFileAtomic(targetPath, JSON.stringify(data)) } 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 interface CharJob { readonly type: 'char' readonly jobId: number readonly archiveDir: string readonly outDir: string } export interface MonsterJob { readonly type: 'monster' readonly jobId: number readonly key: string readonly token: string readonly weapon: string readonly allowedModes: readonly string[] readonly acts: readonly number[] readonly archiveDir: string readonly outDir: string } export type EntityJob = CharJob | MonsterJob export interface CharResult { readonly type: 'char' readonly jobId: number readonly width: number readonly height: number readonly totalMembers: number readonly groupsCount: number readonly directions: number } export interface MonsterResult { readonly type: 'monster' readonly jobId: number readonly key: string readonly token: string readonly weapon: string readonly skipped?: boolean readonly file?: string readonly format?: string readonly width?: number readonly height?: number readonly directions?: number readonly standOffset?: number readonly walkOffset?: number readonly standFrames?: number readonly walkFrames?: number readonly blendMode?: 'normal' | 'additive' } export type EntityResult = CharResult | MonsterResult class EntityWorkerContext { constructor( public readonly archives: MountedArchives, public readonly animData: any, public readonly monstats2: any, public readonly palettes: Record, ) {} static async create(archiveDir: string, outDir: string): 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}`) } } const palettes: Record = {} for (let act = 1; act <= 5; act += 1) { try { const pl2Bytes = await archives.read(`data\\global\\palette\\act${String(act)}\\pal.pl2`) palettes[act] = decodePl2(pl2Bytes).rgb } catch (err) { console.warn(`Act ${act} palette load skipped: ${(err as Error).message}`) } } 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')) return new EntityWorkerContext(archives, animData, monstats2, palettes) } } async function processCharJob(job: CharJob, ctx: EntityWorkerContext): Promise { const { archives, animData, palettes } = ctx const { outDir, jobId } = job const globalEntityDir = join(outDir, 'entities') 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', undefined, true) 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, } const packedCharR8 = packSheetToR8(charJoined, 172) await writeFileAtomic(join(globalEntityDir, 'char-so.r8'), packedCharR8.r8) await writeJsonAtomic( join(globalEntityDir, 'char-so.json'), { 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, walkOffset: charClips.wl.group, standOffset: charClips.nu.group, members: totalCharMembers, groups: packedCharR8.groups, }, ) for (let act = 1; act <= 5; act += 1) { const palette = palettes[act] if (!palette) continue const actEntityDir = join(outDir, `act${String(act)}`, 'entities') const charPng = encodeIndexedPng({ width: packedCharR8.width, height: packedCharR8.height, pixels: packedCharR8.r8, palette, transparentIndex: 0, }) const charPngName = 'char-so.png' await writeFileAtomic(join(actEntityDir, charPngName), charPng) await writeJsonAtomic( join(actEntityDir, 'char-so.json'), { schema: 2, file: charPngName, width: packedCharR8.width, height: packedCharR8.height, directions: charClips.wl.directions, clips: charClips, walk: charClips.wl.group, stand: charClips.nu.group, walkOffset: charClips.wl.group, standOffset: charClips.nu.group, members: totalCharMembers, groups: packedCharR8.groups, }, ) } return { type: 'char', jobId, width: packedCharR8.width, height: packedCharR8.height, totalMembers: totalCharMembers, groupsCount: packedCharR8.groups.length, directions: charClips.wl.directions, } } async function processMonsterJob(job: MonsterJob, ctx: EntityWorkerContext): Promise { const { archives, animData, palettes } = ctx const { key, token, weapon, allowedModes, acts, outDir, jobId } = job const globalEntityDir = join(outDir, 'entities') const allowed = new Set(allowedModes) 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, token, mode, 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}`) return { type: 'monster', jobId, key, token, weapon, skipped: true } } const clips: Record = {} let groupOffset = 0 let baseDirections = 8 for (const { mode, sheet } of monSheets) { const animKey = `${token.toUpperCase()}${mode.toUpperCase()}${weapon.toUpperCase()}` const animHthKey = `${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.`) return { type: 'monster', jobId, key, token, weapon, skipped: true } } const hasGlow = monSheets.some(s => s.sheet.glowSheet !== undefined) const isPureAdditive = !hasGlow && monSheets.some(s => s.sheet.blendMode === 'additive') const monsterBlendMode: 'normal' | 'additive' = isPureAdditive ? 'additive' : 'normal' const glowClips: Record = {} if (hasGlow) { for (const { mode, sheet } of monSheets) { if (sheet.glowSheet !== undefined) { glowClips[mode] = { ...clips[mode]! } } } } const baseGroups = monSheets.flatMap(s => s.sheet.sheet.groups) const baseCount = baseGroups.length const allGlowGroups = hasGlow ? monSheets.flatMap(s => s.sheet.glowSheet?.groups ?? makeEmptyGlowGroups(s.sheet.sheet)) : [] const joinedSheet: SpriteSheet = { groups: hasGlow ? [...baseGroups, ...allGlowGroups] : baseGroups, width: null, } const packedMonR8 = packSheetToR8(joinedSheet, 172) const packedBaseGroups = packedMonR8.groups.slice(0, baseCount) const packedGlowGroups = hasGlow ? packedMonR8.groups.slice(baseCount) : undefined await writeFileAtomic(join(globalEntityDir, `monster-${key}.r8`), packedMonR8.r8) await writeJsonAtomic( join(globalEntityDir, `monster-${key}.json`), { schema: 2, token, weapon, file: `monster-${key}.r8`, format: 'r8', blendMode: monsterBlendMode, width: packedMonR8.width, height: packedMonR8.height, directions: baseDirections, clips, ...(hasGlow ? { glowClips } : {}), ...(clips.wl ? { walkOffset: clips.wl.group, walkFrames: clips.wl.frames } : {}), standOffset: clips.nu.group, standFrames: clips.nu.frames, groups: packedBaseGroups, ...(hasGlow ? { glowGroups: packedGlowGroups } : {}), }, ) for (const act of acts) { const palette = palettes[act] if (!palette) continue const actEntityDir = join(outDir, `act${String(act)}`, 'entities') const monPng = encodeIndexedPng({ width: packedMonR8.width, height: packedMonR8.height, pixels: packedMonR8.r8, palette, transparentIndex: 0, }) const monPngName = `monster-${key}.png` await writeFileAtomic(join(actEntityDir, monPngName), monPng) await writeJsonAtomic( join(actEntityDir, `monster-${key}.json`), { schema: 2, token, weapon, file: monPngName, blendMode: monsterBlendMode, width: packedMonR8.width, height: packedMonR8.height, directions: baseDirections, clips, ...(hasGlow ? { glowClips } : {}), ...(clips.wl ? { walkOffset: clips.wl.group, walkFrames: clips.wl.frames } : {}), standOffset: clips.nu.group, standFrames: clips.nu.frames, groups: packedBaseGroups, ...(hasGlow ? { glowGroups: packedGlowGroups } : {}), }, ) } return { type: 'monster', jobId, key, token, weapon, file: `monster-${key}.r8`, format: 'r8', width: packedMonR8.width, height: packedMonR8.height, directions: baseDirections, standOffset: clips.nu.group, walkOffset: clips.wl?.group, standFrames: clips.nu.frames, walkFrames: clips.wl?.frames, blendMode: monsterBlendMode, } } async function processJob(job: EntityJob, ctx: EntityWorkerContext): Promise { if (job.type === 'char') { return await processCharJob(job, ctx) } return await processMonsterJob(job, ctx) } async function runWorkerPool( jobs: readonly EntityJob[], numWorkers: number, archiveDir: string, outDir: string, ): Promise { return new Promise((resolve, reject) => { const targetScript = fileURLToPath(import.meta.url) const bootstrap = ` import { register } from 'tsx/esm/api' register() import(${JSON.stringify(targetScript)}) ` const results: EntityResult[] = new Array(jobs.length) const queue = [...jobs] let completedCount = 0 let shuttingDown = false let isDone = false const workers: Worker[] = [] const cleanup = () => { if (isDone) return isDone = true shuttingDown = true for (const w of workers) { w.terminate().catch(() => {}) } } const abort = (err: Error) => { if (shuttingDown) return cleanup() reject(err) } const assignNextJob = (worker: Worker) => { if (shuttingDown) return if (queue.length > 0) { const nextJob = queue.shift()! worker.postMessage({ type: 'job', job: nextJob }) } else if (completedCount === jobs.length) { cleanup() resolve(results) } } for (let i = 0; i < numWorkers; i += 1) { const worker = new Worker(bootstrap, { eval: true, execArgv: ['--import', 'tsx'], workerData: { archiveDir, outDir, workerId: i + 1 }, }) worker.on('message', (msg: { type: string; result?: EntityResult; label?: string; message?: string; stack?: string }) => { if (shuttingDown) return if (msg.type === 'ready') { assignNextJob(worker) } else if (msg.type === 'result') { const res = msg.result! results[res.jobId] = res completedCount += 1 const label = res.type === 'char' ? 'char-so' : `monster-${res.key}` console.log(`[${String(completedCount).padStart(2)}/${String(jobs.length)}] Packed ${label}`) assignNextJob(worker) } else if (msg.type === 'error') { console.error(`\nāŒ Error packing entity ${msg.label ?? ''}: ${msg.message ?? ''}`) if (msg.stack) console.error(msg.stack) abort(new Error(`Worker failed on entity ${msg.label ?? ''}: ${msg.message ?? ''}`)) } }) worker.on('error', (err) => { console.error(`\nāŒ Worker thread fatal error: ${err.message}`, err.stack) abort(err) }) worker.on('exit', (code) => { if (code !== 0 && !shuttingDown) { abort(new Error(`Worker thread exited unexpectedly with code ${String(code)}`)) } }) workers.push(worker) } }) } function startWorker(): void { process.on('unhandledRejection', (reason) => { console.error('Worker unhandled rejection:', reason) parentPort?.postMessage({ type: 'error', jobId: -1, label: 'unhandled-rejection', message: String(reason), stack: (reason as Error)?.stack, }) }) const data = (workerData ?? {}) as { archiveDir?: string; outDir?: string; workerId?: number } const archiveDir = data.archiveDir ?? 'samples/d2' const outDir = data.outDir ?? 'samples/d2-packs' let ctxPromise: Promise | null = null parentPort?.on('message', async (msg: { type: string; job?: EntityJob }) => { if (msg.type === 'job' && msg.job) { try { if (!ctxPromise) { ctxPromise = EntityWorkerContext.create( msg.job.archiveDir || archiveDir, msg.job.outDir || outDir, ) } const ctx = await ctxPromise const result = await processJob(msg.job, ctx) parentPort?.postMessage({ type: 'result', result }) } catch (err) { parentPort?.postMessage({ type: 'error', jobId: msg.job.jobId, label: msg.job.type === 'char' ? 'char-so' : msg.job.key, message: (err as Error).message, stack: (err as Error).stack, }) } } else if (msg.type === 'shutdown') { process.exit(0) } }) // Pre-initialize context and signal ready ctxPromise = EntityWorkerContext.create(archiveDir, outDir) ctxPromise .then(() => { parentPort?.postMessage({ type: 'ready' }) }) .catch((err) => { parentPort?.postMessage({ type: 'error', jobId: -1, label: 'worker-init', message: (err as Error).message, stack: (err as Error).stack, }) }) } export async function bakeEntities(archiveDir = 'samples/d2', outDir = 'samples/d2-packs'): Promise { const monstats2JsonPath = join(outDir, 'anim', 'monstats2.json') const animDataJsonPath = join(outDir, 'anim', 'animdata.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 monstats2 = JSON.parse(readFileSync(monstats2JsonPath, 'utf8')) const globalEntityDir = join(outDir, 'entities') await mkdir(globalEntityDir, { recursive: true }) for (let act = 1; act <= 5; act += 1) { await mkdir(join(outDir, `act${String(act)}`, 'entities'), { recursive: true }) } // 1. 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) } } } // Include special summon and minion entities (Valkyrie, Decoy/Dopplezon) for (const summonId of ['valkyrie', 'dopplezon']) { const spec = resolveMonsterArtSpec(summonId) 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()) } } } } modes.add('nu') specAllowedModes.set(key, modes) } // Map acts where each monster is referenced const monsterActsMap = 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()}` const list = monsterActsMap.get(key) ?? [] if (!list.includes(act)) list.push(act) monsterActsMap.set(key, list) } } // 2. Build job list: Job 0 = Sorceress character, Jobs 1..N = Monsters const jobs: EntityJob[] = [] const charJob: CharJob = { type: 'char', jobId: 0, archiveDir, outDir, } jobs.push(charJob) if (process.env.CHAR_ONLY === '1') { const ctx = await EntityWorkerContext.create(archiveDir, outDir) await processCharJob(charJob, ctx) console.log('āœ“ Packed char-so (CHAR_ONLY=1).') return } const entries = [...uniqueSpecs.entries()] for (let i = 0; i < entries.length; i += 1) { const [key, spec] = entries[i]! const allowed = specAllowedModes.get(key) ?? new Set(['nu']) jobs.push({ type: 'monster', jobId: i + 1, key, token: spec.token, weapon: spec.weapon, allowedModes: [...allowed], acts: monsterActsMap.get(key) ?? [], archiveDir, outDir, }) } // 3. Worker pool concurrency governor const defaultMaxWorkers = typeof availableParallelism === 'function' ? availableParallelism() : 8 const envWorkers = process.env.MAX_WORKERS !== undefined ? parseInt(process.env.MAX_WORKERS, 10) : undefined const maxWorkers = envWorkers !== undefined ? Math.max(1, envWorkers) : Math.max(1, Math.min(defaultMaxWorkers, 16)) const useWorkers = maxWorkers > 1 const numWorkers = useWorkers ? Math.min(maxWorkers, jobs.length) : 1 console.log(`Baking ${String(jobs.length)} entities using ${useWorkers ? `${String(numWorkers)} worker threads` : 'sequential execution'}...`) const startTime = performance.now() let results: EntityResult[] if (useWorkers) { results = await runWorkerPool(jobs, numWorkers, archiveDir, outDir) } else { const ctx = await EntityWorkerContext.create(archiveDir, outDir) results = [] let count = 0 for (const job of jobs) { const res = await processJob(job, ctx) results.push(res) count += 1 const label = res.type === 'char' ? 'char-so' : `monster-${res.key}` console.log(`[${String(count).padStart(2)}/${String(jobs.length)}] Packed ${label}`) } } // 4. Summarize and write deterministic global entity index const monsterResults = results.slice(1) as MonsterResult[] const validMonsters = monsterResults.filter(m => !m.skipped) console.log(`Composited ${validMonsters.length} unique monster specs across Acts 1..5.`) console.log(`Global R8 entities: packed char-so.r8 + ${validMonsters.length} monster .r8 atlases into ${globalEntityDir}`) for (let act = 1; act <= 5; act += 1) { const actSpecs = ACT_MONSTER_SPECS[act] ?? [] let packedCount = 0 for (const spec of actSpecs) { const key = `${spec.token.toLowerCase()}-${spec.weapon.toLowerCase()}` if (validMonsters.some(m => m.key === key)) { packedCount += 1 } } const actEntityDir = join(outDir, `act${String(act)}`, 'entities') console.log(`Act ${act}: packed char-so + ${packedCount} monster atlases into ${actEntityDir}`) } // Write deterministic global entity index (preserving exact original order) await writeJsonAtomic( join(globalEntityDir, 'index.json'), { schema: 2, character: 'char-so', entities: monsterResults .filter(m => !m.skipped) .map(m => ({ key: m.key, token: m.token, weapon: m.weapon, file: m.file, format: m.format, blendMode: m.blendMode, width: m.width, height: m.height, directions: m.directions, })), }, ) const elapsedSec = ((performance.now() - startTime) / 1000).toFixed(2) console.log(`āœ“ All entity packs successfully generated in ${elapsedSec}s.`) } if (isMainThread) { 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) }) } } else { startWorker() }