/** * Offline UI / HUD / Font / Item Sprite Extraction & Stitching Script. * * Strictly follows Diablo II v1.13c (D2Client.dll 0x6fad70c0..0x6fad7f40 & D2WinFont.cpp) * to extract and stitch all authentic DC6 panels, borders, globes, buttons, cursors, * bitmap fonts, and inventory item graphics into `public/ui/`. */ 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' interface BlitPlacement { readonly frame: SpriteFrame readonly x: number readonly y: number } /** * 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 } /** * 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: { x: number; y: number; w: number; h: number }[] } { const height = Math.max(1, ...frames.map(f => f.height)) let totalW = 0 const rects: { x: number; y: number; w: number; h: number }[] = [] 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, } } 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[] } /** * 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 const decodeUiDc6 = async (path: string): Promise => { const bytes = await archives.read(path) const sheet = decodeDc6(bytes) decodedCount++ return sheet } 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) } // 1. 800x600 Bottom Control Panel (`Panel\800CtrlPnl7.dc6`) // Per D2Client.dll 0x6fad7422..0x6fad7540: // Frame 0 (117x104) at x=0, y=0 // Frame 6 (128x55) bridges left/right globe inner segments, Frames 1..4 at x=165, 293, 421, 549 (y=49), Frame 5 (117x104) at x=683, y=0 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`) // Left Border (400x553) per D2Client.dll 0x6fad71ed..0x6fad7282: // Frame 0 (256x256) at (0, -3), Frame 1 (144x66) at (256, -3), Frame 2 (85x231) at (0, 253), // Frame 3 (256x71) at (0, 482), Frame 4 (145x71) at (256, 482) 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) // Right Border (400x553, local x = screenX - 400) per D2Client.dll 0x6fad70f1..0x6fad71a8: // Frame 5 (145x66) at (0, -3), Frame 6 (256x256) at (144, -3), Frame 7 (87x231) at (313, 253), // Frame 8 (256x71) at (144, 482), Frame 9 (145x71) at (0, 482) 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, and Equipment Silhouettes 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 miniConvertDc6 = await decodeUiDc6('data\\global\\ui\\Panel\\miniconvert.dc6') const mcFrames = miniConvertDc6.groups[0]?.frames if (!mcFrames || mcFrames.length !== 2 || mcFrames.some(f => f.width !== 32 || f.height !== 32)) { throw new Error('Invalid miniconvert.dc6 frame geometry: expected 2 frames of 32x32') } const mcStrip = stitchHorizontalStrip(mcFrames) savePng('miniconvert.png', mcStrip.width, mcStrip.height, mcStrip.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) 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) 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 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) // 7. Extract Bitmap Fonts (`data\local\font\latin\*.dc6 + *.tbl`) 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)) // 8. Extract Item Inventory Sprites (`data\global\items\inv*.dc6`) const codeToInvFile: Record = {} const uniqueInvFiles = new Set() 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) } } } } // Pack all unique inv*.dc6 into a grid atlas (width 1024) const sortedInvNames = [...uniqueInvFiles].sort() const ATLAS_W = 1024 let curX = 0 let curY = 0 let rowH = 0 const itemRects: Record = {} const itemPlacements: BlitPlacement[] = [] for (const invName of sortedInvNames) { const dc6 = await decodeUiDc6(`data\\global\\items\\${invName}.dc6`) const frame = dc6.groups[0]?.frames[0] if (!frame) continue 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 } const atlasH = Math.max(1, curY + rowH) const itemAtlasPixels = stitchFrames(ATLAS_W, atlasH, itemPlacements) savePng('items-atlas.png', ATLAS_W, atlasH, itemAtlasPixels) const manifest = { version: '1.13c', decodedDc6Count: decodedCount, dc6DecodeFailures: 0, atlasWidth: ATLAS_W, atlasHeight: atlasH, codeToInvFile, itemRects, } writeFileSync(join(outDir, 'manifest.json'), JSON.stringify(manifest, null, 2)) console.log(`Successfully decoded ${decodedCount} DC6 sheets (0 failures) and generated public/ui/ assets!`) } main().catch((err) => { console.error('pack-ui-assets failed:', err) process.exit(1) })