diablo2-web/scripts/pack-ui-assets.ts

471 lines
19 KiB
TypeScript

/**
* 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<void> {
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<SpriteSheet> => {
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<string, FontAtlasMeta> = {}
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<string, string> = {}
const uniqueInvFiles = new Set<string>()
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<string, { x: number; y: number; w: number; h: number }> = {}
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)
})