diablo2-web/scripts/pack-char-so-layers.ts

217 lines
7.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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\<comp>\so<comp><variant><mode><wclass>.dcc` (1,669 DCC files across 77 `<comp>-<variant>` 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<Record<number, string>> = {
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<string, [number, number]>
readonly palettes: Record<number, [number, number]>
readonly animClips: Record<string, { speed: number; events: [number, number][] }>
readonly layers: Record<
string,
{
readonly file: string
readonly dccs: Record<string, [number, number]>
}
>
}
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)
}
export async function packSorceressLayers(
archiveDir = 'samples/d2',
outDir = 'samples/d2-packs',
): Promise<SorceressLayerPackManifest> {
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<string, { frames: number; speed: number; events?: [number, number][] }>
}
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<string, { modeWclass: string; path: string }[]>()
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\<comp>\so<comp><lit><mode><wclass>.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<string, [number, number]> = {}
const animClips: Record<string, { speed: number; events: [number, number][] }> = {}
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<number, [number, number]> = {}
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 <comp>-<variant>.bin
const sortedLayerKeys = [...dccByCompVariant.keys()].sort()
const layers: Record<string, { file: string; dccs: Record<string, [number, number]> }> = {}
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<string, [number, number]> = {}
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)
})
}