/** * Bake Sorceress (`so`) modular paperdoll DCC layers, COF descriptors, AnimData clips, * and `Palette/*.dat` color shift tables into `samples/d2-packs/chars/so/`. * * Ground Truth (Diablo II v1.13c): * - `data\global\chars\so\cof\*.cof` (94 COF files) * - `data\global\chars\so\\so.dcc` (1,669 DCC files across 77 `-` pairs) * - `data\global\items\Palette\*.dat` (8 palette shift tables, each 21 colors × 256 bytes = 5,376 bytes) * * This offline pack script eliminates any runtime dependency on MPQ archives while allowing * instant on-demand loading and caching of paperdoll component variants and dye transforms. */ import { mkdir, writeFile, rename } 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' const MOUNTS = ['d2char.mpq', 'd2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq'] as const /** * 1.13c `D2CMP.dll` `D2Comp.cpp` palette shift `.dat` files indexed by `Transform` (1..8). */ export const PALETTE_SHIFT_FILES: Readonly> = { 1: 'data\\global\\items\\Palette\\grey.dat', 2: 'data\\global\\items\\Palette\\grey2.dat', 3: 'data\\global\\items\\Palette\\gold.dat', 4: 'data\\global\\items\\Palette\\brown.dat', 5: 'data\\global\\items\\Palette\\greybrown.dat', 6: 'data\\global\\items\\Palette\\invgrey.dat', 7: 'data\\global\\items\\Palette\\invgrey2.dat', 8: 'data\\global\\items\\Palette\\invgreybrown.dat', } export interface SorceressLayerPackManifest { readonly schema: 1 readonly metaFile: string readonly cofs: Record readonly palettes: Record readonly animClips: Record readonly layers: Record< string, { readonly file: string readonly dccs: Record } > } 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) } export async function packSorceressLayers( archiveDir = 'samples/d2', outDir = 'samples/d2-packs', ): Promise { const archives = new MountedArchives() for (const name of MOUNTS) { const fullPath = join(archiveDir, name) if (!existsSync(fullPath)) { throw new Error(`Required MPQ archive missing: ${fullPath}`) } const archive = await MpqArchive.open(await fileSource(fullPath)) archives.add(name, archive) } const animDataPath = join(outDir, 'anim', 'animdata.json') if (!existsSync(animDataPath)) { throw new Error(`Missing ${animDataPath}. Run 'npm run pack:animdata' first.`) } const animData = JSON.parse(readFileSync(animDataPath, 'utf8')) as { clips: Record } const soOutDir = join(outDir, 'chars', 'so') const layersOutDir = join(soOutDir, 'layers') await mkdir(layersOutDir, { recursive: true }) // 1. Collect all members under data\global\chars\so\ const allMembers = await archives.listFiles() const cofPaths: string[] = [] const dccByCompVariant = new Map() for (const member of allMembers) { const norm = member.replace(/\//g, '\\') const lower = norm.toLowerCase() if (!lower.startsWith('data\\global\\chars\\so\\')) continue if (lower.startsWith('data\\global\\chars\\so\\cof\\') && lower.endsWith('.cof')) { cofPaths.push(norm) continue } if (lower.endsWith('.dcc')) { // Format: data\global\chars\so\\so.dcc const parts = lower.split('\\') if (parts.length !== 6) continue const comp = parts[4]! const fileName = parts[5]! const baseName = fileName.slice(0, -4) // strip .dcc // baseName is e.g. "sohdcapnu1hs" (2 + 2 + 3 + 2 + 3 = 12 chars) if (!baseName.startsWith(`so${comp}`) || baseName.length !== 12) continue const variant = baseName.slice(4, 7) const modeWclass = baseName.slice(7, 12).toUpperCase() const key = `${comp}-${variant}` const list = dccByCompVariant.get(key) ?? [] list.push({ modeWclass, path: norm }) dccByCompVariant.set(key, list) } } cofPaths.sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase())) // 2. Pack meta.bin (COFs + Palette shift .dat files) const metaChunks: Uint8Array[] = [] let metaOffset = 0 const cofs: Record = {} const animClips: Record = {} for (const cofPath of cofPaths) { const fileName = cofPath.split('\\').pop()! const cofToken = fileName.slice(0, -4).toUpperCase() // e.g. SONUHTH const bytes = await archives.read(cofPath) cofs[cofToken] = [metaOffset, bytes.byteLength] metaChunks.push(bytes) metaOffset += bytes.byteLength const clipRec = animData.clips[cofToken] animClips[cofToken] = { speed: clipRec?.speed ?? 256, events: clipRec?.events ?? [], } } const palettes: Record = {} for (let transIdx = 1; transIdx <= 8; transIdx += 1) { const palPath = PALETTE_SHIFT_FILES[transIdx]! const bytes = await archives.read(palPath) if (bytes.byteLength !== 21 * 256) { throw new Error(`Unexpected palette shift size for ${palPath}: ${bytes.byteLength} (expected 5376)`) } palettes[transIdx] = [metaOffset, bytes.byteLength] metaChunks.push(bytes) metaOffset += bytes.byteLength } const metaBin = new Uint8Array(metaOffset) let cursor = 0 for (const chunk of metaChunks) { metaBin.set(chunk, cursor) cursor += chunk.byteLength } await writeFileAtomic(join(soOutDir, 'meta.bin'), metaBin) // 3. Pack each -.bin const sortedLayerKeys = [...dccByCompVariant.keys()].sort() const layers: Record }> = {} let totalDccCount = 0 let totalDccBytes = 0 for (const key of sortedLayerKeys) { const entries = dccByCompVariant.get(key)! entries.sort((a, b) => a.modeWclass.localeCompare(b.modeWclass)) const chunks: Uint8Array[] = [] let offset = 0 const dccs: Record = {} for (const entry of entries) { const bytes = await archives.read(entry.path) dccs[entry.modeWclass] = [offset, bytes.byteLength] chunks.push(bytes) offset += bytes.byteLength totalDccCount += 1 } const layerBin = new Uint8Array(offset) let pos = 0 for (const chunk of chunks) { layerBin.set(chunk, pos) pos += chunk.byteLength } const relFile = `layers/${key}.bin` await writeFileAtomic(join(soOutDir, relFile), layerBin) layers[key] = { file: relFile, dccs } totalDccBytes += offset } const manifest: SorceressLayerPackManifest = { schema: 1, metaFile: 'meta.bin', cofs, palettes, animClips, layers, } await writeFileAtomic(join(soOutDir, 'manifest.json'), JSON.stringify(manifest)) console.log( `Packed Sorceress paperdoll layers: ${Object.keys(cofs).length} COFs, 8 Palette tables (${(metaOffset / 1024).toFixed(1)} KB meta.bin), ${sortedLayerKeys.length} layer packs (${totalDccCount} DCCs, ${(totalDccBytes / 1024 / 1024).toFixed(2)} MB)`, ) return manifest } if (import.meta.url === `file://${process.argv[1]}`) { packSorceressLayers().catch((err) => { console.error(err) process.exit(1) }) }