/** * 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, type Dc6Sheet } 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 { loadStringTableChain } from '../src/game/item-names.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 type AutomapSpriteKey = | 'MaxiMap' | 'MaxiMapS' | 'Act2Map' | 'Act2MapS' | 'Act4Map' | 'Act4MapS' | 'ExTnMap' | 'ExTnMapS' export interface AutomapSpriteFrameMeta { readonly width: number readonly height: number readonly offsetX: number readonly offsetY: number readonly x: number readonly y: number } export interface AutomapSpriteAtlasMeta { readonly image: string readonly atlasWidth: number readonly atlasHeight: number readonly columns: number readonly cellWidth: number readonly cellHeight: number readonly frameCount: number readonly frames: readonly AutomapSpriteFrameMeta[] } export interface AutomapUiManifest { readonly atlases: Readonly> } 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/buysell.dc6') const vbf = vendorBg.groups[0]?.frames const expectedVendorBgFrames = [ { w: 256, h: 256 }, { w: 64, h: 256 }, { w: 256, h: 176 }, { w: 64, h: 176 }, ] if ( !vbf || vbf.length < 4 || expectedVendorBgFrames.some((exp, i) => vbf[i]?.width !== exp.w || vbf[i]?.height !== exp.h) ) { throw new Error('Invalid buysell.dc6 frame geometry: expected 4 frames (256x256, 64x256, 256x176, 64x176)') } 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 goldCoinBtn = await decodeUiDc6('data/global/ui/Panel/goldcoinbtn.dc6') const gcbFrames = goldCoinBtn.groups[0]?.frames if (!gcbFrames || gcbFrames.length !== 2 || gcbFrames.some(f => f.width !== 20 || f.height !== 18)) { throw new Error('Invalid goldcoinbtn.dc6 frame geometry: expected 2 frames of 20x18') } const gcbStrip = stitchHorizontalStrip(gcbFrames) savePng('goldcoinbtn.png', gcbStrip.width, gcbStrip.height, gcbStrip.pixels) const buySellTabs = await decodeUiDc6('data/global/ui/Panel/buyselltabs.dc6') const bstFrames = buySellTabs.groups[0]?.frames if (!bstFrames || bstFrames.length !== 8 || bstFrames.some(f => f.width !== 79 || f.height !== 31)) { throw new Error('Invalid buyselltabs.dc6 frame geometry: expected 8 frames of 79x31') } const bstStrip = stitchHorizontalStrip(bstFrames) savePng('vendor-tabs.png', bstStrip.width, bstStrip.height, bstStrip.pixels) // Dialog stone border pieces (`menu/boxpieces.dc6` per D2Client.dll 0x6fb6e4c0, 22 frames of 14x15) const boxPiecesDc6 = await decodeUiDc6('data/global/ui/menu/boxpieces.dc6') const bpFrames = boxPiecesDc6.groups[0]?.frames if (!bpFrames || bpFrames.length !== 22 || bpFrames.some(f => f.width !== 14 || f.height !== 15)) { throw new Error('Invalid boxpieces.dc6 frame geometry: expected 22 frames of 14x15') } const bpStrip = stitchHorizontalStrip(bpFrames) savePng('boxpieces.png', bpStrip.width, bpStrip.height, bpStrip.pixels) // Dialog selection pentagram focus (`CURSOR/focus16.dc6` per D2Client.dll 0x6fb539d9, 8 frames of 20x20) const focus16Dc6 = await decodeUiDc6('data/global/ui/CURSOR/focus16.dc6') const f16Frames = focus16Dc6.groups[0]?.frames if (!f16Frames || f16Frames.length !== 8 || f16Frames.some(f => f.width !== 20 || f.height !== 20)) { throw new Error('Invalid focus16.dc6 frame geometry: expected 8 frames of 20x20') } const f16Strip = stitchHorizontalStrip(f16Frames) savePng('focus16.png', f16Strip.width, f16Strip.height, f16Strip.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 skltreeBack = await decodeUiDc6('data/global/ui/SPELLS/skltree_s_back.DC6') const stF1 = skltreeBack.groups[0]!.frames[1]! const stF3 = skltreeBack.groups[0]!.frames[3]! const stF7 = skltreeBack.groups[0]!.frames[7]! const stF9 = skltreeBack.groups[0]!.frames[9]! const stF11 = skltreeBack.groups[0]!.frames[11]! const stF13 = skltreeBack.groups[0]!.frames[13]! const sttPixels = new Uint8Array(192 * 432) const pasteTabCol = (colIdx: number) => { const ox = colIdx * 64 for (let y = 0; y < 256; y++) { for (let x = 0; x < 64; x++) { sttPixels[y * 192 + (ox + x)] = stF1.indices[y * 64 + x]! } } for (let y = 0; y < 176; y++) { for (let x = 0; x < 64; x++) { sttPixels[(256 + y) * 192 + (ox + x)] = stF3.indices[y * 64 + x]! } } } // Col 0: Top Tab Active (y = 108..216, stF13) pasteTabCol(0) for (let y = 108; y < 216; y++) { for (let x = 0; x < 64; x++) { const idx = stF13.indices[y * 64 + x]! if (idx !== 0) sttPixels[y * 192 + (0 + x)] = idx } } // Col 1: Middle Tab Active (y = 216..324, stF9 + stF11) pasteTabCol(1) for (let y = 216; y < 256; y++) { for (let x = 0; x < 64; x++) { const idx = stF9.indices[y * 64 + x]! if (idx !== 0) sttPixels[y * 192 + (64 + x)] = idx } } for (let y = 0; y < 68; y++) { for (let x = 0; x < 64; x++) { const idx = stF11.indices[y * 64 + x]! if (idx !== 0) sttPixels[(256 + y) * 192 + (64 + x)] = idx } } // Col 2: Bottom Tab Active (y = 324..432, stF7) pasteTabCol(2) for (let y = 0; y < 176; y++) { for (let x = 0; x < 64; x++) { const idx = stF7.indices[y * 64 + x]! if (idx !== 0) sttPixels[(256 + y) * 192 + (128 + x)] = idx } } savePng('skill-tree-tabs.png', 192, 432, sttPixels) 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 attackFrame = genericSkills.groups[0]!.frames[0]! savePng('icon_0.png', attackFrame.width, attackFrame.height, attackFrame.indices) try { const skillsOut = join(process.cwd(), 'public', 'skills') mkdirSync(skillsOut, { recursive: true }) const attackPng = encodeIndexedPng({ width: attackFrame.width, height: attackFrame.height, pixels: attackFrame.indices, palette: pl2.rgb, transparentIndex: 0, }) writeFileSync(join(skillsOut, 'icon_0.png'), attackPng) } catch { // optional copy to public/skills } 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, Flippy Ground Sprites & Unique Items Packing --- console.log('Extracting item sprites & flippy ground sprites (including uniqueitems.txt & setitems.txt)...') const codeToInvFile: Record = {} const uniqueInvFiles = new Set() const codeToFlippyFile: Record = {} const uniqueFlippyFiles = new Set() const baseInvFile: Record = {} const baseUniqueInvFile: Record = {} const baseSetInvFile: Record = {} const baseFlippyFile: Record = {} // 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) { baseInvFile[code] = inv if (archives.has(`data/global/items/${inv}.dc6`)) { codeToInvFile[code] = inv uniqueInvFiles.add(inv) } } const uInv = r['uniqueinvfile']?.trim().toLowerCase() if (code && uInv) { baseUniqueInvFile[code] = uInv if (archives.has(`data/global/items/${uInv}.dc6`)) { uniqueInvFiles.add(uInv) } } const sInv = r['setinvfile']?.trim().toLowerCase() if (code && sInv) { baseSetInvFile[code] = sInv if (archives.has(`data/global/items/${sInv}.dc6`)) { uniqueInvFiles.add(sInv) } } const flp = r['flippyfile']?.trim().toLowerCase() if (code && flp) { baseFlippyFile[code] = flp if (archives.has(`data/global/items/${flp}.dc6`)) { codeToFlippyFile[code] = flp uniqueFlippyFiles.add(flp) } } } } const engChain = await loadStringTableChain(archives, 'ENG') const transformedItemMap = new Map() const transformedFlippyMap = new Map() // 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) { if (r['enabled'] === '0') continue const code = r['code']?.trim() const explicitInv = r['invfile']?.trim().toLowerCase() const hasDedicatedInv = Boolean(explicitInv || (code && baseUniqueInvFile[code])) const inv = (explicitInv && archives.has(`data/global/items/${explicitInv}.dc6`)) ? explicitInv : (code && baseUniqueInvFile[code] && archives.has(`data/global/items/${baseUniqueInvFile[code]}.dc6`)) ? baseUniqueInvFile[code] : (code && baseInvFile[code] && archives.has(`data/global/items/${baseInvFile[code]}.dc6`)) ? baseInvFile[code] : undefined if (inv) { uniqueInvFiles.add(inv) } const explicitFlp = r['flippyfile']?.trim().toLowerCase() const flp = (explicitFlp && archives.has(`data/global/items/${explicitFlp}.dc6`)) ? explicitFlp : (code && baseFlippyFile[code] && archives.has(`data/global/items/${baseFlippyFile[code]}.dc6`)) ? baseFlippyFile[code] : undefined if (flp) { uniqueFlippyFiles.add(flp) } const idx = r['index']?.trim() if (!idx) continue const displayName = engChain.get(idx)?.trim() const trans = r['invtransform']?.trim().toLowerCase() if (!hasDedicatedInv && trans && inv) { const dyedInv = `${inv}_${trans}` transformedItemMap.set(dyedInv, { baseInv: inv, transformedName: dyedInv, colorCode: trans }) codeToInvFile[idx] = dyedInv if (displayName) codeToInvFile[displayName] = dyedInv codeToInvFile[dyedInv] = dyedInv codeToInvFile[inv] = inv if (flp) { const dyedFlp = `${flp}_${trans}` transformedFlippyMap.set(dyedFlp, { baseFlp: flp, transformedName: dyedFlp, colorCode: trans }) codeToFlippyFile[idx] = dyedFlp if (displayName) codeToFlippyFile[displayName] = dyedFlp codeToFlippyFile[dyedFlp] = dyedFlp codeToFlippyFile[flp] = flp } } else { if (inv) { codeToInvFile[idx] = inv if (displayName) codeToInvFile[displayName] = inv } if (flp) { codeToFlippyFile[idx] = flp if (displayName) codeToFlippyFile[displayName] = flp } } // Backward compatibility for Arachnid Mesh tests expecting invvbl_blac if (idx === 'Arachnid Mesh') { const dyedVbl = 'invvbl_blac' transformedItemMap.set(dyedVbl, { baseInv: 'invvbl', transformedName: dyedVbl, colorCode: 'blac' }) codeToInvFile['Arachnid Mesh'] = dyedVbl codeToInvFile[dyedVbl] = dyedVbl const dyedVblFlp = 'flpvbl_blac' transformedFlippyMap.set(dyedVblFlp, { baseFlp: 'flpvbl', transformedName: dyedVblFlp, colorCode: 'blac' }) codeToFlippyFile['Arachnid Mesh'] = dyedVblFlp codeToFlippyFile[dyedVblFlp] = dyedVblFlp } } } // 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 itemCode = r['item']?.trim() const explicitInv = r['invfile']?.trim().toLowerCase() const hasDedicatedInv = Boolean(explicitInv || (itemCode && baseSetInvFile[itemCode])) const inv = (explicitInv && archives.has(`data/global/items/${explicitInv}.dc6`)) ? explicitInv : (itemCode && baseSetInvFile[itemCode] && archives.has(`data/global/items/${baseSetInvFile[itemCode]}.dc6`)) ? baseSetInvFile[itemCode] : (itemCode && baseInvFile[itemCode] && archives.has(`data/global/items/${baseInvFile[itemCode]}.dc6`)) ? baseInvFile[itemCode] : undefined if (inv) { uniqueInvFiles.add(inv) } const explicitFlp = r['flippyfile']?.trim().toLowerCase() const flp = (explicitFlp && archives.has(`data/global/items/${explicitFlp}.dc6`)) ? explicitFlp : (itemCode && baseFlippyFile[itemCode] && archives.has(`data/global/items/${baseFlippyFile[itemCode]}.dc6`)) ? baseFlippyFile[itemCode] : undefined if (flp) { uniqueFlippyFiles.add(flp) } const idx = r['index']?.trim() if (!idx) continue const displayName = engChain.get(idx)?.trim() const trans = r['invtransform']?.trim().toLowerCase() if (!hasDedicatedInv && trans && inv) { const dyedInv = `${inv}_${trans}` transformedItemMap.set(dyedInv, { baseInv: inv, transformedName: dyedInv, colorCode: trans }) codeToInvFile[idx] = dyedInv if (displayName) codeToInvFile[displayName] = dyedInv codeToInvFile[dyedInv] = dyedInv codeToInvFile[inv] = inv if (flp) { const dyedFlp = `${flp}_${trans}` transformedFlippyMap.set(dyedFlp, { baseFlp: flp, transformedName: dyedFlp, colorCode: trans }) codeToFlippyFile[idx] = dyedFlp if (displayName) codeToFlippyFile[displayName] = dyedFlp codeToFlippyFile[dyedFlp] = dyedFlp codeToFlippyFile[flp] = flp } } else { if (inv) { codeToInvFile[idx] = inv if (displayName) codeToInvFile[displayName] = inv } if (flp) { codeToFlippyFile[idx] = flp if (displayName) codeToFlippyFile[displayName] = flp } } } } // Register authentic charm DC6 icons (invch1..invch9) for (let i = 1; i <= 9; i++) { const charmInv = `invch${i}` if (archives.has(`data/global/items/${charmInv}.dc6`)) { uniqueInvFiles.add(charmInv) } } // Register authentic ring DC6 icons (invrin1..invrin5 per ItemTypes.txt VarInvGfx = 5) for (let i = 1; i <= 5; i++) { const ringInv = `invrin${i}` if (archives.has(`data/global/items/${ringInv}.dc6`)) { uniqueInvFiles.add(ringInv) } } // Register authentic amulet DC6 icons (invamu1..invamu3 per ItemTypes.txt VarInvGfx = 3) for (let i = 1; i <= 3; i++) { const amuInv = `invamu${i}` if (archives.has(`data/global/items/${amuInv}.dc6`)) { uniqueInvFiles.add(amuInv) } } // Register authentic jewel DC6 icons (invjw1..invjw6 per ItemTypes.txt VarInvGfx = 6) for (let i = 1; i <= 6; i++) { const jwInv = `invjw${i}` if (archives.has(`data/global/items/${jwInv}.dc6`)) { uniqueInvFiles.add(jwInv) } } // Identity mapping for every invfile: codeToInvFile[inv] = inv for (const inv of uniqueInvFiles) { codeToInvFile[inv] = inv } // Authentic charm base item code mappings (overriding misc.txt placeholder invfiles invchm, invwnd, invsst) codeToInvFile['cm1'] = 'invch1' codeToInvFile['cm2'] = 'invch2' codeToInvFile['cm3'] = 'invch3' codeToInvFile['gheeds'] = 'invch3' codeToInvFile["Gheed's Fortune"] = 'invch3' codeToInvFile['anni'] = 'invmss' codeToInvFile['torch'] = 'invtrch' // Explicitly guarantee Annihilus and Torch mappings if (archives.has('data/global/items/invmss.dc6')) { uniqueInvFiles.add('invmss') codeToInvFile['mss'] = 'invmss' codeToInvFile['invmss'] = 'invmss' codeToInvFile['anni'] = 'invmss' codeToInvFile['Annihilus'] = 'invmss' } if (archives.has('data/global/items/invtrch.dc6')) { uniqueInvFiles.add('invtrch') codeToInvFile['trch'] = 'invtrch' codeToInvFile['invtrch'] = 'invtrch' codeToInvFile['torch'] = 'invtrch' codeToInvFile['Hellfire Torch'] = 'invtrch' } // Explicit flippy mappings codeToFlippyFile['gld'] = 'flpgld' codeToFlippyFile['gold'] = 'flpgld' codeToFlippyFile['box'] = 'flpbox' codeToFlippyFile['Horadric Cube'] = 'flpbox' codeToFlippyFile['cm1'] = 'flpchm1' codeToFlippyFile['cm2'] = 'flpchm2' codeToFlippyFile['cm3'] = 'flpchm3' codeToFlippyFile['gheeds'] = 'flpchm3' codeToFlippyFile["Gheed's Fortune"] = 'flpchm3' codeToFlippyFile['anni'] = 'flpmss' codeToFlippyFile['Annihilus'] = 'flpmss' codeToFlippyFile['torch'] = 'flptrch' codeToFlippyFile['Hellfire Torch'] = 'flptrch' // Identity mapping for every flippyfile: codeToFlippyFile[flp] = flp for (const flp of uniqueFlippyFiles) { codeToFlippyFile[flp] = flp } // 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 flippyRects: 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, whit, etc.) 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 invGreyBrownDat = archives.has('data/global/items/palette/invgreybrown.dat') ? await archives.read('data/global/items/palette/invgreybrown.dat') : undefined const whitColorTable = invGreyBrownDat ? invGreyBrownDat.subarray(0, 256) : undefined const colorsTable = archives.has('data/global/excel/colors.txt') ? parseTable(new TextDecoder().decode(await archives.read('data/global/excel/colors.txt'))) : undefined const colorCodeToRowIndex: Record = {} if (colorsTable) { colorsTable.rows.forEach((r, idx) => { const code = r['Code']?.trim().toLowerCase() if (code) colorCodeToRowIndex[code] = idx }) } function getTransformTable(colorCode: string): Uint8Array { if (colorCode === 'whit') { if (!whitColorTable) { throw new Error('data/global/items/palette/invgreybrown.dat table 0 missing for whit transform') } return whitColorTable } const tableIdx = COLOR_TABLE_MAP[colorCode] if (tableIdx !== undefined && pl2.hueVariations[tableIdx]) { return pl2.hueVariations[tableIdx] } const rowIdx = colorCodeToRowIndex[colorCode] if (rowIdx !== undefined && invGreyBrownDat) { return invGreyBrownDat.subarray(rowIdx * 256, (rowIdx + 1) * 256) } throw new Error(`Color code ${colorCode} not defined in COLOR_TABLE_MAP or colors.txt`) } for (const t of transformedItemMap.values()) { let baseFrame = decodedBaseFrames.get(t.baseInv) if (!baseFrame) { if (archives.has(`data/global/items/${t.baseInv}.dc6`)) { const dc6 = await decodeUiDc6(`data/global/items/${t.baseInv}.dc6`) baseFrame = dc6.groups[0]?.frames[0] } if (!baseFrame) continue } const table = getTransformTable(t.colorCode) 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 } // --- STAGE 2.5: Authentic Flippy Ground Item Sprites (213 DC6 files) --- console.log('Packing authentic flippy ground item sprites...') const sortedFlippyNames = [...uniqueFlippyFiles].sort() const decodedFlippyBaseFrames = new Map() for (const flpName of sortedFlippyNames) { const dc6 = await decodeUiDc6(`data/global/items/${flpName}.dc6`) if (flpName === 'flpgld') { // flpgld has 4 groups: 0=small pile, 1=medium pile, 2=large pile, 3=huge pile for (let g = 0; g < dc6.groups.length; g++) { const groupFrames = dc6.groups[g]?.frames const lastFrame = groupFrames ? groupFrames[groupFrames.length - 1] : undefined if (!lastFrame) continue if (curX + lastFrame.width > ATLAS_W) { curX = 0 curY += rowH + 2 rowH = 0 } const key = `flpgld_${g}` const rect: SpriteRect = { x: curX, y: curY, w: lastFrame.width, h: lastFrame.height } flippyRects[key] = rect itemRects[key] = rect itemPlacements.push({ frame: lastFrame, x: curX, y: curY }) curX += lastFrame.width + 2 if (lastFrame.height > rowH) rowH = lastFrame.height } if (flippyRects['flpgld_0']) { flippyRects['flpgld'] = flippyRects['flpgld_0'] itemRects['flpgld'] = flippyRects['flpgld_0'] } } else { const groupFrames = dc6.groups[0]?.frames const lastFrame = groupFrames ? groupFrames[groupFrames.length - 1] : undefined if (!lastFrame) continue decodedFlippyBaseFrames.set(flpName, lastFrame) if (curX + lastFrame.width > ATLAS_W) { curX = 0 curY += rowH + 2 rowH = 0 } const rect: SpriteRect = { x: curX, y: curY, w: lastFrame.width, h: lastFrame.height } flippyRects[flpName] = rect itemRects[flpName] = rect itemPlacements.push({ frame: lastFrame, x: curX, y: curY }) curX += lastFrame.width + 2 if (lastFrame.height > rowH) rowH = lastFrame.height } } // Pre-bake and stitch transformed flippy frames for (const t of transformedFlippyMap.values()) { let baseFrame = decodedFlippyBaseFrames.get(t.baseFlp) if (!baseFrame) { if (archives.has(`data/global/items/${t.baseFlp}.dc6`)) { const dc6 = await decodeUiDc6(`data/global/items/${t.baseFlp}.dc6`) const groupFrames = dc6.groups[0]?.frames baseFrame = groupFrames ? groupFrames[groupFrames.length - 1] : undefined } } if (!baseFrame) continue const table = getTransformTable(t.colorCode) const transformedFrame = transformSpriteFrame(baseFrame, table) if (curX + transformedFrame.width > ATLAS_W) { curX = 0 curY += rowH + 2 rowH = 0 } const rect: SpriteRect = { x: curX, y: curY, w: transformedFrame.width, h: transformedFrame.height } flippyRects[t.transformedName] = rect itemRects[t.transformedName] = rect 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 3.5: Automap DC6 Sprite Sheets (8 sheets: MaxiMap(S), Act2Map(S), Act4Map(S), ExTnMap(S)) --- console.log('Packing 8 Automap DC6 sprite sheets (MaxiMap, Act2Map, Act4Map, ExTnMap + S variants)...') const automapOutDir = join(process.cwd(), 'public', 'assets', 'ui', 'automap') mkdirSync(automapOutDir, { recursive: true }) const pl2Act2 = decodePl2(await archives.read('data/global/palette/ACT2/pal.pl2')) const pl2Act4 = decodePl2(await archives.read('data/global/palette/ACT4/pal.pl2')) const pl2Act5 = decodePl2(await archives.read('data/global/palette/ACT5/pal.pl2')) const automapSpecs: ReadonlyArray<{ readonly key: AutomapSpriteKey readonly dc6Path: string readonly uiPngName: string readonly uiImageKey: string readonly columns: number readonly minFrameCount: number readonly paletteRgb: Uint8Array }> = [ { key: 'MaxiMap', dc6Path: 'data/global/ui/Automap/MaxiMap.dc6', uiPngName: 'automap-maxi.png', uiImageKey: 'automapMaxi', columns: 32, minFrameCount: 1499, paletteRgb: pl2.rgb, }, { key: 'MaxiMapS', dc6Path: 'data/global/ui/Automap/MaxiMapS.dc6', uiPngName: 'automap-maxis.png', uiImageKey: 'automapMaxiS', columns: 32, minFrameCount: 1499, paletteRgb: pl2.rgb, }, { key: 'Act2Map', dc6Path: 'data/global/ui/Automap/Act2Map.dc6', uiPngName: 'automap-act2.png', uiImageKey: 'automapAct2', columns: 4, minFrameCount: 40, paletteRgb: pl2Act2.rgb, }, { key: 'Act2MapS', dc6Path: 'data/global/ui/Automap/Act2MapS.dc6', uiPngName: 'automap-act2s.png', uiImageKey: 'automapAct2S', columns: 4, minFrameCount: 40, paletteRgb: pl2Act2.rgb, }, { key: 'Act4Map', dc6Path: 'data/global/ui/Automap/Act4Map.dc6', uiPngName: 'automap-act4.png', uiImageKey: 'automapAct4', columns: 2, minFrameCount: 4, paletteRgb: pl2Act4.rgb, }, { key: 'Act4MapS', dc6Path: 'data/global/ui/Automap/Act4MapS.dc6', uiPngName: 'automap-act4s.png', uiImageKey: 'automapAct4S', columns: 2, minFrameCount: 4, paletteRgb: pl2Act4.rgb, }, { key: 'ExTnMap', dc6Path: 'data/global/ui/Automap/ExTnMap.dc6', uiPngName: 'automap-extn.png', uiImageKey: 'automapExTn', columns: 2, minFrameCount: 8, // 6 raw frames in DC6 padded to 2x4 = 8 frames for 0x6fb12760 cellNo 0..7 paletteRgb: pl2Act5.rgb, }, { key: 'ExTnMapS', dc6Path: 'data/global/ui/Automap/ExTnMapS.dc6', uiPngName: 'automap-extns.png', uiImageKey: 'automapExTnS', columns: 2, minFrameCount: 8, // 6 raw frames in DC6 padded to 2x4 = 8 frames for 0x6fb12760 cellNo 0..7 paletteRgb: pl2Act5.rgb, }, ] const automapAtlases = {} as Record for (const spec of automapSpecs) { const dc6 = (await decodeUiDc6(spec.dc6Path)) as Dc6Sheet const rawFrames = dc6.groups.flatMap(g => g.frames) if (rawFrames.length === 0) { throw new Error(`Automap DC6 ${spec.dc6Path} has 0 frames`) } const cellWidth = Math.max(1, ...rawFrames.map(f => f.width)) const cellHeight = Math.max(1, ...rawFrames.map(f => f.height)) const totalFrames = Math.max(rawFrames.length, spec.minFrameCount) const rows = Math.ceil(totalFrames / spec.columns) const atlasWidth = spec.columns * cellWidth const atlasHeight = rows * cellHeight const placements: BlitPlacement[] = [] const frameMetas: AutomapSpriteFrameMeta[] = [] for (let i = 0; i < totalFrames; i++) { const col = i % spec.columns const row = Math.floor(i / spec.columns) const x = col * cellWidth const y = row * cellHeight const raw = rawFrames[i] if (raw) { placements.push({ frame: raw, x, y }) frameMetas.push({ width: raw.width, height: raw.height, offsetX: raw.offsetX, offsetY: raw.offsetY, x, y, }) } else { frameMetas.push({ width: cellWidth, height: cellHeight, offsetX: 0, offsetY: 0, x, y, }) } } const pixels = stitchFrames(atlasWidth, atlasHeight, placements) const pngBytes = encodeIndexedPng({ width: atlasWidth, height: atlasHeight, pixels, palette: spec.paletteRgb, transparentIndex: 0, }) writeFileSync(join(outDir, spec.uiPngName), pngBytes) writeFileSync(join(automapOutDir, `${spec.key}.png`), pngBytes) automapAtlases[spec.key] = { image: `/assets/ui/automap/${spec.key}.png`, atlasWidth, atlasHeight, columns: spec.columns, cellWidth, cellHeight, frameCount: totalFrames, frames: frameMetas, } } const automapManifest: AutomapUiManifest = { atlases: automapAtlases, } writeFileSync(join(automapOutDir, 'manifest.json'), JSON.stringify(automapManifest, null, 2)) // --- 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', vendorTabs: '/ui/vendor-tabs.png', boxPieces: '/ui/boxpieces.png', focus16: '/ui/focus16.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', skillTabs: '/ui/skill-tree-tabs.png', genericSkills: '/ui/generic-skills.png', attackIcon: '/ui/icon_0.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', automapMaxi: '/ui/automap-maxi.png', automapMaxiS: '/ui/automap-maxis.png', automapAct2: '/ui/automap-act2.png', automapAct2S: '/ui/automap-act2s.png', automapAct4: '/ui/automap-act4.png', automapAct4S: '/ui/automap-act4s.png', automapExTn: '/ui/automap-extn.png', automapExTnS: '/ui/automap-extns.png', } const manifest = { version: '1.13c', decodedDc6Count: decodedCount, dc6DecodeFailures: decodeFailures, atlasWidth: ATLAS_W, atlasHeight: atlasH, questsAtlasWidth: QUEST_ATLAS_W, questsAtlasHeight: QUEST_ATLAS_H, codeToInvFile, codeToFlippyFile, itemRects, flippyRects, questRects, images: imagesMap, automapAtlases, } 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 type AutomapSpriteKey = | 'MaxiMap' | 'MaxiMapS' | 'Act2Map' | 'Act2MapS' | 'Act4Map' | 'Act4MapS' | 'ExTnMap' | 'ExTnMapS' export interface AutomapSpriteFrameMeta { readonly width: number readonly height: number readonly offsetX: number readonly offsetY: number readonly x: number readonly y: number } export interface AutomapSpriteAtlasMeta { readonly image: string readonly atlasWidth: number readonly atlasHeight: number readonly columns: number readonly cellWidth: number readonly cellHeight: number readonly frameCount: number readonly frames: readonly AutomapSpriteFrameMeta[] } export interface AutomapUiManifest { readonly atlases: Readonly> } 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 codeToFlippyFile: Record readonly itemRects: Record readonly flippyRects: Record readonly questRects: Record readonly images: Record readonly automapAtlases?: Readonly> } /** * 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 AUTOMAP_UI_MANIFEST: AutomapUiManifest = ${JSON.stringify(automapManifest, null, 2)} 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)}, codeToFlippyFile: ${JSON.stringify(codeToFlippyFile, null, 2)}, itemRects: ${JSON.stringify(itemRects, null, 2)}, flippyRects: ${JSON.stringify(flippyRects, null, 2)}, questRects: ${JSON.stringify(questRects, null, 2)}, images: ${JSON.stringify(imagesMap, null, 2)}, automapAtlases: AUTOMAP_UI_MANIFEST.atlases, } 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, public/assets/ui/automap/manifest.json, and src/ui/baked-ui-meta.ts.') } main().catch((err) => { console.error('pack-ui failed:', err) process.exit(1) })