diablo2-web/scripts/pack-entity-assets.ts

914 lines
30 KiB
TypeScript

/**
* 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<N>/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<void> {
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<void> {
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<number, Uint8Array>,
) {}
static async create(archiveDir: string, outDir: string): Promise<EntityWorkerContext> {
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<number, Uint8Array> = {}
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<CharResult> {
const { archives, animData, palettes } = ctx
const { outDir, jobId } = job
const globalEntityDir = join(outDir, 'entities')
const charSheets: CharacterSheet[] = []
const charClips: Record<string, {
group: number
directions: number
frames: number
speed: number
loop: boolean
events: { f: number; c: number }[]
}> = {}
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,
}
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<MonsterResult> {
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<string, {
group: number
directions: number
frames: number
speed: number
loop: boolean
events: { f: number; c: number }[]
}> = {}
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<string, {
group: number
directions: number
frames: number
speed: number
loop: boolean
events: { f: number; c: number }[]
}> = {}
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<EntityResult> {
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<EntityResult[]> {
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<EntityWorkerContext> | 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<void> {
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<string, { token: string; weapon: string }>()
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<string>()
for (const landmark of CANONICAL_SUPER_UNIQUE_LANDMARKS) {
if (landmark.minionMonsterId) {
landmarkMinionIds.add(landmark.minionMonsterId)
}
}
const allSuperUniqueMonsterIds = new Set<string>()
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<string, Set<string>>()
for (const [key, spec] of uniqueSpecs) {
const modes = new Set<string>()
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<string, number[]>()
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[] = []
jobs.push({
type: 'char',
jobId: 0,
archiveDir,
outDir,
})
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()
}