/** * Offline Cast Overlay Asset & Data Baking Script. * * Extracts authentic Diablo II v1.13c cast overlay DCC sprites from d2data.mpq: * - FireCast_for_Sorceress.dcc (fire_cast_1, 14 frames, box: [-55, -127, 117, 164]) * - FireCast2.dcc (fire_cast_2, 16 frames, box: [-74, -89, 145, 133]) * - IceCastNew01.dcc (ice_cast_1, 15 frames, box: [-49, -90, 97, 55]) * - IceCastNew02.dcc (ice_cast_2, 15 frames, box: [-58, -100, 115, 123]) * - IceCastNew03.dcc (ice_cast_3, 16 frames, box: [-63, -113, 127, 148]) * - LightningCast.dcc (light_cast_1, 10 frames, box: [-79, -107, 164, 144]) * - LightningCastRunesFront.dcc (light_cast_2, 10 frames, box: [-73, -154, 147, 190]) * - Teleport.dcc (teleport, 18 frames, box: [-61, -108, 136, 154]) * * Bakes them into indexed-color PNG atlases and JSON metadata under: * - public/overlays/ * - samples/d2-packs/overlays/ * * And generates typed metadata in src/render/overlays-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 { decodeDcc } from '../src/formats/dcc.ts' import { decodePl2 } from '../src/formats/pl2.ts' import { packSheetToPng } from './pack-entity-assets.ts' import type { SpriteFrame, SpriteSheet } from '../src/formats/sprite.ts' export interface OverlayFrameMeta { readonly x: number readonly y: number readonly w: number readonly h: number readonly anchorX: number readonly anchorY: number } export type OverlayFrameTuple = readonly [ x: number, y: number, width: number, height: number, anchorX: number, anchorY: number, ] export interface OverlayBox { readonly left: number readonly top: number readonly width: number readonly height: number } export interface OverlayMeta { readonly name: string readonly celFile: string readonly width: number readonly height: number readonly directions: number readonly frames: number readonly framesPerDirection: number readonly animRate: number readonly trans: number readonly preDraw: boolean readonly lightRadius: number readonly lightColor: readonly [number, number, number] readonly box: OverlayBox readonly groups: readonly (readonly OverlayFrameTuple[])[] } export interface OverlayTarget { readonly name: string readonly celFile: string readonly aliases?: readonly string[] readonly animRate: number readonly trans: number readonly preDraw: boolean readonly lightRadius: number readonly lightColor: readonly [number, number, number] } export const OVERLAY_TARGETS: readonly OverlayTarget[] = [ { name: 'fire_cast_1', celFile: 'FireCast_for_Sorceress', animRate: 16, trans: 3, preDraw: false, lightRadius: 9, lightColor: [255, 178, 64] as const, }, { name: 'fire_cast_2', celFile: 'FireCast2', animRate: 16, trans: 3, preDraw: false, lightRadius: 9, lightColor: [255, 178, 64] as const, }, { name: 'ice_cast_1', celFile: 'IceCastNew01', animRate: 16, trans: 3, preDraw: false, lightRadius: 9, lightColor: [81, 81, 255] as const, }, { name: 'ice_cast_2', celFile: 'IceCastNew02', animRate: 16, trans: 3, preDraw: false, lightRadius: 9, lightColor: [81, 81, 255] as const, }, { name: 'ice_cast_3', celFile: 'IceCastNew03', animRate: 16, trans: 3, preDraw: false, lightRadius: 9, lightColor: [81, 81, 255] as const, }, { name: 'light_cast_1', celFile: 'LightningCast', animRate: 16, trans: 3, preDraw: false, lightRadius: 9, lightColor: [255, 255, 255] as const, }, { name: 'light_cast_2', celFile: 'LightningCastRunesFront', animRate: 16, trans: 3, preDraw: false, lightRadius: 9, lightColor: [255, 255, 255] as const, }, { name: 'teleport', celFile: 'Teleport', animRate: 16, trans: 3, preDraw: false, lightRadius: 5, lightColor: [255, 255, 200] as const, }, // 33 Authentic Diablo II v1.13c Paladin Aura Overlays (13 Pairs with Front & Back + 7 Full Rings) // 1. Might (#98) { name: 'might_front', celFile: 'AuraMightFront', aliases: ['might', 'aura_might_front', 'auramightfront'], animRate: 16, trans: 3, preDraw: false, lightRadius: 0, lightColor: [255, 255, 255] as const, }, { name: 'might_back', celFile: 'AuraMightBack', aliases: ['might_back', 'aura_might_back', 'auramightback'], animRate: 16, trans: 3, preDraw: true, lightRadius: 0, lightColor: [255, 255, 255] as const, }, // 2. Prayer (#99) { name: 'prayer_front', celFile: 'AuraPrayerFront', aliases: ['prayer', 'aura_prayer_front', 'auraprayerfront'], animRate: 8, trans: 3, preDraw: false, lightRadius: 0, lightColor: [255, 255, 255] as const, }, { name: 'prayer_back', celFile: 'AuraPrayerBack', aliases: ['prayer_back', 'aura_prayer_back', 'auraprayerback'], animRate: 8, trans: 3, preDraw: true, lightRadius: 0, lightColor: [255, 255, 255] as const, }, // 3. Resist Fire (#100) - Full Ring { name: 'resist_fire', celFile: 'AuraResistFireFront', aliases: ['aura_resistfire', 'AuraResistFireFront'], animRate: 16, trans: 3, preDraw: true, lightRadius: 6, lightColor: [255, 193, 103] as const, }, // 4. Holy Fire (#102) { name: 'holy_fire_front', celFile: 'AuraHolyFireFront', aliases: ['holy_fire', 'holyfire', 'aura_holyfire_front', 'auraholyfirefront'], animRate: 16, trans: 3, preDraw: false, lightRadius: 0, lightColor: [255, 255, 255] as const, }, { name: 'holy_fire_back', celFile: 'AuraHolyFireBack', aliases: ['holy_fire_back', 'holyfire_back', 'aura_holyfire_back', 'auraholyfireback'], animRate: 16, trans: 3, preDraw: true, lightRadius: 0, lightColor: [255, 255, 255] as const, }, // 5. Thorns (#103) { name: 'thorns_front', celFile: 'AuraThornsFront', aliases: ['thorns', 'aura_thorns_front', 'aurathornsfront'], animRate: 16, trans: 3, preDraw: false, lightRadius: 0, lightColor: [255, 255, 255] as const, }, { name: 'thorns_back', celFile: 'AuraThornsBack', aliases: ['thorns_back', 'aura_thorns_back', 'aurathornsback'], animRate: 16, trans: 3, preDraw: true, lightRadius: 0, lightColor: [255, 255, 255] as const, }, // 6. Defiance (#104) { name: 'defiance_front', celFile: 'AuraDefianceFront', aliases: ['defiance', 'aura_defiance_front', 'auradefiancefront'], animRate: 8, trans: 3, preDraw: false, lightRadius: 0, lightColor: [255, 255, 255] as const, }, { name: 'defiance_back', celFile: 'AuraDefianceBack', aliases: ['defiance_back', 'aura_defiance_back', 'auradefianceback'], animRate: 8, trans: 3, preDraw: true, lightRadius: 0, lightColor: [255, 255, 255] as const, }, // 7. Resist Cold (#105) - Full Ring { name: 'resist_cold', celFile: 'AuraResistColdBack', aliases: ['aura_resistcold', 'AuraResistColdBack'], animRate: 16, trans: 3, preDraw: true, lightRadius: 6, lightColor: [210, 210, 255] as const, }, // 8. Blessed Aim (#108) { name: 'blessed_aim_front', celFile: 'BlessedAim_front', aliases: ['blessed_aim', 'blessedaim', 'blessedaimfront', 'BlessedAim_front'], animRate: 8, trans: 3, preDraw: false, lightRadius: 0, lightColor: [255, 255, 255] as const, }, { name: 'blessed_aim_back', celFile: 'BlessedAim_back', aliases: ['blessed_aim_back', 'blessedaimback', 'BlessedAim_back'], animRate: 8, trans: 3, preDraw: true, lightRadius: 0, lightColor: [255, 255, 255] as const, }, // 9. Cleansing (#109) { name: 'cleansing_front', celFile: 'Cleansing_front', aliases: ['cleansing', 'cleansingfront', 'Cleansing_front'], animRate: 8, trans: 3, preDraw: false, lightRadius: 0, lightColor: [255, 255, 255] as const, }, { name: 'cleansing_back', celFile: 'Cleansing_back', aliases: ['cleansing_back', 'cleansingback', 'Cleansing_back'], animRate: 8, trans: 3, preDraw: true, lightRadius: 0, lightColor: [255, 255, 255] as const, }, // 10. Resist Lightning (#110) - Full Ring { name: 'resist_lightning', celFile: 'AuraResistLightningBack', aliases: ['aura_resistlight', 'AuraResistLightningBack'], animRate: 16, trans: 3, preDraw: true, lightRadius: 6, lightColor: [255, 255, 200] as const, }, // 11. Concentration (#113) { name: 'concentration_front', celFile: 'Concentration_front', aliases: ['concentration', 'concentrationfront', 'Concentration_front'], animRate: 3, trans: 3, preDraw: false, lightRadius: 0, lightColor: [255, 255, 255] as const, }, { name: 'concentration_back', celFile: 'Concentration_back', aliases: ['concentration_back', 'concentrationback', 'Concentration_back'], animRate: 3, trans: 3, preDraw: true, lightRadius: 0, lightColor: [255, 255, 255] as const, }, // 12. Holy Freeze (#114) - Full Ring { name: 'holy_freeze', celFile: 'HolyFreeze', aliases: ['holyfreeze', 'holywind', 'HolyFreeze'], animRate: 8, trans: 3, preDraw: true, lightRadius: 0, lightColor: [255, 255, 255] as const, }, // 13. Vigor (#115) { name: 'vigor_front', celFile: 'Stamina_front', aliases: ['vigor', 'stamina', 'staminafront', 'Stamina_front'], animRate: 8, trans: 3, preDraw: false, lightRadius: 0, lightColor: [255, 255, 255] as const, }, { name: 'vigor_back', celFile: 'Stamina_back', aliases: ['vigor_back', 'staminaback', 'Stamina_back'], animRate: 8, trans: 3, preDraw: true, lightRadius: 0, lightColor: [255, 255, 255] as const, }, // 14. Holy Shock (#118) { name: 'holy_shock_front', celFile: 'HolyShock_front', aliases: ['holy_shock', 'holyshock', 'holyshockfront', 'HolyShock_front'], animRate: 16, trans: 3, preDraw: false, lightRadius: 0, lightColor: [255, 255, 255] as const, }, { name: 'holy_shock_back', celFile: 'HolyShock_back', aliases: ['holy_shock_back', 'holyshockback', 'HolyShock_back'], animRate: 16, trans: 3, preDraw: true, lightRadius: 0, lightColor: [255, 255, 255] as const, }, // 15. Sanctuary (#119) { name: 'sanctuary_front', celFile: 'SanctuaryAura_Front', aliases: ['sanctuary', 'sanctuaryfront', 'SanctuaryAura_Front'], animRate: 16, trans: 3, preDraw: true, lightRadius: 0, lightColor: [255, 255, 255] as const, }, { name: 'sanctuary_back', celFile: 'SanctuaryAura_Back', aliases: ['sanctuary_back', 'sanctuaryback', 'SanctuaryAura_Back'], animRate: 16, trans: 3, preDraw: true, lightRadius: 0, lightColor: [255, 255, 255] as const, }, // 16. Meditation (#120) - Full Ring { name: 'meditation', celFile: 'Meditation', aliases: ['meditationfront', 'Meditation'], animRate: 16, trans: 3, preDraw: false, lightRadius: 0, lightColor: [255, 255, 255] as const, }, // 17. Fanaticism (#122) { name: 'fanaticism_front', celFile: 'Fanatacism_Front', aliases: ['fanaticism_front', 'fanaticismfront', 'Fanatacism_Front'], animRate: 6, trans: 3, preDraw: false, lightRadius: 0, lightColor: [255, 255, 255] as const, }, { name: 'fanaticism_back', celFile: 'Fanatacism_Back', aliases: ['fanaticism', 'fanaticismback', 'aura_fanatic', 'Fanatacism_Back'], animRate: 6, trans: 3, preDraw: true, lightRadius: 0, lightColor: [255, 255, 255] as const, }, // 18. Conviction (#123) - Full Ring { name: 'conviction', celFile: 'Conviction', aliases: ['convictionback', 'Conviction'], animRate: 6, trans: 3, preDraw: true, lightRadius: 0, lightColor: [255, 255, 255] as const, }, // 19. Redemption (#124) - Full Ring { name: 'redemption', celFile: 'Redemption', aliases: ['redemptionback', 'Redemption'], animRate: 16, trans: 3, preDraw: true, lightRadius: 0, lightColor: [255, 255, 255] as const, }, // 20. Salvation (#125) { name: 'salvation_front', celFile: 'AuraResistAllFront', aliases: ['salvation', 'aura_resistall_front', 'AuraResistAllFront'], animRate: 16, trans: 3, preDraw: false, lightRadius: 6, lightColor: [211, 148, 255] as const, }, { name: 'salvation_back', celFile: 'AuraResistAllBack', aliases: ['salvation_back', 'aura_resistall_back', 'AuraResistAllBack'], animRate: 16, trans: 3, preDraw: true, lightRadius: 6, lightColor: [211, 148, 255] as const, }, ] as const export async function bakeOverlays(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 publicOutDir = join(projectRoot, 'public', 'overlays') const packOutDir = join(projectRoot, 'samples', 'd2-packs', 'overlays') mkdirSync(publicOutDir, { recursive: true }) mkdirSync(packOutDir, { recursive: true }) const metaRecord: Record = {} for (const target of OVERLAY_TARGETS) { const f = mpq.find(`data/global/overlays/${target.celFile}.dcc`) if (!f) throw new Error(`Overlay DCC not found: data/global/overlays/${target.celFile}.dcc`) const dcc = decodeDcc(await mpq.read(f)) const firstDir = dcc.directions[0] if (!firstDir) throw new Error(`Overlay DCC has no directions: ${target.celFile}`) const box: OverlayBox = { left: firstDir.box.left, top: firstDir.box.top, width: firstDir.box.width, height: firstDir.box.height, } const groups = dcc.directions.map(dir => ({ frames: dir.frames.map( (frame): SpriteFrame => ({ width: frame.frame.width, height: frame.frame.height, indices: frame.frame.indices, mask: frame.frame.mask, anchorX: dir.box.left, anchorY: dir.box.top, }), ), })) const sheet: SpriteSheet = { groups, width: null } const packed = packSheetToPng(sheet, pl2.rgb) const meta: OverlayMeta = { name: target.name, celFile: target.celFile, width: packed.width, height: packed.height, directions: dcc.directions.length, frames: firstDir.frames.length, framesPerDirection: firstDir.frames.length, animRate: target.animRate, trans: target.trans, preDraw: target.preDraw, lightRadius: target.lightRadius, lightColor: target.lightColor, box, groups: packed.groups, } metaRecord[target.name] = meta metaRecord[target.celFile] = meta if (target.celFile.toLowerCase() !== target.celFile) { metaRecord[target.celFile.toLowerCase()] = meta } const allKeys = new Set([target.name, target.celFile, ...(target.aliases ?? [])]) for (const alias of target.aliases ?? []) { metaRecord[alias] = meta } const jsonStr = JSON.stringify(meta, null, 2) // Write primary name and aliases to both public/overlays/ and samples/d2-packs/overlays/ for (const fileKey of allKeys) { writeFileSync(join(publicOutDir, `${fileKey}.png`), packed.png) writeFileSync(join(publicOutDir, `${fileKey}.json`), jsonStr, 'utf-8') writeFileSync(join(packOutDir, `${fileKey}.png`), packed.png) writeFileSync(join(packOutDir, `${fileKey}.json`), jsonStr, 'utf-8') } console.log( `[Overlay Packer] Baked ${target.name} (${target.celFile}.dcc): ` + `${meta.frames} frames, box [${box.left}, ${box.top}, ${box.width}, ${box.height}], ` + `${packed.width}x${packed.height}, ${packed.png.byteLength} bytes PNG`, ) } const TARGET_CONST_NAMES: Record = { fire_cast_1: 'FIRE_CAST_1_META', fire_cast_2: 'FIRE_CAST_2_META', ice_cast_1: 'ICE_CAST_1_META', ice_cast_2: 'ICE_CAST_2_META', ice_cast_3: 'ICE_CAST_3_META', light_cast_1: 'LIGHT_CAST_1_META', light_cast_2: 'LIGHT_CAST_2_META', teleport: 'TELEPORT_META', might_front: 'MIGHT_FRONT_AURA_META', might_back: 'MIGHT_BACK_AURA_META', prayer_front: 'PRAYER_FRONT_AURA_META', prayer_back: 'PRAYER_BACK_AURA_META', resist_fire: 'RESIST_FIRE_AURA_META', holy_fire_front: 'HOLY_FIRE_FRONT_AURA_META', holy_fire_back: 'HOLY_FIRE_BACK_AURA_META', thorns_front: 'THORNS_FRONT_AURA_META', thorns_back: 'THORNS_BACK_AURA_META', defiance_front: 'DEFIANCE_FRONT_AURA_META', defiance_back: 'DEFIANCE_BACK_AURA_META', resist_cold: 'RESIST_COLD_AURA_META', blessed_aim_front: 'BLESSED_AIM_FRONT_AURA_META', blessed_aim_back: 'BLESSED_AIM_BACK_AURA_META', cleansing_front: 'CLEANSING_FRONT_AURA_META', cleansing_back: 'CLEANSING_BACK_AURA_META', resist_lightning: 'RESIST_LIGHTNING_AURA_META', concentration_front: 'CONCENTRATION_FRONT_AURA_META', concentration_back: 'CONCENTRATION_BACK_AURA_META', holy_freeze: 'HOLY_FREEZE_AURA_META', vigor_front: 'VIGOR_FRONT_AURA_META', vigor_back: 'VIGOR_BACK_AURA_META', holy_shock_front: 'HOLY_SHOCK_FRONT_AURA_META', holy_shock_back: 'HOLY_SHOCK_BACK_AURA_META', sanctuary_front: 'SANCTUARY_FRONT_AURA_META', sanctuary_back: 'SANCTUARY_BACK_AURA_META', meditation: 'MEDITATION_AURA_META', fanaticism_front: 'FANATICISM_FRONT_AURA_META', fanaticism_back: 'FANATICISM_BACK_AURA_META', conviction: 'CONVICTION_AURA_META', redemption: 'REDEMPTION_AURA_META', salvation_front: 'SALVATION_FRONT_AURA_META', salvation_back: 'SALVATION_BACK_AURA_META', } const individualConsts = OVERLAY_TARGETS.map(t => { const constName = TARGET_CONST_NAMES[t.name] ?? `${t.name.toUpperCase()}_META` return `export const ${constName}: OverlayMeta = ${JSON.stringify(metaRecord[t.name], null, 2)}` }).join('\n\n') const compatAliases = ` // Backward compatibility aliases for primary aura constants export const MIGHT_AURA_META: OverlayMeta = MIGHT_FRONT_AURA_META export const PRAYER_AURA_META: OverlayMeta = PRAYER_FRONT_AURA_META export const HOLY_FIRE_AURA_META: OverlayMeta = HOLY_FIRE_FRONT_AURA_META export const THORNS_AURA_META: OverlayMeta = THORNS_FRONT_AURA_META export const DEFIANCE_AURA_META: OverlayMeta = DEFIANCE_FRONT_AURA_META export const BLESSED_AIM_AURA_META: OverlayMeta = BLESSED_AIM_FRONT_AURA_META export const CLEANSING_AURA_META: OverlayMeta = CLEANSING_FRONT_AURA_META export const CONCENTRATION_AURA_META: OverlayMeta = CONCENTRATION_FRONT_AURA_META export const VIGOR_AURA_META: OverlayMeta = VIGOR_FRONT_AURA_META export const HOLY_SHOCK_AURA_META: OverlayMeta = HOLY_SHOCK_FRONT_AURA_META export const SANCTUARY_AURA_META: OverlayMeta = SANCTUARY_FRONT_AURA_META export const FANATICISM_AURA_META: OverlayMeta = FANATICISM_BACK_AURA_META export const SALVATION_AURA_META: OverlayMeta = SALVATION_FRONT_AURA_META ` const overlayMetasEntries: string[] = [] const seenKeys = new Set() for (const t of OVERLAY_TARGETS) { const constName = TARGET_CONST_NAMES[t.name] ?? `${t.name.toUpperCase()}_META` const keys = new Set([t.name, t.celFile, ...(t.aliases ?? [])]) for (const k of keys) { if (!seenKeys.has(k)) { seenKeys.add(k) overlayMetasEntries.push(` ${JSON.stringify(k)}: ${constName},`) } const lower = k.toLowerCase() if (!seenKeys.has(lower)) { seenKeys.add(lower) overlayMetasEntries.push(` ${JSON.stringify(lower)}: ${constName},`) } } } // Generate src/render/overlays-meta.ts const metaTsContent = `/** * Authentic Diablo II v1.13c Cast & Aura Overlay Atlas Metadata. * Auto-generated by scripts/pack-overlays.ts. * Enforces Zero Runtime MPQ/DLL Architectural Invariant. */ export interface OverlayFrameMeta { readonly x: number readonly y: number readonly w: number readonly h: number readonly anchorX: number readonly anchorY: number } export type OverlayFrameTuple = readonly [ x: number, y: number, width: number, height: number, anchorX: number, anchorY: number, ] export interface OverlayBox { readonly left: number readonly top: number readonly width: number readonly height: number } export interface OverlayMeta { readonly name: string readonly celFile: string readonly width: number readonly height: number readonly directions: number readonly frames: number readonly framesPerDirection: number readonly animRate: number readonly trans: number readonly preDraw: boolean readonly lightRadius: number readonly lightColor: readonly [number, number, number] readonly box: OverlayBox readonly groups: readonly (readonly OverlayFrameTuple[])[] } ${individualConsts} ${compatAliases} export const ALL_OVERLAY_NAMES: readonly string[] = [ ${OVERLAY_TARGETS.map(t => ` ${JSON.stringify(t.name)},`).join('\n')} ] export const OVERLAY_METAS: Readonly> = { ${overlayMetasEntries.join('\n')} } ` const metaTsPath = join(projectRoot, 'src', 'render', 'overlays-meta.ts') writeFileSync(metaTsPath, metaTsContent, 'utf-8') console.log(`[Overlay Packer] Wrote ${metaTsPath}`) return metaRecord } // Execute standalone if called from CLI if (process.argv[1]?.endsWith('pack-overlays.ts')) { bakeOverlays().catch((err: unknown) => { console.error('Bake overlays failed:', err) process.exit(1) }) }