/** * scripts/pack-cursor.ts * * Extracts authentic Diablo II v1.13c cursor DC6 assets from d2data.mpq: * - protate.dc6 (8 frames: pointing hand swaying animation) * - ohand.dc6 (8 frames: finger-to-palm opening transition) * - orotate.dc6 (8 frames: open palm swaying idle animation) * * Packs all 24 frames into public/ui/cursor.png with metadata in src/ui/cursor-meta.ts. * Enforces Zero Runtime MPQ/DLL Architectural Invariant. */ import { mkdirSync, writeFileSync } from 'node:fs' import { join } from 'node:path' import { MpqArchive } from '../src/mpq/archive.ts' import { fileSource } from '../src/mpq/file-source.ts' import { decodeDc6, type Dc6Frame } from '../src/formats/dc6.ts' import { decodePl2 } from '../src/formats/pl2.ts' import { encodeIndexedPng } from './png.ts' export interface CursorFrameMeta { readonly x: number readonly y: number readonly w: number readonly h: number readonly offsetX: number readonly offsetY: number readonly hotspotX: number readonly hotspotY: number } export interface CursorAtlasMetadata { readonly atlasWidth: number readonly atlasHeight: number readonly frameWidth: number readonly frameHeight: number readonly protate: readonly CursorFrameMeta[] readonly ohand: readonly CursorFrameMeta[] readonly orotate: readonly CursorFrameMeta[] readonly frames: Record } export async function packCursorAssets(projectRoot: string = process.cwd()): Promise { const mpqPath = join(projectRoot, 'samples', 'd2', 'd2data.mpq') const mpq = await MpqArchive.open(await fileSource(mpqPath)) const palFile = mpq.find('data/global/palette/ACT1/pal.pl2') if (!palFile) throw new Error('data/global/palette/ACT1/pal.pl2 not found in d2data.mpq') const pl2 = decodePl2(await mpq.read(palFile)) const animNames = ['protate', 'ohand', 'orotate'] as const const decodedSheets: Record = {} for (const name of animNames) { const file = mpq.find(`data/global/ui/CURSOR/${name}.dc6`) if (!file) throw new Error(`Cursor DC6 not found: data/global/ui/CURSOR/${name}.dc6`) const sheet = decodeDc6(await mpq.read(file)) const frames = sheet.groups[0]?.frames ?? [] if (frames.length !== 8) { throw new Error(`Expected 8 frames in ${name}.dc6, got ${frames.length}`) } decodedSheets[name] = frames } // Layout: 3 rows (protate, ohand, orotate), 8 columns const CELL_W = 32 const CELL_H = 32 const COLS = 8 const ROWS = 3 const atlasWidth = CELL_W * COLS // 256 const atlasHeight = CELL_H * ROWS // 96 const pixels = new Uint8Array(atlasWidth * atlasHeight) const metaLists: Record = { protate: [], ohand: [], orotate: [], } const allFrames: Record = {} animNames.forEach((anim, row) => { const frames = decodedSheets[anim]! frames.forEach((frame, col) => { const dstX = col * CELL_W const dstY = row * CELL_H // Blit frame pixels into atlas for (let sy = 0; sy < frame.height; sy++) { const ty = dstY + sy for (let sx = 0; sx < frame.width; sx++) { const tx = dstX + sx const srcIdx = sy * frame.width + sx if (frame.mask[srcIdx] !== 0) { const color = frame.indices[srcIdx] ?? 0 if (color !== 0) { pixels[ty * atlasWidth + tx] = color } } } } // Hotspot calculation: // In D2 DC6 coordinates, pointing finger tip is at col 0, row 0 of frame 0. // For protate (offsetX = -1), the tip is at (0, 0). // When palm spreads in ohand/orotate (offsetX = -2), the anchor shifts left 1px. const hotspotX = anim === 'protate' ? 0 : Math.max(0, -frame.offsetX - 1) const hotspotY = 0 const frameMeta: CursorFrameMeta = { x: dstX, y: dstY, w: frame.width, h: frame.height, offsetX: frame.offsetX, offsetY: frame.offsetY, hotspotX, hotspotY, } metaLists[anim]!.push(frameMeta) allFrames[`${anim}_${col}`] = frameMeta }) }) const atlasMeta: CursorAtlasMetadata = { atlasWidth, atlasHeight, frameWidth: CELL_W, frameHeight: CELL_H, protate: metaLists.protate!, ohand: metaLists.ohand!, orotate: metaLists.orotate!, frames: allFrames, } // Save public/ui/cursor.png const outDir = join(projectRoot, 'public', 'ui') mkdirSync(outDir, { recursive: true }) const pngData = encodeIndexedPng({ width: atlasWidth, height: atlasHeight, pixels, palette: pl2.rgb, transparentIndex: 0, }) writeFileSync(join(outDir, 'cursor.png'), pngData) // Save src/ui/cursor-meta.ts const metaCode = `/** * Authentic Diablo II v1.13c Cursor Atlas Metadata. * Auto-generated by scripts/pack-cursor.ts. * Enforces Zero Runtime MPQ/DLL Architectural Invariant. */ export interface CursorFrameMeta { readonly x: number readonly y: number readonly w: number readonly h: number readonly offsetX: number readonly offsetY: number readonly hotspotX: number readonly hotspotY: number } export interface CursorAtlasMetadata { readonly atlasWidth: number readonly atlasHeight: number readonly frameWidth: number readonly frameHeight: number readonly protate: readonly CursorFrameMeta[] readonly ohand: readonly CursorFrameMeta[] readonly orotate: readonly CursorFrameMeta[] readonly frames: Record } export const CURSOR_METADATA: CursorAtlasMetadata = ${JSON.stringify(atlasMeta, null, 2)} as const ` writeFileSync(join(projectRoot, 'src', 'ui', 'cursor-meta.ts'), metaCode, 'utf8') console.log(`Extracted 24 authentic cursor frames -> public/ui/cursor.png (${atlasWidth}x${atlasHeight})`) console.log('Saved metadata to src/ui/cursor-meta.ts') return atlasMeta } if (process.argv[1]?.endsWith('pack-cursor.ts')) { packCursorAssets() .then(() => process.exit(0)) .catch((err) => { console.error('pack-cursor failed:', err) process.exit(1) }) }