895 lines
33 KiB
TypeScript
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)
|
|
})
|