/** * 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 { 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 => { 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 = {} { 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> = {} 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 = {} 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 = {} 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) })