/** * scripts/pack-ui.ts * * Complete Offline UI, Item Sprite, and Quest Icon Packer. * Extracts DC6/PL2 assets from 1.13c MPQs and generates: * 1. public/ui/*.png (all panels, globes, borders, fonts, items-atlas, quests-atlas) * 2. public/ui/manifest.json * 3. src/ui/baked-ui-meta.ts */ 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 { MountedArchives } from '../src/mpq/mount.ts' import { decodeDc6 } from '../src/formats/dc6.ts' import type { SpriteFrame, SpriteSheet } from '../src/formats/sprite.ts' import { decodePl2 } from '../src/formats/pl2.ts' import { parseTable } from '../src/game/tables.ts' import { encodeIndexedPng } from './png.ts' import { packCursorAssets } from './pack-cursor.ts' interface BlitPlacement { readonly frame: SpriteFrame readonly x: number readonly y: number } export interface SpriteRect { readonly x: number readonly y: number readonly w: number readonly h: number } export interface FontGlyphMetric { readonly code: number readonly width: number readonly height: number readonly x: number readonly y: number readonly frameW: number readonly frameH: number } export interface FontAtlasMeta { readonly name: string readonly lineHeight: number readonly capHeight: number readonly atlasWidth: number readonly atlasHeight: number readonly glyphs: readonly FontGlyphMetric[] } /** * Create an indexed pixel buffer and blit multiple DC6 frames onto it. * Index 0 is transparent in DC6 UI sprites. */ function stitchFrames(width: number, height: number, placements: readonly BlitPlacement[]): Uint8Array { const out = new Uint8Array(width * height) for (const { frame, x: dstX, y: dstY } of placements) { for (let sy = 0; sy < frame.height; sy++) { const ty = dstY + sy if (ty < 0 || ty >= height) continue for (let sx = 0; sx < frame.width; sx++) { const tx = dstX + sx if (tx < 0 || tx >= width) continue const srcIdx = sy * frame.width + sx if (frame.mask[srcIdx] === 0) continue const color = frame.indices[srcIdx] ?? 0 if (color !== 0) { out[ty * width + tx] = color } } } } return out } /** * Apply a 256-byte PL2 color transformation table to a sprite frame. * Opaque pixels are mapped through table[orig]. * Black dye guard: If mapped === 0, remap to index 172 (RGB [4, 4, 4]), * the darkest non-zero index in the D2 palette, ensuring opaque black pixels * are not rendered as transparent in indexed PNG (where index 0 is transparent). * Transparent pixels (mask === 0 or orig === 0) remain index 0. */ function transformSpriteFrame(frame: SpriteFrame, table: Uint8Array): SpriteFrame { const indices = new Uint8Array(frame.indices.length) for (let i = 0; i < frame.indices.length; i++) { if (frame.mask[i] !== 0 && frame.indices[i] !== 0) { const orig = frame.indices[i]! let mapped = table[orig] ?? 0 if (mapped === 0) { mapped = 172 } indices[i] = mapped } else { indices[i] = 0 } } return { width: frame.width, height: frame.height, indices, mask: frame.mask, } } /** * Stitch a standard 2x2 320x432 Diablo II panel from 4 consecutive DC6 frames: * Frame +0: 256x256 (Top-Left) * Frame +1: 64x256 (Top-Right) * Frame +2: 256x176 (Bottom-Left) * Frame +3: 64x176 (Bottom-Right) */ function stitch320x432Panel(sheet: SpriteSheet, startFrame = 0): Uint8Array { const frames = sheet.groups[0]!.frames return stitchFrames(320, 432, [ { frame: frames[startFrame + 0]!, x: 0, y: 0 }, { frame: frames[startFrame + 1]!, x: 256, y: 0 }, { frame: frames[startFrame + 2]!, x: 0, y: 256 }, { frame: frames[startFrame + 3]!, x: 256, y: 256 }, ]) } /** * Stitch a horizontal strip of frames into a single sprite sheet. */ function stitchHorizontalStrip(frames: readonly SpriteFrame[]): { width: number height: number pixels: Uint8Array rects: SpriteRect[] } { const height = Math.max(1, ...frames.map(f => f.height)) let totalW = 0 const rects: SpriteRect[] = [] const placements: BlitPlacement[] = [] for (const frame of frames) { rects.push({ x: totalW, y: 0, w: frame.width, h: frame.height }) placements.push({ frame, x: totalW, y: 0 }) totalW += frame.width } return { width: Math.max(1, totalW), height, pixels: stitchFrames(Math.max(1, totalW), height, placements), rects, } } /** * Parse a `Woo!\x01` font metric table (`data/local/font/latin/font*.tbl`) * and pack its 256 DC6 glyphs into a 16x16 grid PNG. */ function buildFontAtlas(name: string, dc6: SpriteSheet, tblBytes: Uint8Array): { meta: FontAtlasMeta pixels: Uint8Array } { const magic = new TextDecoder().decode(tblBytes.slice(0, 4)) if (magic !== 'Woo!') { throw new Error(`Invalid font tbl header for ${name}: ${magic}`) } const lineHeight = tblBytes[10] || 14 const capHeight = tblBytes[11] || 16 const frames = dc6.groups[0]!.frames const cellW = Math.max(16, ...frames.map(f => f.width)) const cellH = Math.max(16, ...frames.map(f => f.height)) const atlasWidth = cellW * 16 const atlasHeight = cellH * 16 const placements: BlitPlacement[] = [] const glyphs: FontGlyphMetric[] = [] for (let i = 0; i < 256; i++) { const recOff = 12 + i * 14 const metricWidth = recOff + 3 < tblBytes.length ? (tblBytes[recOff + 3] || 6) : 6 const metricHeight = recOff + 4 < tblBytes.length ? (tblBytes[recOff + 4] || lineHeight) : lineHeight const frameIdx = recOff + 8 < tblBytes.length ? (tblBytes[recOff + 8] ?? i) : i const frame = frames[frameIdx] ?? frames[i] const gx = (i % 16) * cellW const gy = Math.floor(i / 16) * cellH if (frame) { placements.push({ frame, x: gx, y: gy }) } glyphs.push({ code: i, width: metricWidth, height: metricHeight, x: gx, y: gy, frameW: frame?.width ?? 0, frameH: frame?.height ?? 0, }) } return { meta: { name, lineHeight, capHeight, atlasWidth, atlasHeight, glyphs, }, pixels: stitchFrames(atlasWidth, atlasHeight, placements), } } async function main(): Promise { console.log('Mounting Diablo II v1.13c MPQ archives...') const archives = new MountedArchives() for (const m of ['d2char.mpq', 'd2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq']) { archives.add(m, await MpqArchive.open(await fileSource(`samples/d2/${m}`))) } const pl2 = decodePl2(await archives.read('data/global/palette/ACT1/pal.pl2')) const outDir = join(process.cwd(), 'public', 'ui') const fontsDir = join(outDir, 'fonts') mkdirSync(outDir, { recursive: true }) mkdirSync(fontsDir, { recursive: true }) let decodedCount = 0 let decodeFailures = 0 const decodeUiDc6 = async (path: string): Promise => { try { const bytes = await archives.read(path) const sheet = decodeDc6(bytes) decodedCount++ return sheet } catch (err) { decodeFailures++ console.error(`Failed to decode DC6 ${path}:`, err) throw err } } const savePng = (relPath: string, width: number, height: number, pixels: Uint8Array, transparent = true): void => { const png = encodeIndexedPng({ width, height, pixels, palette: pl2.rgb, transparentIndex: transparent ? 0 : undefined, }) writeFileSync(join(outDir, relPath), png) } // --- STAGE 1: Base UI Panels, Globes, Borders, Fonts, Buttons --- console.log('Extracting base UI panels, globes, borders, and controls...') // 1. 800x600 Bottom Control Panel (`Panel/800CtrlPnl7.dc6`) const ctrl800 = await decodeUiDc6('data/global/ui/Panel/800CtrlPnl7.dc6') const cf = ctrl800.groups[0]!.frames const ctrlPixels = stitchFrames(800, 104, [ { frame: cf[6]!, x: 64, y: 49 }, { frame: cf[6]!, x: 600, y: 49 }, { frame: cf[1]!, x: 165, y: 49 }, { frame: cf[2]!, x: 293, y: 49 }, { frame: cf[3]!, x: 421, y: 49 }, { frame: cf[4]!, x: 549, y: 49 }, { frame: cf[0]!, x: 0, y: 0 }, { frame: cf[5]!, x: 683, y: 0 }, ]) savePng('ctrlpnl-800.png', 800, 104, ctrlPixels) // 2. Health & Mana Globes (`Panel/Hlthmana.dc6` & `Panel/overlap.dc6`) const hlthmana = await decodeUiDc6('data/global/ui/Panel/Hlthmana.dc6') const hf = hlthmana.groups[0]!.frames savePng('globe-life.png', hf[0]!.width, hf[0]!.height, hf[0]!.indices) savePng('globe-mana.png', hf[1]!.width, hf[1]!.height, hf[1]!.indices) if (hf[2]) savePng('globe-poison.png', hf[2].width, hf[2].height, hf[2].indices) const overlap = await decodeUiDc6('data/global/ui/Panel/overlap.dc6') const of = overlap.groups[0]!.frames savePng('overlap-left.png', of[0]!.width, of[0]!.height, of[0]!.indices) savePng('overlap-right.png', of[1]!.width, of[1]!.height, of[1]!.indices) // 3. 800x600 Gothic Stone Borders (`Panel/800BorderFrame.dc6`) const borderDc6 = await decodeUiDc6('data/global/ui/Panel/800BorderFrame.dc6') const bf = borderDc6.groups[0]!.frames const leftBorderPixels = stitchFrames(400, 553, [ { frame: bf[0]!, x: 0, y: -3 }, { frame: bf[1]!, x: 256, y: -3 }, { frame: bf[2]!, x: 0, y: 253 }, { frame: bf[3]!, x: 0, y: 482 }, { frame: bf[4]!, x: 256, y: 482 }, ]) savePng('border-left.png', 400, 553, leftBorderPixels) const rightBorderPixels = stitchFrames(400, 553, [ { frame: bf[5]!, x: 0, y: -3 }, { frame: bf[6]!, x: 144, y: -3 }, { frame: bf[7]!, x: 313, y: 253 }, { frame: bf[8]!, x: 144, y: 482 }, { frame: bf[9]!, x: 0, y: 482 }, ]) savePng('border-right.png', 400, 553, rightBorderPixels) // 4. Character Sheet & Inventory Sheet (`Panel/invchar6.dc6` & `Panel/invchar6Tab.dc6`) const invchar6 = await decodeUiDc6('data/global/ui/Panel/invchar6.dc6') savePng('char-sheet.png', 320, 432, stitch320x432Panel(invchar6, 0), false) savePng('inv-sheet.png', 320, 432, stitch320x432Panel(invchar6, 4), false) const invTab = await decodeUiDc6('data/global/ui/Panel/invchar6Tab.dc6') const itf = invTab.groups[0]!.frames savePng('inv-tab-0.png', itf[0]!.width, itf[0]!.height, itf[0]!.indices) savePng('inv-tab-1.png', itf[1]!.width, itf[1]!.height, itf[1]!.indices) // 5. Quest Log, Waypoint Menu, Stash, Horadric Cube, Vendor (`320x432` panels) const questBg = await decodeUiDc6('data/global/ui/Menu/questbackground.dc6') savePng('quest-bg.png', 320, 432, stitch320x432Panel(questBg, 0), false) const wpBg = await decodeUiDc6('data/global/ui/Menu/waygatebackground.dc6') savePng('waypoint-bg.png', 320, 432, stitch320x432Panel(wpBg, 0), false) const stashBg = await decodeUiDc6('data/global/ui/Panel/TradeStash.dc6') savePng('stash-bg.png', 320, 432, stitch320x432Panel(stashBg, 0), false) const cubeBg = await decodeUiDc6('data/global/ui/Panel/supertransmogrifier.dc6') savePng('cube-bg.png', 320, 432, stitch320x432Panel(cubeBg, 0), false) const vendorBg = await decodeUiDc6('data/global/ui/Panel/NPCInv.dc6') savePng('vendor-bg.png', 320, 432, stitch320x432Panel(vendorBg, 0), false) // 6. Pop-up Belt, Mini-panel, Buttons, Cursors const popbelt = await decodeUiDc6('data/global/ui/Panel/ctrlpnl_popbelt.dc6') savePng('popbelt.png', popbelt.groups[0]!.frames[0]!.width, popbelt.groups[0]!.frames[0]!.height, popbelt.groups[0]!.frames[0]!.indices, false) const minipanel = await decodeUiDc6('data/global/ui/Panel/minipanel.dc6') savePng('minipanel.png', minipanel.groups[0]!.frames[0]!.width, minipanel.groups[0]!.frames[0]!.height, minipanel.groups[0]!.frames[0]!.indices) const minipanelBtns = await decodeUiDc6('data/global/ui/Panel/minipanelbtn.dc6') const mpStrip = stitchHorizontalStrip(minipanelBtns.groups[0]!.frames) savePng('minipanel-btns.png', mpStrip.width, mpStrip.height, mpStrip.pixels) const runBtn = await decodeUiDc6('data/global/ui/Panel/runbutton.dc6') const runStrip = stitchHorizontalStrip(runBtn.groups[0]!.frames) savePng('runbutton.png', runStrip.width, runStrip.height, runStrip.pixels) const menuBtn = await decodeUiDc6('data/global/ui/Panel/menubutton.dc6') const menuStrip = stitchHorizontalStrip(menuBtn.groups[0]!.frames) savePng('menubutton.png', menuStrip.width, menuStrip.height, menuStrip.pixels) const buySellBtn = await decodeUiDc6('data/global/ui/Panel/buysellbtn.dc6') const bsStrip = stitchHorizontalStrip(buySellBtn.groups[0]!.frames) savePng('buysellbtn.png', bsStrip.width, bsStrip.height, bsStrip.pixels) const levelBtn = await decodeUiDc6('data/global/ui/Panel/Level.dc6') const lvlStrip = stitchHorizontalStrip(levelBtn.groups[0]!.frames) savePng('level-btn.png', lvlStrip.width, lvlStrip.height, lvlStrip.pixels) const levelSocket = await decodeUiDc6('data/global/ui/Panel/Levelsocket.dc6') savePng('level-socket.png', levelSocket.groups[0]!.frames[0]!.width, levelSocket.groups[0]!.frames[0]!.height, levelSocket.groups[0]!.frames[0]!.indices) const skillPts = await decodeUiDc6('data/global/ui/Panel/skillpoints.dc6') savePng('skillpoints.png', skillPts.groups[0]!.frames[0]!.width, skillPts.groups[0]!.frames[0]!.height, skillPts.groups[0]!.frames[0]!.indices) const expQuestTabs = await decodeUiDc6('data/global/ui/Menu/expquesttabs.dc6') const eqtStrip = stitchHorizontalStrip(expQuestTabs.groups[0]!.frames) savePng('quest-tabs.png', eqtStrip.width, eqtStrip.height, eqtStrip.pixels) const expWpTabs = await decodeUiDc6('data/global/ui/Menu/expwaygatetabs.dc6') const ewtStrip = stitchHorizontalStrip(expWpTabs.groups[0]!.frames) savePng('waypoint-tabs.png', ewtStrip.width, ewtStrip.height, ewtStrip.pixels) const wpIcons = await decodeUiDc6('data/global/ui/Menu/waygateicons.dc6') const wpiStrip = stitchHorizontalStrip(wpIcons.groups[0]!.frames) savePng('waypoint-icons.png', wpiStrip.width, wpiStrip.height, wpiStrip.pixels) const genericSkills = await decodeUiDc6('data/global/ui/SPELLS/Skillicon.DC6') const gsStrip = stitchHorizontalStrip(genericSkills.groups[0]!.frames) savePng('generic-skills.png', gsStrip.width, gsStrip.height, gsStrip.pixels) const ohand = await decodeUiDc6('data/global/ui/CURSOR/ohand.dc6') savePng('cursor-hand.png', ohand.groups[0]!.frames[0]!.width, ohand.groups[0]!.frames[0]!.height, ohand.groups[0]!.frames[0]!.indices) // Extract authentic dynamic cursor atlas (Issue #148: protate, ohand, orotate) console.log('Extracting authentic dynamic cursor atlas (Issue #148)...') await packCursorAssets(process.cwd()) // 7. Extract Bitmap Fonts (`data/local/font/latin/*.dc6 + *.tbl`) console.log('Extracting bitmap fonts...') const fontNames = ['font6', 'font8', 'font16', 'font30', 'fontexocet10', 'fontformal12'] as const const fontMetas: Record = {} for (const fn of fontNames) { const fDc6 = await decodeUiDc6(`data/local/font/latin/${fn}.dc6`) const fTbl = await archives.read(`data/local/font/latin/${fn}.tbl`) const built = buildFontAtlas(fn, fDc6, fTbl) fontMetas[fn] = built.meta savePng(`fonts/${fn}.png`, built.meta.atlasWidth, built.meta.atlasHeight, built.pixels) } writeFileSync(join(fontsDir, 'metrics.json'), JSON.stringify(fontMetas, null, 2)) // --- STAGE 2: Comprehensive Items & Unique Items Packing --- console.log('Extracting item sprites (including uniqueitems.txt & setitems.txt)...') const codeToInvFile: Record = {} const uniqueInvFiles = new Set() // A. Base Items from weapons.txt, armor.txt, misc.txt for (const t of ['weapons.txt', 'armor.txt', 'misc.txt']) { const tbl = parseTable(new TextDecoder().decode(await archives.read(`data/global/excel/${t}`))) for (const r of tbl.rows) { const code = r['code']?.trim() const inv = r['invfile']?.trim().toLowerCase() if (code && inv && archives.has(`data/global/items/${inv}.dc6`)) { codeToInvFile[code] = inv uniqueInvFiles.add(inv) } for (const extraCol of ['uniqueinvfile', 'setinvfile']) { const extra = r[extraCol]?.trim().toLowerCase() if (extra && archives.has(`data/global/items/${extra}.dc6`)) { uniqueInvFiles.add(extra) } } } } // B. Unique Items from uniqueitems.txt (includes invmss, invtrch, invamu2, etc.) if (archives.has('data/global/excel/uniqueitems.txt')) { const uTbl = parseTable(new TextDecoder().decode(await archives.read('data/global/excel/uniqueitems.txt'))) for (const r of uTbl.rows) { const inv = r['invfile']?.trim().toLowerCase() if (inv && archives.has(`data/global/items/${inv}.dc6`)) { uniqueInvFiles.add(inv) } const idx = r['index']?.trim() if (idx && inv && archives.has(`data/global/items/${inv}.dc6`)) { codeToInvFile[idx] = inv } } } // C. Set Items from setitems.txt if (archives.has('data/global/excel/setitems.txt')) { const sTbl = parseTable(new TextDecoder().decode(await archives.read('data/global/excel/setitems.txt'))) for (const r of sTbl.rows) { const inv = r['invfile']?.trim().toLowerCase() if (inv && archives.has(`data/global/items/${inv}.dc6`)) { uniqueInvFiles.add(inv) } const idx = r['index']?.trim() if (idx && inv && archives.has(`data/global/items/${inv}.dc6`)) { codeToInvFile[idx] = inv } } } // Identity mapping for every invfile: codeToInvFile[inv] = inv for (const inv of uniqueInvFiles) { codeToInvFile[inv] = inv } // Explicitly guarantee Annihilus and Torch mappings if (archives.has('data/global/items/invmss.dc6')) { uniqueInvFiles.add('invmss') codeToInvFile['mss'] = 'invmss' codeToInvFile['invmss'] = 'invmss' } if (archives.has('data/global/items/invtrch.dc6')) { uniqueInvFiles.add('invtrch') codeToInvFile['trch'] = 'invtrch' codeToInvFile['invtrch'] = 'invtrch' } // Register transformed item lookups in codeToInvFile codeToInvFile['Harlequin Crest'] = 'invcap_cgrn' codeToInvFile["Tal Rasha's Guardianship"] = 'invgth_dpur' codeToInvFile["Tal Rasha's Howling Wind"] = 'invgth_dpur' codeToInvFile['Magefist'] = 'invtgl_lgry' codeToInvFile['Arachnid Mesh'] = 'invvbl_blac' // Identity mappings for transformed items codeToInvFile['invcap_cgrn'] = 'invcap_cgrn' codeToInvFile['invgth_dpur'] = 'invgth_dpur' codeToInvFile['invtgl_lgry'] = 'invtgl_lgry' codeToInvFile['invvbl_blac'] = 'invvbl_blac' codeToInvFile['invlbl_blac'] = 'invlbl_blac' // Pack items-atlas.png (ATLAS_W = 1024) const sortedInvNames = [...uniqueInvFiles].sort() const ATLAS_W = 1024 let curX = 0 let curY = 0 let rowH = 0 const itemRects: Record = {} const itemPlacements: BlitPlacement[] = [] const decodedBaseFrames = new Map() for (const invName of sortedInvNames) { const dc6 = await decodeUiDc6(`data/global/items/${invName}.dc6`) const frame = dc6.groups[0]?.frames[0] if (!frame) continue decodedBaseFrames.set(invName, frame) if (curX + frame.width > ATLAS_W) { curX = 0 curY += rowH + 2 rowH = 0 } itemRects[invName] = { x: curX, y: curY, w: frame.width, h: frame.height } itemPlacements.push({ frame, x: curX, y: curY }) curX += frame.width + 2 if (frame.height > rowH) rowH = frame.height } // Pre-bake and stitch transformed item frames (cgrn, dpur, lgry, blac) const COLOR_TABLE_MAP: Record = { cgrn: 104, // emerald green RGB [51, 135, 75] dpur: 108, // royal dark purple RGB [92, 35, 154] lgry: 73, // silver grey RGB [117, 117, 117] blac: 98, // jet black RGB [0, 0, 0] } const TRANSFORMED_ITEMS: ReadonlyArray<{ baseInv: string transformedName: string colorCode: 'cgrn' | 'dpur' | 'lgry' | 'blac' }> = [ { baseInv: 'invcap', transformedName: 'invcap_cgrn', colorCode: 'cgrn' }, { baseInv: 'invgth', transformedName: 'invgth_dpur', colorCode: 'dpur' }, { baseInv: 'invtgl', transformedName: 'invtgl_lgry', colorCode: 'lgry' }, { baseInv: 'invvbl', transformedName: 'invvbl_blac', colorCode: 'blac' }, { baseInv: 'invlbl', transformedName: 'invlbl_blac', colorCode: 'blac' }, ] for (const t of TRANSFORMED_ITEMS) { let baseFrame = decodedBaseFrames.get(t.baseInv) if (!baseFrame) { const dc6 = await decodeUiDc6(`data/global/items/${t.baseInv}.dc6`) baseFrame = dc6.groups[0]?.frames[0] if (!baseFrame) { throw new Error(`Base DC6 frame missing for transformed item ${t.transformedName}`) } } const tableIdx = COLOR_TABLE_MAP[t.colorCode] if (tableIdx === undefined) { throw new Error(`Color table code ${t.colorCode} not defined in COLOR_TABLE_MAP`) } const table = pl2.hueVariations[tableIdx] if (!table) { throw new Error(`PL2 hue variation table ${tableIdx} not found in pal.pl2`) } const transformedFrame = transformSpriteFrame(baseFrame, table) if (curX + transformedFrame.width > ATLAS_W) { curX = 0 curY += rowH + 2 rowH = 0 } itemRects[t.transformedName] = { x: curX, y: curY, w: transformedFrame.width, h: transformedFrame.height } itemPlacements.push({ frame: transformedFrame, x: curX, y: curY }) curX += transformedFrame.width + 2 if (transformedFrame.height > rowH) rowH = transformedFrame.height } const atlasH = Math.max(1, curY + rowH) savePng('items-atlas.png', ATLAS_W, atlasH, stitchFrames(ATLAS_W, atlasH, itemPlacements)) // --- STAGE 3: 27 Active Quest Icons + Completed Quest Icons Atlas --- console.log('Extracting quest icons (27 active + 27 completed)...') const canonicalQuests = [ // Act 1 (0..5) 'a1q1', 'a1q2', 'a1q3', 'a1q4', 'a1q5', 'a1q6', // Act 2 (6..11) 'a2q1', 'a2q2', 'a2q3', 'a2q4', 'a2q5', 'a2q6', // Act 3 (12..17) 'a3q1', 'a3q2', 'a3q3', 'a3q4', 'a3q5', 'a3q6', // Act 4 (18..20) 'a4q1', 'a4q2', 'a4q3', // Act 5 (21..26) 'a5q1', 'a5q2', 'a5q3', 'a5q4', 'a5q5', 'a5q6', ] const questRects: Record = {} const questPlacements: BlitPlacement[] = [] // Pack in a 6-column grid (each tile 72x86): // Width: 6 * 72 = 432 // Height: 9 rows * 86 = 774 const QUEST_ATLAS_W = 432 const QUEST_ATLAS_H = 774 // 1. 27 Active quest icons (frame 0) for (let i = 0; i < canonicalQuests.length; i++) { const qSlug = canonicalQuests[i]! const qDc6 = await decodeUiDc6(`data/global/ui/MENU/${qSlug}.dc6`) const frame = qDc6.groups[0]!.frames[0]! const col = i % 6 const row = Math.floor(i / 6) // rows 0..4 const qx = col * 72 const qy = row * 86 questRects[qSlug] = { x: qx, y: qy, w: 72, h: 86 } questPlacements.push({ frame, x: qx, y: qy }) } // 2. 27 Completed quest icons from questdone.dc6 const qDoneDc6 = await decodeUiDc6('data/global/ui/MENU/questdone.dc6') const doneFrames = qDoneDc6.groups[0]!.frames for (let i = 0; i < doneFrames.length; i++) { const frame = doneFrames[i]! const idx = 27 + i const col = idx % 6 const row = Math.floor(idx / 6) // rows 4..8 const qx = col * 72 const qy = row * 86 const qSlug = canonicalQuests[i]! questRects[`questdone_${i}`] = { x: qx, y: qy, w: 72, h: 86 } questRects[`questdone_${qSlug}`] = { x: qx, y: qy, w: 72, h: 86 } questPlacements.push({ frame, x: qx, y: qy }) } savePng('quests-atlas.png', QUEST_ATLAS_W, QUEST_ATLAS_H, stitchFrames(QUEST_ATLAS_W, QUEST_ATLAS_H, questPlacements)) // --- STAGE 4: Emit public/ui/manifest.json --- const imagesMap: Record = { ctrlPnl: '/ui/ctrlpnl-800.png', globeLife: '/ui/globe-life.png', globeMana: '/ui/globe-mana.png', globePoison: '/ui/globe-poison.png', overlapLeft: '/ui/overlap-left.png', overlapRight: '/ui/overlap-right.png', borderLeft: '/ui/border-left.png', borderRight: '/ui/border-right.png', charSheet: '/ui/char-sheet.png', invSheet: '/ui/inv-sheet.png', invTab0: '/ui/inv-tab-0.png', invTab1: '/ui/inv-tab-1.png', questBg: '/ui/quest-bg.png', waypointBg: '/ui/waypoint-bg.png', stashBg: '/ui/stash-bg.png', cubeBg: '/ui/cube-bg.png', vendorBg: '/ui/vendor-bg.png', popbelt: '/ui/popbelt.png', minipanel: '/ui/minipanel.png', minipanelBtns: '/ui/minipanel-btns.png', runBtn: '/ui/runbutton.png', menuBtn: '/ui/menubutton.png', buySellBtn: '/ui/buysellbtn.png', levelBtn: '/ui/level-btn.png', levelSocket: '/ui/level-socket.png', skillPoints: '/ui/skillpoints.png', genericSkills: '/ui/generic-skills.png', cursorHand: '/ui/cursor-hand.png', cursorAtlas: '/ui/cursor.png', questTabs: '/ui/quest-tabs.png', waypointTabs: '/ui/waypoint-tabs.png', waypointIcons: '/ui/waypoint-icons.png', itemsAtlas: '/ui/items-atlas.png', questsAtlas: '/ui/quests-atlas.png', } const manifest = { version: '1.13c', decodedDc6Count: decodedCount, dc6DecodeFailures: decodeFailures, atlasWidth: ATLAS_W, atlasHeight: atlasH, questsAtlasWidth: QUEST_ATLAS_W, questsAtlasHeight: QUEST_ATLAS_H, codeToInvFile, itemRects, questRects, images: imagesMap, } writeFileSync(join(outDir, 'manifest.json'), JSON.stringify(manifest, null, 2)) // --- STAGE 5: Emit src/ui/baked-ui-meta.ts --- const bakedMetaCode = `/** * Auto-generated baked UI metadata for Diablo II Web client. * Generated by \`scripts/pack-ui.ts\`. * Enforces the Zero Runtime MPQ/DLL Architectural Invariant. */ export interface SpriteRect { readonly x: number readonly y: number readonly w: number readonly h: number } export interface QuestAssetInfo { readonly activeIcon: string readonly doneIcon: string readonly doneFrame: number } export interface BakedUiManifest { readonly version: string readonly decodedDc6Count: number readonly dc6DecodeFailures: number readonly atlasWidth: number readonly atlasHeight: number readonly questsAtlasWidth: number readonly questsAtlasHeight: number readonly codeToInvFile: Record readonly itemRects: Record readonly questRects: Record readonly images: Record } /** * Authoritative 1.13c Quest Asset Mapping: [act: number] -> QuestAssetInfo[] * Act 1: 6 quests (a1q1..a1q6, done frames 0..5) * Act 2: 6 quests (a2q1..a2q6, done frames 6..11) * Act 3: 6 quests (a3q1..a3q6, done frames 12..17) * Act 4: 3 quests (a4q1..a4q3, done frames 18..20) * Act 5: 6 quests (a5q1..a5q6, done frames 21..26) */ export const ACT_QUEST_ASSET_MAP: Readonly> = { 1: [ { activeIcon: 'a1q1', doneIcon: 'questdone_0', doneFrame: 0 }, { activeIcon: 'a1q2', doneIcon: 'questdone_1', doneFrame: 1 }, { activeIcon: 'a1q3', doneIcon: 'questdone_2', doneFrame: 2 }, { activeIcon: 'a1q4', doneIcon: 'questdone_3', doneFrame: 3 }, { activeIcon: 'a1q5', doneIcon: 'questdone_4', doneFrame: 4 }, { activeIcon: 'a1q6', doneIcon: 'questdone_5', doneFrame: 5 }, ], 2: [ { activeIcon: 'a2q1', doneIcon: 'questdone_6', doneFrame: 6 }, { activeIcon: 'a2q2', doneIcon: 'questdone_7', doneFrame: 7 }, { activeIcon: 'a2q3', doneIcon: 'questdone_8', doneFrame: 8 }, { activeIcon: 'a2q4', doneIcon: 'questdone_9', doneFrame: 9 }, { activeIcon: 'a2q5', doneIcon: 'questdone_10', doneFrame: 10 }, { activeIcon: 'a2q6', doneIcon: 'questdone_11', doneFrame: 11 }, ], 3: [ { activeIcon: 'a3q1', doneIcon: 'questdone_12', doneFrame: 12 }, { activeIcon: 'a3q2', doneIcon: 'questdone_13', doneFrame: 13 }, { activeIcon: 'a3q3', doneIcon: 'questdone_14', doneFrame: 14 }, { activeIcon: 'a3q4', doneIcon: 'questdone_15', doneFrame: 15 }, { activeIcon: 'a3q5', doneIcon: 'questdone_16', doneFrame: 16 }, { activeIcon: 'a3q6', doneIcon: 'questdone_17', doneFrame: 17 }, ], 4: [ { activeIcon: 'a4q1', doneIcon: 'questdone_18', doneFrame: 18 }, { activeIcon: 'a4q2', doneIcon: 'questdone_19', doneFrame: 19 }, { activeIcon: 'a4q3', doneIcon: 'questdone_20', doneFrame: 20 }, ], 5: [ { activeIcon: 'a5q1', doneIcon: 'questdone_21', doneFrame: 21 }, { activeIcon: 'a5q2', doneIcon: 'questdone_22', doneFrame: 22 }, { activeIcon: 'a5q3', doneIcon: 'questdone_23', doneFrame: 23 }, { activeIcon: 'a5q4', doneIcon: 'questdone_24', doneFrame: 24 }, { activeIcon: 'a5q5', doneIcon: 'questdone_25', doneFrame: 25 }, { activeIcon: 'a5q6', doneIcon: 'questdone_26', doneFrame: 26 }, ], } export const BAKED_UI_MANIFEST: BakedUiManifest = { version: '1.13c', decodedDc6Count: ${decodedCount}, dc6DecodeFailures: ${decodeFailures}, atlasWidth: ${ATLAS_W}, atlasHeight: ${atlasH}, questsAtlasWidth: ${QUEST_ATLAS_W}, questsAtlasHeight: ${QUEST_ATLAS_H}, codeToInvFile: ${JSON.stringify(codeToInvFile, null, 2)}, itemRects: ${JSON.stringify(itemRects, null, 2)}, questRects: ${JSON.stringify(questRects, null, 2)}, images: ${JSON.stringify(imagesMap, null, 2)}, } export { CURSOR_METADATA, type CursorFrameMeta, type CursorAtlasMetadata } from './cursor-meta.ts' ` writeFileSync(join(process.cwd(), 'src', 'ui', 'baked-ui-meta.ts'), bakedMetaCode, 'utf8') console.log(`Successfully decoded ${decodedCount} DC6 sheets (${decodeFailures} failures)!`) console.log('Emitted public/ui/manifest.json and src/ui/baked-ui-meta.ts.') } main().catch((err) => { console.error('pack-ui failed:', err) process.exit(1) })