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

947 lines
36 KiB
TypeScript

/**
* Offline FrontEnd / Battle.net / CharSelect / Campfire / Loading Screen Asset Packer.
*
* Strictly follows Diablo II v1.13c decompiled binary assembly:
* - D2Launch.dll (Main Menu 0x6fa598b0, Bnet Login 0x6fa51440, CharCreate 0x6fa5d780, CharSelect 0x6fa4af00/0x6fa627e0)
* - D2Multi.dll (Battle.net Lobby & Create Game 0x6f9d8c90)
* - D2Client.dll (10-frame Door Loading Screen 0x6fab24e0)
* - D2Win.dll & D2Gdi.dll (D2Gfx_DrawChar 0x6f876580..0x6f87658f textShifts & D2WinAnimImage 0x6f8f0a50)
*
* Extracts all authentic DC6 sprites, palettes, colored bitmap font atlases,
* 7-class campfire animation strips, 7-class town-neutral (TN) avatar strips,
* and English TBL strings into `public/ui/frontend/`.
*/
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 { applyTransform, decodePl2, type Pl2, type Pl2Transform } from '../src/formats/pl2.ts'
import { decodeTbl } from '../src/formats/tbl.ts'
import { loadCharacterSheet } from '../src/game/character.ts'
import { encodeIndexedPng } from './png.ts'
import type { FontAtlasMeta, FontGlyphMetric } from './pack-ui-assets.ts'
interface BlitPlacement {
readonly frame: SpriteFrame
readonly x: number
readonly y: number
}
export interface PackedStripFrameMeta {
readonly x: number
readonly y: number
readonly w: number
readonly h: number
readonly anchorX: number
readonly anchorY: number
}
export interface PackedAnimStripMeta {
readonly file: string
readonly width: number
readonly height: number
readonly frameCount: number
readonly frames: readonly PackedStripFrameMeta[]
}
/**
* 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[],
transparentZero = true,
): 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 (transparentZero && frame.mask[srcIdx] === 0) continue
const color = frame.indices[srcIdx] ?? 0
if (!transparentZero || color !== 0) {
out[ty * width + tx] = color
}
}
}
}
return out
}
/**
* Stitch a 12-frame (4x3 grid: 256+256+256+32 = 800, 256+256+88 = 600) full-screen DC6 background.
*/
function stitch800x600Background(sheet: SpriteSheet): Uint8Array {
const f = sheet.groups[0]!.frames
if (f.length < 12) {
throw new Error(`Expected at least 12 frames for 800x600 background, got ${f.length}`)
}
const colX = [0, 256, 512, 768]
const rowY = [0, 256, 512]
const placements: BlitPlacement[] = []
for (let r = 0; r < 3; r++) {
for (let c = 0; c < 4; c++) {
placements.push({
frame: f[r * 4 + c]!,
x: colX[c]!,
y: rowY[r]!,
})
}
}
return stitchFrames(800, 600, placements, false)
}
/**
* Stitch pairs of horizontally split button frames (e.g. WideButtonBlank 256+16 = 272).
*/
function stitchButtonStates(
sheet: SpriteSheet,
chunksPerState: number,
): {
width: number
height: number
pixels: Uint8Array
stateWidth: number
stateHeight: number
stateCount: number
} {
const frames = sheet.groups[0]!.frames
const stateCount = Math.min(2, Math.floor(frames.length / chunksPerState))
let stateWidth = 0
for (let c = 0; c < chunksPerState; c++) {
stateWidth += frames[c]!.width
}
const stateHeight = frames[0]!.height
const totalWidth = stateWidth * stateCount
const placements: BlitPlacement[] = []
for (let s = 0; s < stateCount; s++) {
let curX = s * stateWidth
for (let c = 0; c < chunksPerState; c++) {
const frame = frames[s * chunksPerState + c]!
placements.push({ frame, x: curX, y: 0 })
curX += frame.width
}
}
return {
width: totalWidth,
height: stateHeight,
pixels: stitchFrames(totalWidth, stateHeight, placements),
stateWidth,
stateHeight,
stateCount,
}
}
/**
* Composite an optional non-additive overlay SpriteSheet onto a base SpriteSheet frame-by-frame
* in top-left anchor-relative coordinates (anchorX = left, anchorY = top).
*/
function compositeOverlayFrames(
baseFrames: readonly SpriteFrame[],
overlayFrames: readonly SpriteFrame[] | null,
): SpriteFrame[] {
const count = baseFrames.length
const result: SpriteFrame[] = []
for (let i = 0; i < count; i++) {
const base = baseFrames[i]!
const ov = overlayFrames && i < overlayFrames.length ? overlayFrames[i]! : null
if (!ov) {
result.push(base)
continue
}
const baseLeft = base.anchorX ?? 0
const baseTop = base.anchorY ?? 0
const baseRight = baseLeft + base.width
const baseBottom = baseTop + base.height
const ovLeft = ov.anchorX ?? 0
const ovTop = ov.anchorY ?? 0
const ovRight = ovLeft + ov.width
const ovBottom = ovTop + ov.height
const minLeft = Math.min(baseLeft, ovLeft)
const minTop = Math.min(baseTop, ovTop)
const maxRight = Math.max(baseRight, ovRight)
const maxBottom = Math.max(baseBottom, ovBottom)
const unionW = Math.max(1, maxRight - minLeft)
const unionH = Math.max(1, maxBottom - minTop)
const indices = new Uint8Array(unionW * unionH)
const mask = new Uint8Array(unionW * unionH)
// Blit base
const bx0 = baseLeft - minLeft
const by0 = baseTop - minTop
for (let sy = 0; sy < base.height; sy++) {
for (let sx = 0; sx < base.width; sx++) {
const sIdx = sy * base.width + sx
if (base.mask[sIdx] === 0) continue
const c = base.indices[sIdx] ?? 0
if (c === 0) continue
const dIdx = (by0 + sy) * unionW + (bx0 + sx)
indices[dIdx] = c
mask[dIdx] = 1
}
}
// Blit overlay
const ox0 = ovLeft - minLeft
const oy0 = ovTop - minTop
for (let sy = 0; sy < ov.height; sy++) {
for (let sx = 0; sx < ov.width; sx++) {
const sIdx = sy * ov.width + sx
if (ov.mask[sIdx] === 0) continue
const srcColor = ov.indices[sIdx] ?? 0
if (srcColor === 0) continue
const dIdx = (oy0 + sy) * unionW + (ox0 + sx)
indices[dIdx] = srcColor
mask[dIdx] = 1
}
}
result.push({
width: unionW,
height: unionH,
anchorX: minLeft,
anchorY: minTop,
indices,
mask,
})
}
return result
}
/**
* Pack a sequence of SpriteFrames into a grid sprite sheet (max width ~2048) to keep texture sizes compact.
*/
function packFramesToGrid(
file: string,
frames: readonly SpriteFrame[],
maxRowWidth = 2048,
): {
meta: PackedAnimStripMeta
pixels: Uint8Array
} {
let curX = 0
let curY = 0
let rowH = 0
let maxW = 1
const placements: BlitPlacement[] = []
const frameMetas: PackedStripFrameMeta[] = []
for (const frame of frames) {
if (curX > 0 && curX + frame.width > maxRowWidth) {
curX = 0
curY += rowH + 1
rowH = 0
}
placements.push({ frame, x: curX, y: curY })
frameMetas.push({
x: curX,
y: curY,
w: frame.width,
h: frame.height,
anchorX: frame.anchorX ?? 0,
anchorY: frame.anchorY ?? 0,
})
curX += frame.width + 1
if (curX > maxW) maxW = curX
if (frame.height > rowH) rowH = frame.height
}
const totalH = Math.max(1, curY + rowH)
const pixels = stitchFrames(maxW, totalH, placements)
return {
meta: {
file,
width: maxW,
height: totalH,
frameCount: frames.length,
frames: frameMetas,
},
pixels,
}
}
/**
* Build a bitmap font atlas from `data\local\font\latin\font*.dc6` and `.tbl`.
*/
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> {
const t0 = performance.now()
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 skyPl2 = decodePl2(await archives.read('data\\global\\palette\\sky\\pal.pl2'))
const fecharPl2 = decodePl2(await archives.read('data\\global\\palette\\fechar\\pal.pl2'))
const act1Pl2 = decodePl2(await archives.read('data\\global\\palette\\act1\\pal.pl2'))
const loadingPl2Bytes = await archives.read('data\\global\\palette\\loading\\pal.pl2')
const loadingRgb = new Uint8Array(256 * 3)
for (let i = 0; i < 256; i++) {
loadingRgb[i * 3] = loadingPl2Bytes[i * 4] ?? 0
loadingRgb[i * 3 + 1] = loadingPl2Bytes[i * 4 + 1] ?? 0
loadingRgb[i * 3 + 2] = loadingPl2Bytes[i * 4 + 2] ?? 0
}
const outDir = join(process.cwd(), 'public', 'ui', 'frontend')
const fontsDir = join(outDir, 'fonts')
const campfireDir = join(outDir, 'campfire')
const avatarsDir = join(outDir, 'avatars')
mkdirSync(outDir, { recursive: true })
mkdirSync(fontsDir, { recursive: true })
mkdirSync(campfireDir, { recursive: true })
mkdirSync(avatarsDir, { recursive: true })
let decodedCount = 0
const decodeUiDc6 = async (path: string): Promise<SpriteSheet> => {
const bytes = await archives.read(path)
const sheet = decodeDc6(bytes)
decodedCount++
for (const g of sheet.groups) {
for (const f of g.frames) {
;(f as { anchorX?: number; anchorY?: number }).anchorX = f.offsetX
;(f as { anchorX?: number; anchorY?: number }).anchorY = f.offsetY - f.height
}
}
return sheet
}
const savePng = (
relPath: string,
width: number,
height: number,
pixels: Uint8Array,
palette: Uint8Array,
transparent = true,
): void => {
const png = encodeIndexedPng({
width,
height,
pixels,
palette,
transparentIndex: transparent ? 0 : undefined,
})
writeFileSync(join(outDir, relPath), png)
}
// ============================================================================
// 1. 800x600 Full-Screen Backgrounds
// ============================================================================
console.log('Packing 800x600 full-screen backgrounds...')
const mainBgDc6 = await decodeUiDc6('data\\global\\ui\\FrontEnd\\gameselectscreenEXP.dc6')
const mainBgPixels = stitch800x600Background(mainBgDc6)
savePng('bg-main-menu.png', 800, 600, mainBgPixels, skyPl2.rgb, false)
const charCreateBgDc6 = await decodeUiDc6('data\\global\\ui\\FrontEnd\\charactercreationscreenEXP.dc6')
const charCreateBgPixels = stitch800x600Background(charCreateBgDc6)
savePng('bg-char-create.png', 800, 600, charCreateBgPixels, fecharPl2.rgb, false)
const charSelectBgDc6 = await decodeUiDc6('data\\global\\ui\\CharSelect\\characterselectscreenEXP.dc6')
const charSelectBgPixels = stitch800x600Background(charSelectBgDc6)
savePng('bg-char-select.png', 800, 600, charSelectBgPixels, skyPl2.rgb, false)
const waitingRoomBgDc6 = await decodeUiDc6('data\\global\\ui\\BIGMENU\\waitingroombkgd.dc6')
const waitingRoomBgPixels = stitch800x600Background(waitingRoomBgDc6)
savePng('bg-waiting-room.png', 800, 600, waitingRoomBgPixels, act1Pl2.rgb, false)
// ============================================================================
// 2. Dialogs, Popups & Selection Boxes
// ============================================================================
console.log('Packing dialogs, popups, and buttons...')
const popupDc6 = await decodeUiDc6('data\\global\\ui\\FrontEnd\\PopUp_340x224.dc6')
const pf = popupDc6.groups[0]!.frames
const popupPixels = stitchFrames(340, 224, [
{ frame: pf[0]!, x: 0, y: 0 },
{ frame: pf[1]!, x: 256, y: 0 },
], false)
savePng('popup-340x224.png', 340, 224, popupPixels, skyPl2.rgb, false)
const createGameBgDc6 = await decodeUiDc6('data\\global\\ui\\BIGMENU\\creategamebckg.dc6')
const cgf = createGameBgDc6.groups[0]!.frames
const createGameBgPixels = stitchFrames(373, 373, [
{ frame: cgf[0]!, x: 0, y: 0 },
{ frame: cgf[1]!, x: 256, y: 0 },
{ frame: cgf[2]!, x: 0, y: 256 },
{ frame: cgf[3]!, x: 256, y: 256 },
], false)
savePng('create-game-bg.png', 373, 373, createGameBgPixels, act1Pl2.rgb, false)
const charSelectBoxDc6 = await decodeUiDc6('data\\global\\ui\\CharSelect\\charselectbox.dc6')
const csbf = charSelectBoxDc6.groups[0]!.frames
const charSelectBoxPixels = stitchFrames(272, 93, [
{ frame: csbf[0]!, x: 0, y: 0 },
{ frame: csbf[1]!, x: 256, y: 0 },
], true)
savePng('charselectbox.png', 272, 93, charSelectBoxPixels, skyPl2.rgb, true)
const realmSelectDc6 = await decodeUiDc6('data\\global\\ui\\CharSelect\\Realmselect.dc6')
const rsf = realmSelectDc6.groups[0]!.frames[0]!
savePng('realmselect.png', rsf.width, rsf.height, rsf.indices, skyPl2.rgb, true)
const realmBtnDc6 = await decodeUiDc6('data\\global\\ui\\CharSelect\\RealmSelectbuttonthin.dc6')
const realmBtnStrip = stitchButtonStates(realmBtnDc6, 1)
savePng('btn-realm-thin.png', realmBtnStrip.width, realmBtnStrip.height, realmBtnStrip.pixels, skyPl2.rgb, true)
// ============================================================================
// 3. Buttons, Textboxes, Checkboxes, Radio Buttons & Chat Gem
// ============================================================================
const wideBtnDc6 = await decodeUiDc6('data\\global\\ui\\FrontEnd\\WideButtonBlank.dc6')
const wideBtnStrip = stitchButtonStates(wideBtnDc6, 2) // 272x35 x 2 states = 544x35
savePng('btn-wide.png', wideBtnStrip.width, wideBtnStrip.height, wideBtnStrip.pixels, skyPl2.rgb, true)
const wide3BtnDc6 = await decodeUiDc6('data\\global\\ui\\FrontEnd\\3WideButtonBlank.dc6')
const wide3BtnStrip = stitchButtonStates(wide3BtnDc6, 2) // 272x35 x 2 states = 544x35
savePng('btn-3wide.png', wide3BtnStrip.width, wide3BtnStrip.height, wide3BtnStrip.pixels, skyPl2.rgb, true)
const wide02BtnDc6 = await decodeUiDc6('data\\global\\ui\\FrontEnd\\WideButtonBlank02.dc6')
const wide02BtnStrip = stitchButtonStates(wide02BtnDc6, 2) // 272x25 x 2 states = 544x25
savePng('btn-wide02.png', wide02BtnStrip.width, wide02BtnStrip.height, wide02BtnStrip.pixels, skyPl2.rgb, true)
const medBtnDc6 = await decodeUiDc6('data\\global\\ui\\FrontEnd\\MediumButtonBlank.dc6')
const medBtnStrip = stitchButtonStates(medBtnDc6, 1) // 128x35 x 2 states = 256x35
savePng('btn-medium.png', medBtnStrip.width, medBtnStrip.height, medBtnStrip.pixels, skyPl2.rgb, true)
const shortBtnDc6 = await decodeUiDc6('data\\global\\ui\\FrontEnd\\NarrowButtonBlank.dc6')
const shortBtnStrip = stitchButtonStates(shortBtnDc6, 1) // 135x25 x 2 states = 270x25
savePng('btn-short.png', shortBtnStrip.width, shortBtnStrip.height, shortBtnStrip.pixels, skyPl2.rgb, true)
const tallBtnDc6 = await decodeUiDc6('data\\global\\ui\\CharSelect\\TallButtonBlank.dc6')
const tallBtnStrip = stitchButtonStates(tallBtnDc6, 1) // 168x60 x 2 states = 336x60
savePng('btn-tall.png', tallBtnStrip.width, tallBtnStrip.height, tallBtnStrip.pixels, skyPl2.rgb, true)
const popupOkCancelDc6 = await decodeUiDc6('data\\global\\ui\\FrontEnd\\PopUpOkCancel2.dc6')
const pocf = popupOkCancelDc6.groups[0]!.frames
const popupOkCancelPixels = stitchFrames(264, 176, [
{ frame: pocf[0]!, x: 0, y: 0 },
{ frame: pocf[1]!, x: 256, y: 0 },
], false)
savePng('popup-okcancel.png', 264, 176, popupOkCancelPixels, skyPl2.rgb, false)
const textboxDc6 = await decodeUiDc6('data\\global\\ui\\FrontEnd\\textbox2.dc6')
const tbFrame = textboxDc6.groups[0]!.frames[0]!
savePng('textbox.png', tbFrame.width, tbFrame.height, tbFrame.indices, skyPl2.rgb, true)
const clickboxDc6 = await decodeUiDc6('data\\global\\ui\\FrontEnd\\clickbox.dc6')
const clickboxStrip = stitchButtonStates(clickboxDc6, 1) // 15x16 x 2 states = 30x16
savePng('clickbox.png', clickboxStrip.width, clickboxStrip.height, clickboxStrip.pixels, fecharPl2.rgb, true)
const joinClickboxDc6 = await decodeUiDc6('data\\global\\ui\\BIGMENU\\joingameclickbox.dc6')
const joinClickboxStrip = stitchButtonStates(joinClickboxDc6, 1)
savePng('joingameclickbox.png', joinClickboxStrip.width, joinClickboxStrip.height, joinClickboxStrip.pixels, act1Pl2.rgb, true)
const radioDc6 = await decodeUiDc6('data\\global\\ui\\BIGMENU\\radiobutton.dc6')
const radioStrip = stitchButtonStates(radioDc6, 1) // 16x16 x 2 states = 32x16
savePng('radiobutton.png', radioStrip.width, radioStrip.height, radioStrip.pixels, act1Pl2.rgb, true)
const gemBtnDc6 = await decodeUiDc6('data\\global\\ui\\BIGMENU\\gembutton.dc6')
const gemBtnStrip = stitchButtonStates(gemBtnDc6, 1) // 2 states
savePng('gembutton.png', gemBtnStrip.width, gemBtnStrip.height, gemBtnStrip.pixels, act1Pl2.rgb, true)
const chatLeftBtnsDc6 = await decodeUiDc6('data\\global\\ui\\BIGMENU\\chatleftbuttons.dc6')
const chatLeftBtnsStrip = stitchButtonStates(chatLeftBtnsDc6, 1) // 80x20 x 2 states
savePng('btn-chatleft.png', chatLeftBtnsStrip.width, chatLeftBtnsStrip.height, chatLeftBtnsStrip.pixels, act1Pl2.rgb, true)
const chatRightTopBtnsDc6 = await decodeUiDc6('data\\global\\ui\\BIGMENU\\chatrighttopbuttons.dc6')
const chatRightTopBtnsStrip = stitchButtonStates(chatRightTopBtnsDc6, 1) // 119x20 x 2 states
savePng('btn-chatrighttop.png', chatRightTopBtnsStrip.width, chatRightTopBtnsStrip.height, chatRightTopBtnsStrip.pixels, act1Pl2.rgb, true)
const chatRightBtnsDc6 = await decodeUiDc6('data\\global\\ui\\BIGMENU\\chatrightbuttons.dc6')
const chatRightBtnsStrip = stitchButtonStates(chatRightBtnsDc6, 1) // 79x20 x 2 states
savePng('btn-chatright.png', chatRightBtnsStrip.width, chatRightBtnsStrip.height, chatRightBtnsStrip.pixels, act1Pl2.rgb, true)
const gameBtnDc6 = await decodeUiDc6('data\\global\\ui\\BIGMENU\\GameButtonBlank.dc6')
const gameBtnStrip = stitchButtonStates(gameBtnDc6, 1) // 172x32 x 2 states
savePng('btn-game.png', gameBtnStrip.width, gameBtnStrip.height, gameBtnStrip.pixels, act1Pl2.rgb, true)
const btnCancelDc6 = await decodeUiDc6('data\\global\\ui\\BIGMENU\\CancelButtonBlank.dc6')
const btnCancelStrip = stitchButtonStates(btnCancelDc6, 1) // 96x32 x 2 states
savePng('btn-create-cancel.png', btnCancelStrip.width, btnCancelStrip.height, btnCancelStrip.pixels, act1Pl2.rgb, true)
const btnCreateGameDc6 = await decodeUiDc6('data\\global\\ui\\BIGMENU\\creategamebutton.dc6')
const btnCreateGameStrip = stitchButtonStates(btnCreateGameDc6, 1) // 144x36 x 2 states
savePng('btn-create-game.png', btnCreateGameStrip.width, btnCreateGameStrip.height, btnCreateGameStrip.pixels, act1Pl2.rgb, true)
const greyLevelBoxDc6 = await decodeUiDc6('data\\global\\ui\\BIGMENU\\GreyLevelBox.dc6')
const glbFrame = greyLevelBoxDc6.groups[0]!.frames[0]!
savePng('grey-level-box.png', glbFrame.width, glbFrame.height, glbFrame.indices, act1Pl2.rgb, true)
// ============================================================================
// 4. Burning Diablo II Logos (Main Menu & Battle.net Lobby) + Campfire Flames
// ============================================================================
console.log('Packing burning Diablo II logos and campfire flame animations...')
// Main Menu Burning Logo at control anchor (400, 120) on gameselectscreenEXP.dc6
// Note: D2WinAnimImage with ptAnimTable == NULL subtracts frame0.offset from each frame!
const logoBlackLDc6 = await decodeUiDc6('data\\global\\ui\\FrontEnd\\D2logoBlackLeft.DC6')
const logoBlackRDc6 = await decodeUiDc6('data\\global\\ui\\FrontEnd\\D2logoBlackRight.DC6')
const logoFireLDc6 = await decodeUiDc6('data\\global\\ui\\FrontEnd\\D2logoFireLeft.DC6')
const logoFireRDc6 = await decodeUiDc6('data\\global\\ui\\FrontEnd\\D2logoFireRight.DC6')
const animStrips: Record<string, PackedAnimStripMeta> = {}
{
const blackFrames = compositeOverlayFrames(
logoBlackLDc6.groups[0]!.frames,
logoBlackRDc6.groups[0]!.frames,
)
const packedBlack = packFramesToGrid('logo-main-black.png', blackFrames, 2048)
savePng(packedBlack.meta.file, packedBlack.meta.width, packedBlack.meta.height, packedBlack.pixels, skyPl2.rgb, true)
animStrips['logoMainBlack'] = packedBlack.meta
const fireFrames = compositeOverlayFrames(
logoFireLDc6.groups[0]!.frames,
logoFireRDc6.groups[0]!.frames,
)
const packedFire = packFramesToGrid('logo-main-fire.png', fireFrames, 2048)
savePng(packedFire.meta.file, packedFire.meta.width, packedFire.meta.height, packedFire.pixels, skyPl2.rgb, true)
animStrips['logoMainFire'] = packedFire.meta
}
// Battle.net Small Burning Logo at control anchor (84, 52) on waitingroombkgd.dc6
const bnetBlackDc6 = await decodeUiDc6('data\\global\\ui\\BIGMENU\\D2logoBlackBnet.dc6')
const bnetFireDc6 = await decodeUiDc6('data\\global\\ui\\BIGMENU\\D2logoBnet.dc6')
{
const packedBlack = packFramesToGrid('logo-bnet-black.png', bnetBlackDc6.groups[0]!.frames, 2048)
savePng(packedBlack.meta.file, packedBlack.meta.width, packedBlack.meta.height, packedBlack.pixels, skyPl2.rgb, true)
animStrips['logoBnetBlack'] = packedBlack.meta
const packedFire = packFramesToGrid('logo-bnet-fire.png', bnetFireDc6.groups[0]!.frames, 2048)
savePng(packedFire.meta.file, packedFire.meta.width, packedFire.meta.height, packedFire.pixels, skyPl2.rgb, true)
animStrips['logoBnetFire'] = packedFire.meta
}
// Campfire `fire.dc6` at control anchor (345, 454) on charactercreationscreenEXP.dc6
const fireDc6 = await decodeUiDc6('data\\global\\ui\\FrontEnd\\fire.dc6')
{
const packed = packFramesToGrid('campfire/fire-strip.png', fireDc6.groups[0]!.frames, 2048)
savePng(packed.meta.file, packed.meta.width, packed.meta.height, packed.pixels, fecharPl2.rgb, true)
animStrips['campfireFlame'] = packed.meta
}
// ============================================================================
// 5. 7-Class Campfire Multi-State Animations (fechar/pal.pl2)
// ============================================================================
console.log('Packing 7-class campfire multi-state animations...')
// Per D2Launch.dll 0x6fa68df8 & 0x6fa5d780:
// Note: ptAnimTable != NULL does NOT subtract frame0.offset!
interface ClassCampfireSpec {
readonly id: string
readonly dir: string
readonly prefix: string
readonly states: readonly {
readonly key: string
readonly base: string
readonly overlay?: string
readonly additiveOverlay?: boolean
}[]
}
const CAMPFIRE_SPECS: readonly ClassCampfireSpec[] = [
{
id: 'am',
dir: 'amazon',
prefix: 'am',
states: [
{ key: 'nu1', base: 'amnu1.dc6' },
{ key: 'nu2', base: 'amnu2.dc6' },
{ key: 'nu3', base: 'amnu3.dc6' },
{ key: 'fw', base: 'amfw.dc6', overlay: 'amfws.dc6', additiveOverlay: false },
{ key: 'bw', base: 'ambw.dc6' },
],
},
{
id: 'as',
dir: 'assassin',
prefix: 'as',
states: [
{ key: 'nu1', base: 'asnu1.dc6' },
{ key: 'nu2', base: 'asnu2.dc6' },
{ key: 'nu3', base: 'asnu3.dc6' },
{ key: 'fw', base: 'asfw.dc6' },
{ key: 'bw', base: 'asbw.dc6' },
],
},
{
id: 'ne',
dir: 'necromancer',
prefix: 'ne',
states: [
{ key: 'nu1', base: 'nenu1.dc6' },
{ key: 'nu2', base: 'nenu2.dc6' },
{ key: 'nu3', base: 'nenu3.dc6', overlay: 'nenu3s.dc6', additiveOverlay: true },
{ key: 'fw', base: 'nefw.dc6', overlay: 'nefws.dc6', additiveOverlay: true },
{ key: 'bw', base: 'nebw.dc6', overlay: 'nebws.dc6', additiveOverlay: true },
],
},
{
id: 'ba',
dir: 'barbarian',
prefix: 'ba',
states: [
{ key: 'nu1', base: 'banu1.dc6' },
{ key: 'nu2', base: 'banu2.dc6' },
{ key: 'nu3', base: 'banu3.dc6' },
{ key: 'fw', base: 'bafw.dc6', overlay: 'bafws.dc6', additiveOverlay: false },
{ key: 'bw', base: 'babw.dc6' },
],
},
{
id: 'pa',
dir: 'paladin',
prefix: 'pa',
states: [
{ key: 'nu1', base: 'panu1.dc6' },
{ key: 'nu2', base: 'panu2.dc6' },
{ key: 'nu3', base: 'panu3.dc6' },
{ key: 'fw', base: 'pafw.dc6', overlay: 'pafws.dc6', additiveOverlay: false },
{ key: 'bw', base: 'pabw.dc6' },
],
},
{
id: 'so',
dir: 'sorceress',
prefix: 'so',
states: [
{ key: 'nu1', base: 'sonu1.dc6' },
{ key: 'nu2', base: 'sonu2.dc6' },
{ key: 'nu3', base: 'sonu3.dc6', overlay: 'sonu3s.dc6', additiveOverlay: true },
{ key: 'fw', base: 'sofw.dc6', overlay: 'sofws.dc6', additiveOverlay: true },
{ key: 'bw', base: 'sobw.dc6', overlay: 'sobws.dc6', additiveOverlay: true },
],
},
{
id: 'dz',
dir: 'druid',
prefix: 'dz',
states: [
{ key: 'nu1', base: 'dznu1.dc6' },
{ key: 'nu2', base: 'dznu2.dc6' },
{ key: 'nu3', base: 'dznu3.dc6' },
{ key: 'fw', base: 'dzfw.dc6' },
{ key: 'bw', base: 'dzbw.dc6' },
],
},
]
const campfireClasses: Record<string, Record<string, PackedAnimStripMeta>> = {}
for (const cls of CAMPFIRE_SPECS) {
campfireClasses[cls.id] = {}
for (const st of cls.states) {
const basePath = `data\\global\\ui\\FrontEnd\\${cls.dir}\\${st.base}`
const baseDc6 = await decodeUiDc6(basePath)
const ovDc6 = st.overlay
? await decodeUiDc6(`data\\global\\ui\\FrontEnd\\${cls.dir}\\${st.overlay}`)
: null
if (ovDc6 && !st.additiveOverlay) {
const composited = compositeOverlayFrames(baseDc6.groups[0]!.frames, ovDc6.groups[0]!.frames)
const relFile = `campfire/${cls.id}-${st.key}.png`
const packed = packFramesToGrid(relFile, composited, 2048)
savePng(relFile, packed.meta.width, packed.meta.height, packed.pixels, fecharPl2.rgb, true)
campfireClasses[cls.id]![st.key] = packed.meta
} else {
const relFile = `campfire/${cls.id}-${st.key}.png`
const packed = packFramesToGrid(relFile, baseDc6.groups[0]!.frames, 2048)
savePng(relFile, packed.meta.width, packed.meta.height, packed.pixels, fecharPl2.rgb, true)
campfireClasses[cls.id]![st.key] = packed.meta
if (ovDc6 && st.additiveOverlay) {
const ovRelFile = `campfire/${cls.id}-${st.key}-ov.png`
const ovPacked = packFramesToGrid(ovRelFile, ovDc6.groups[0]!.frames, 2048)
savePng(ovRelFile, ovPacked.meta.width, ovPacked.meta.height, ovPacked.pixels, fecharPl2.rgb, true)
campfireClasses[cls.id]![`${st.key}_ov`] = ovPacked.meta
}
}
}
}
// ============================================================================
// 6. 10-Frame Door Opening Loading Screen (`Loadingscreen.dc6` + `loading/pal.pl2`)
// ============================================================================
console.log('Packing 10-frame Door Opening Loading Screen...')
const loadingDc6 = await decodeUiDc6('data\\global\\ui\\Loading\\Loadingscreen.dc6')
const loadingPacked = packFramesToGrid('loading-door-strip.png', loadingDc6.groups[0]!.frames, 2560)
savePng(
loadingPacked.meta.file,
loadingPacked.meta.width,
loadingPacked.meta.height,
loadingPacked.pixels,
loadingRgb,
false,
)
animStrips['loadingDoor'] = loadingPacked.meta
// ============================================================================
// 7. 7-Class Town-Neutral (`TN`) Character Selection Avatars (Direction 0 = South)
// ============================================================================
console.log('Packing 7-class Town-Neutral (TN) Character Selection avatar strips...')
const AVATAR_TOKENS = ['am', 'ai', 'ne', 'ba', 'pa', 'so', 'dz'] as const
const charAvatars: Record<string, PackedAnimStripMeta> = {}
for (const token of AVATAR_TOKENS) {
const tnSheet = await loadCharacterSheet(archives, token, 'tn', 'hth', undefined, true)
// Group 0 in loadCharacterSheet is direction 0 (dir64=0 -> dccDir=4 = straight South towards camera)
const southFrames = tnSheet.sheet.groups[0]!.frames
const relFile = `avatars/char-${token}-tn.png`
const packed = packFramesToGrid(relFile, southFrames, 2048)
savePng(relFile, packed.meta.width, packed.meta.height, packed.pixels, skyPl2.rgb, true)
charAvatars[token] = packed.meta
}
// ============================================================================
// 8. Colored Bitmap Font Atlases (`D2Gfx_DrawChar` 0x6f876580..0x6f87658f)
// ============================================================================
// Color 0 = Base skyPl2.rgb (White / Bright Engraved)
// Color 1..12 = applyTransform(skyPl2.rgb, skyPl2.textShifts[color])
// 1 = Red (Hardcore / Error)
// 2 = Bright Green (Expansion Character)
// 3 = Blue
// 4 = Gold / Bronze (Engraved Button Labels, Titles, Character Names)
// 5 = Dark Gray (Disabled Button Labels)
// 7 = Tan
// 8 = Orange
// 9 = Yellow
console.log('Packing multi-shade chiseled bitmap font atlases...')
const FONT_NAMES = [
'font6',
'font8',
'font16',
'font24',
'font30',
'font42',
'fontexocet8',
'fontexocet10',
'fontformal10',
'fontformal11',
'fontformal12',
'fontridiculous',
] as const
const FONT_COLORS = [0, 1, 2, 3, 4, 5, 7, 8, 9] as const
const fontMetas: Record<string, FontAtlasMeta> = {}
for (const fn of FONT_NAMES) {
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
for (const c of FONT_COLORS) {
const pal = c === 0 ? skyPl2.rgb : applyTransform(skyPl2.rgb, skyPl2.textShifts[c]!)
savePng(
`fonts/${fn}-c${c}.png`,
built.meta.atlasWidth,
built.meta.atlasHeight,
built.pixels,
pal,
true,
)
}
}
writeFileSync(join(fontsDir, 'metrics.json'), JSON.stringify(fontMetas, null, 2))
// ============================================================================
// 9. Canonical English Strings from string.tbl / expansionstring.tbl / patchstring.tbl
// ============================================================================
console.log('Extracting canonical English TBL strings...')
const strBase = decodeTbl(await archives.read('data\\local\\lng\\eng\\string.tbl'))
const strExp = decodeTbl(await archives.read('data\\local\\lng\\eng\\expansionstring.tbl'))
const strPatch = decodeTbl(await archives.read('data\\local\\lng\\eng\\patchstring.tbl'))
const lookupByKey = (key: string): string | undefined => {
for (const table of [strPatch, strExp, strBase]) {
const found = table.dict.get(key)
if (found) return found
}
return undefined
}
const strings = {
singlePlayer: lookupByKey('strSinglePlayer') ?? 'SINGLE PLAYER',
battleNet: lookupByKey('strBattleNet') ?? 'BATTLE.NET',
otherMultiplayer: lookupByKey('strOtherMultiplayer') ?? 'OTHER MULTIPLAYER',
exitDiablo2: lookupByKey('strExitDiablo2') ?? 'EXIT DIABLO II',
credits: 'CREDITS',
cinematics: 'CINEMATICS',
gatewayPrefix: 'GATEWAY: ',
connectingToFastest: 'Connecting to the fastest server...',
checkingVersions: 'Checking versions...',
accessingAccount: 'Accessing your account...',
accountName: 'Account Name',
password: 'Password',
logIn: 'LOG IN',
accountSettings: 'ACCOUNT SETTINGS',
createNewAccount: 'CREATE NEW ACCOUNT',
exit: 'EXIT',
ok: 'OK',
cancel: 'CANCEL',
chooseClassTitle: 'Choose a class for your campaign',
characterName: 'Character Name',
expansionCharacter: 'EXPANSION CHARACTER',
hardcore: 'HARDCORE',
ladderCharacter: 'LADDER CHARACTER',
selectCharacterTitle: 'Select a Character',
createNewCharacter: 'CREATE NEW\nCHARACTER',
convertToExpansion: 'CONVERT TO\nEXPANSION',
deleteCharacter: 'DELETE\nCHARACTER',
expiresIn90Days: 'Expires in 90 days',
classes: {
am: {
name: lookupByKey('Amazon') ?? 'Amazon',
desc:
lookupByKey('strAmazonDesc') ??
'Skilled with the spear and the bow, she is a very versatile fighter.',
},
as: {
name: lookupByKey('Assassin') ?? 'Assassin',
desc:
lookupByKey('AssasinDesc') ??
'Schooled in the Martial Arts, her mind and body are deadly weapons.',
},
ne: {
name: lookupByKey('Necromancer') ?? 'Necromancer',
desc:
lookupByKey('strNecroDesc') ??
'Summoning undead minions and cursing his enemies are his specialties.',
},
ba: {
name: lookupByKey('Barbarian') ?? 'Barbarian',
desc:
lookupByKey('strBarbDesc') ??
'He is unequaled in close-quarters combat and mastery of weapons.',
},
pa: {
name: lookupByKey('Paladin') ?? 'Paladin',
desc:
lookupByKey('strPaladinDesc') ??
'He is a natural party leader, holy man, and blessed warrior.',
},
so: {
name: lookupByKey('Sorceress') ?? 'Sorceress',
desc:
lookupByKey('strSorcDesc') ??
'She has mastered the elemental magics -- fire, lightning, and ice.',
},
dz: {
name: lookupByKey('Druid') ?? 'Druid',
desc:
lookupByKey('DruidDesc') ??
'Commanding the forces of nature, he summons wild beasts and raging storms to his side.',
},
},
lobby: {
send: 'SEND',
whisper: 'WHISPER',
help: 'HELP',
squelch: 'SQUELCH',
unsquelch: 'UNSQUELCH',
emote: 'EMOTE',
create: 'CREATE',
join: 'JOIN',
ladder: 'LADDER',
channel: 'CHANNEL',
quit: 'QUIT',
createGameTitle: 'Create Game',
gameName: 'Game Name',
gamePassword: 'Password',
gameDescription: 'Game Description',
maxPlayers: 'Max Number of Players',
charDifference: 'Character Difference',
selectDifficulty: 'Select Difficulty',
normal: 'Normal',
nightmare: 'Nightmare',
hell: 'Hell',
createGameBtn: 'CREATE GAME',
gemActivated: 'Gem Activated',
gemDeactivated: 'Gem Deactivated',
},
}
const manifest = {
version: '1.13c',
decodedDc6Count: decodedCount,
animStrips,
campfireClasses,
charAvatars,
fontColors: FONT_COLORS,
strings,
}
writeFileSync(join(outDir, 'manifest.json'), JSON.stringify(manifest, null, 2))
console.log(
`Successfully packed ${decodedCount} DC6 sheets + 7 TN character avatars + ${FONT_NAMES.length * FONT_COLORS.length} colored font atlases in ${(performance.now() - t0).toFixed(0)} ms!`,
)
}
main().catch(err => {
console.error('pack-frontend-ui failed:', err)
process.exit(1)
})