diablo2-web/scripts/pack-ui.ts

895 lines
33 KiB
TypeScript

/**
* 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<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
let decodeFailures = 0
const decodeUiDc6 = async (path: string): Promise<SpriteSheet> => {
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 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<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))
// --- STAGE 2: Comprehensive Items & Unique Items Packing ---
console.log('Extracting item sprites (including uniqueitems.txt & setitems.txt)...')
const codeToInvFile: Record<string, string> = {}
const uniqueInvFiles = new Set<string>()
// 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
}
}
}
// 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'
}
// 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<string, SpriteRect> = {}
const itemPlacements: BlitPlacement[] = []
const decodedBaseFrames = new Map<string, SpriteFrame>()
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<string, number> = {
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<string, SpriteRect> = {}
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<string, string> = {
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',
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',
}
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<string, string>
readonly itemRects: Record<string, SpriteRect>
readonly questRects: Record<string, SpriteRect>
readonly images: Record<string, string>
}
/**
* 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<Record<number, readonly QuestAssetInfo[]>> = {
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)
})