diablo2-web/scripts/pack-tiles.ts

677 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Seed-independent DT1 tile library & runtime DRLG asset packer (Feature F11).
*
* Reads all `LvlTypes.txt` DT1 libraries across Acts 1–5 plus the 3 mandatory
* universal DT1s (`Blank.dt1`, `InvisWal.dt1` with `style=49 sequence=7` collision
* floor, `Warp.dt1`) and hardcoded Act DT1s (`DRLG_HARDCODED_DT1_FILES`) from the
* mounted MPQ archives, decodes every tile's 5x5 sub-tile collision flags and pixel
* blocks, packs all tile frames into per-act 2048x2048 R8 & indexed-PNG atlas pages,
* and writes:
* - `public/act-packs/tiles/act1..act5/{palette.bin, tiles-*.r8, tiles-*.png, index.json}`
* - `public/act-packs/tiles/index.json` (complete seed-independent tile library catalog)
* - `public/act-packs/tiles/{drlg-bundle.json, drlg-bundle.bin}` (compiled `.bin` tables,
* `.ds1` files, header-only `.dt1` files, and level/warp metadata for `map-worker.ts`)
*
* Usage:
* npx tsx scripts/pack-tiles.ts [archive-dir] [output-dir]
*/
import { existsSync } from 'node:fs'
import { mkdir, readdir, unlink, writeFile } from 'node:fs/promises'
import { join, resolve } from 'node:path'
import { pathToFileURL } from 'node:url'
import { decodeDt1, type Dt1Tile } from '../src/formats/dt1.ts'
import { decodePl2 } from '../src/formats/pl2.ts'
import { cell, loadActTables } from '../src/game/acts.ts'
import { DRLG_UNIVERSAL_DT1 } from '../src/game/drlg/drlg-map.ts'
import {
DRLG_TABLE_NAMES,
drlgReferencedDs1Files,
memoryDrlgSource,
normalizeDrlgPath,
} from '../src/game/drlg/drlg-source.ts'
import { loadDrlgTables, type DrlgTableName } from '../src/game/drlg/drlg-tables.ts'
import { MpqArchive } from '../src/mpq/archive.ts'
import { fileSource } from '../src/mpq/file-source.ts'
import { MountedArchives } from '../src/mpq/mount.ts'
import {
DRLG_HARDCODED_DT1_FILES,
drlgActDt1Files,
drlgSuperUniqueIds,
} from './lib/drlg-mpq-source.ts'
import { encodeIndexedPng } from './png.ts'
const MOUNTS = ['d2char.mpq', 'd2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq'] as const
const PAGE_SIZE = 2048
const DT1_TILE_HEADER_SIZE = 96
export interface PackedTilePlacement {
readonly act: number
readonly page: number
readonly x: number
readonly y: number
readonly width: number
readonly height: number
readonly nonEmptyPixels: number
readonly hash: string
}
export interface PackedDt1TileEntry {
readonly index: number
readonly direction: number
readonly roofHeight: number
readonly materialFlags: number
readonly animated: number
readonly height: number
readonly width: number
readonly type: number
readonly style: number
readonly sequence: number
readonly rarityFrameIndex: number
readonly minBlockY: number
readonly bitmapHeight: number
readonly blockCount: number
/** 25 sub-tile flag bytes in normalized top-to-bottom order (`subY * 5 + subX`). */
readonly subTileFlags: readonly number[]
/** 25 sub-tile flag bytes in raw DT1 file bottom-to-top order (`(4 - subY) * 5 + subX`). */
readonly rawSubTileFlags: readonly number[]
readonly placement: PackedTilePlacement | null
}
export interface PackedDt1LibraryEntry {
readonly path: string
readonly archivePath: string
readonly ownerAct: number
readonly acts: readonly number[]
readonly versionMajor: number
readonly versionMinor: number
readonly tileCount: number
readonly tiles: readonly PackedDt1TileEntry[]
}
export interface PackedActAtlasSummary {
readonly act: number
readonly paletteBytes: number
readonly pages: readonly {
readonly page: number
readonly r8File: string
readonly pngFile: string
readonly width: number
readonly height: number
readonly frames: number
readonly r8Bytes: number
readonly pngBytes: number
}[]
readonly libraries: readonly string[]
}
export interface PackedLevelMetaEntry {
readonly id: number
readonly name: string
readonly act: number
readonly drlgType: number
readonly levelType: number
readonly layer: number
readonly waypoint: number
readonly vis: readonly number[]
readonly warp: readonly number[]
}
export interface PackedWarpMetaEntry {
readonly id: number
readonly selectX: number
readonly selectY: number
readonly selectDX: number
readonly selectDY: number
readonly exitWalkX: number
readonly exitWalkY: number
readonly offsetX: number
readonly offsetY: number
readonly litVersion: boolean
readonly tiles: number
readonly direction: string
}
export interface PackedDrlgBundleManifest {
readonly schema: 1
readonly binaryFile: string
readonly totalBytes: number
readonly tables: Readonly<Record<DrlgTableName, { readonly offset: number; readonly length: number }>>
readonly ds1Files: Readonly<Record<string, { readonly offset: number; readonly length: number }>>
readonly dt1HeaderFiles: Readonly<Record<string, { readonly offset: number; readonly length: number }>>
readonly superUniqueIds: readonly string[]
readonly levels: readonly PackedLevelMetaEntry[]
readonly warps: readonly PackedWarpMetaEntry[]
}
export interface PackedTileCatalogIndex {
readonly schema: 1
readonly totalLibraries: number
readonly totalTiles: number
readonly totalPlacedFrames: number
readonly missingFiles: readonly string[]
readonly universalDt1s: readonly string[]
readonly hardcodedDt1s: readonly string[]
readonly invisWalFloorVerified: boolean
readonly acts: Readonly<Record<string, PackedActAtlasSummary>>
readonly libraries: Readonly<Record<string, PackedDt1LibraryEntry>>
readonly drlgBundle: {
readonly manifestFile: string
readonly binaryFile: string
readonly totalBytes: number
}
}
interface ShelfPage {
readonly pixels: Uint8Array
frames: number
}
class AtlasPagePacker {
private readonly pages: ShelfPage[] = []
private cursorX = 0
private shelfY = 0
private shelfHeight = 0
add(frame: { readonly width: number; readonly height: number; readonly indices: Uint8Array }): {
readonly page: number
readonly x: number
readonly y: number
readonly width: number
readonly height: number
} {
const width = Math.max(1, Math.min(frame.width, PAGE_SIZE))
const height = Math.max(1, Math.min(frame.height, PAGE_SIZE))
if (this.cursorX + width > PAGE_SIZE) {
this.shelfY += this.shelfHeight
this.shelfHeight = 0
this.cursorX = 0
}
if (this.pages.length === 0 || this.shelfY + height > PAGE_SIZE) {
this.pages.push({ pixels: new Uint8Array(PAGE_SIZE * PAGE_SIZE), frames: 0 })
this.shelfY = 0
this.shelfHeight = 0
this.cursorX = 0
}
const pageIndex = this.pages.length - 1
const page = this.pages[pageIndex]!
const x = this.cursorX
const y = this.shelfY
for (let row = 0; row < height; row += 1) {
const srcOffset = row * frame.width
const dstOffset = (y + row) * PAGE_SIZE + x
page.pixels.set(frame.indices.subarray(srcOffset, srcOffset + width), dstOffset)
}
this.cursorX += width
this.shelfHeight = Math.max(this.shelfHeight, height)
page.frames += 1
return { page: pageIndex, x, y, width, height }
}
finalize(palette: Uint8Array): {
readonly page: number
readonly width: number
readonly height: number
readonly frames: number
readonly r8: Uint8Array
readonly png: Uint8Array
}[] {
return this.pages.map((page, index) => {
const usedHeight = Math.max(1, this.measureUsedHeight(page.pixels))
const r8 = page.pixels.subarray(0, PAGE_SIZE * usedHeight)
const png = encodeIndexedPng({
width: PAGE_SIZE,
height: usedHeight,
pixels: r8,
palette,
transparentIndex: 0,
compressionLevel: 1,
})
return {
page: index,
width: PAGE_SIZE,
height: usedHeight,
frames: page.frames,
r8,
png,
}
})
}
private measureUsedHeight(pixels: Uint8Array): number {
for (let row = PAGE_SIZE - 1; row >= 0; row -= 1) {
const base = row * PAGE_SIZE
for (let x = 0; x < PAGE_SIZE; x += 1) {
if (pixels[base + x] !== 0) return row + 1
}
}
return 1
}
}
function fnv1aHex(bytes: Uint8Array): string {
let hash = 0x811c9dc5
for (let i = 0; i < bytes.byteLength; i += 1) {
hash ^= bytes[i]!
hash = Math.imul(hash, 0x01000193) >>> 0
}
return hash.toString(16).padStart(8, '0')
}
function compositeDt1Tile(tile: Dt1Tile): {
readonly width: number
readonly height: number
readonly indices: Uint8Array
readonly nonEmptyPixels: number
} {
const width = Math.max(tile.width, 1)
const height = Math.max(tile.bitmapHeight, 1)
const indices = new Uint8Array(width * height)
let nonEmptyPixels = 0
for (const block of tile.blocks) {
const limit = Math.min(indices.length, block.pixels.length)
for (let at = 0; at < limit; at += 1) {
const val = block.pixels[at]!
if (val === 0) continue
if (indices[at] === 0) nonEmptyPixels += 1
indices[at] = val
}
}
return { width, height, indices, nonEmptyPixels }
}
function resolveDefaultArchiveDir(argDir?: string): string {
if (argDir && existsSync(argDir)) return argDir
if (process.env.D2_DATA_DIR && existsSync(process.env.D2_DATA_DIR)) return process.env.D2_DATA_DIR
if (existsSync('samples/d2')) return 'samples/d2'
if (existsSync('/usr/local/google/home/taodao/d2-data')) return '/usr/local/google/home/taodao/d2-data'
return argDir ?? 'samples/d2'
}
function extractDt1HeaderSlice(rawBytes: Uint8Array, path: string): Uint8Array {
if (rawBytes.byteLength < 0x114) {
throw new Error(`${path}: truncated DT1 header (${rawBytes.byteLength} bytes)`)
}
const view = new DataView(rawBytes.buffer, rawBytes.byteOffset, rawBytes.byteLength)
const nTiles = view.getInt32(0x10c, true)
const nTileStart = view.getInt32(0x110, true)
const end = nTileStart + nTiles * DT1_TILE_HEADER_SIZE
if (nTiles < 0 || nTileStart < 0 || end > rawBytes.byteLength) {
throw new Error(`${path}: invalid DT1 header bounds (nTiles=${nTiles}, nTileStart=${nTileStart})`)
}
return rawBytes.subarray(0, end)
}
function inferOwnerActFromPath(normalizedPath: string, acts: readonly number[]): number {
const match = /[\\/]act([1-5])[\\/]/i.exec(normalizedPath)
if (match) return Number(match[1])
return acts[0] ?? 1
}
export async function runPackTiles(
archiveDirInput?: string,
outDirInput = 'public/act-packs/tiles',
): Promise<PackedTileCatalogIndex> {
const archiveDir = resolveDefaultArchiveDir(archiveDirInput)
const outDir = resolve(outDirInput)
const archives = new MountedArchives()
for (const name of MOUNTS) {
archives.add(name, await MpqArchive.open(await fileSource(join(archiveDir, name))))
}
const actTables = await loadActTables(archives)
// 1. Load DRLG compiled `.bin` tables
const tableBytes = new Map<DrlgTableName, Uint8Array>()
for (const name of DRLG_TABLE_NAMES) {
tableBytes.set(name, await archives.read(`data\\global\\excel\\${name}.bin`))
}
const tables = loadDrlgTables(memoryDrlgSource(tableBytes, new Map()))
const superUniqueIds = drlgSuperUniqueIds(actTables.superuniques, tables)
// 2. Collect all DT1 files across Acts 1..5 + all LvlTypes rows + universal/hardcoded DT1s
const dt1ArchivePathByKey = new Map<string, string>()
const actsByDt1Key = new Map<string, Set<number>>()
const registerDt1 = (archivePath: string, act1Based: number): void => {
const key = normalizeDrlgPath(archivePath)
if (!dt1ArchivePathByKey.has(key)) {
dt1ArchivePathByKey.set(key, archivePath)
}
let set = actsByDt1Key.get(key)
if (!set) {
set = new Set<number>()
actsByDt1Key.set(key, set)
}
set.add(act1Based)
}
for (let act = 1; act <= 5; act += 1) {
for (const f of drlgActDt1Files(tables, act)) {
registerDt1(f, act)
}
}
for (let typeId = 0; typeId < tables.lvlTypes.length; typeId += 1) {
const rec = tables.lvlTypes[typeId]!
const act = rec.nAct >= 1 && rec.nAct <= 5 ? rec.nAct : 1
for (const f of rec.szFile) {
if (f.length > 1) registerDt1(f, act)
}
}
for (const u of DRLG_UNIVERSAL_DT1) {
for (let act = 1; act <= 5; act += 1) registerDt1(u, act)
}
for (const h of DRLG_HARDCODED_DT1_FILES) {
for (let act = 1; act <= 5; act += 1) registerDt1(h, act)
}
// 3. Load Act palettes (act1..act5)
const palettes = new Map<number, Uint8Array>()
const packers = new Map<number, AtlasPagePacker>()
for (let act = 1; act <= 5; act += 1) {
const pl2Bytes = await archives.read(`data\\global\\palette\\act${String(act)}\\pal.pl2`)
palettes.set(act, decodePl2(pl2Bytes).rgb)
packers.set(act, new AtlasPagePacker())
}
// 4. Read and decode every DT1 library, verifying 0 missing files
const sortedDt1Keys = [...dt1ArchivePathByKey.keys()].sort((a, b) => a.localeCompare(b))
const missingFiles: string[] = []
const libraries: Record<string, PackedDt1LibraryEntry> = {}
const dt1HeaderSlices = new Map<string, Uint8Array>()
let totalTiles = 0
let totalPlacedFrames = 0
let invisWalFloorVerified = false
for (const key of sortedDt1Keys) {
const archivePath = dt1ArchivePathByKey.get(key)!
let rawBytes: Uint8Array
try {
rawBytes = await archives.read(archivePath)
} catch {
missingFiles.push(archivePath)
continue
}
dt1HeaderSlices.set(key, extractDt1HeaderSlice(rawBytes, key))
const decoded = decodeDt1(rawBytes)
const acts = [...(actsByDt1Key.get(key) ?? [1])].sort((a, b) => a - b)
const ownerAct = inferOwnerActFromPath(key, acts)
const packer = packers.get(ownerAct)!
const view = new DataView(rawBytes.buffer, rawBytes.byteOffset, rawBytes.byteLength)
const tileDataStart = view.getInt32(0x110, true)
const packedTiles: PackedDt1TileEntry[] = decoded.tiles.map((tile, index) => {
const headerAt = tileDataStart + index * DT1_TILE_HEADER_SIZE
const rawSubTileFlags: number[] = []
for (let i = 0; i < 25; i += 1) {
rawSubTileFlags.push(rawBytes[headerAt + 0x28 + i] ?? 0)
}
const subTileFlags = tile.subTileFlags.map(f => f.raw)
if (
key === DRLG_UNIVERSAL_DT1[1] &&
tile.style === 49 &&
tile.sequence === 7 &&
tile.type === 0
) {
invisWalFloorVerified = true
}
let placement: PackedTilePlacement | null = null
if (tile.blocks.length > 0 && tile.width > 0 && tile.bitmapHeight > 0) {
const composited = compositeDt1Tile(tile)
if (composited.nonEmptyPixels > 0) {
const rect = packer.add(composited)
placement = {
act: ownerAct,
page: rect.page,
x: rect.x,
y: rect.y,
width: rect.width,
height: rect.height,
nonEmptyPixels: composited.nonEmptyPixels,
hash: fnv1aHex(composited.indices),
}
totalPlacedFrames += 1
}
}
return {
index,
direction: tile.direction,
roofHeight: tile.roofHeight,
materialFlags: tile.materialFlags,
animated: tile.animated,
height: tile.height,
width: tile.width,
type: tile.type,
style: tile.style,
sequence: tile.sequence,
rarityFrameIndex: tile.rarityFrameIndex,
minBlockY: tile.minBlockY,
bitmapHeight: tile.bitmapHeight,
blockCount: tile.blocks.length,
subTileFlags,
rawSubTileFlags,
placement,
}
})
totalTiles += packedTiles.length
libraries[key] = {
path: key,
archivePath,
ownerAct,
acts,
versionMajor: decoded.versionMajor,
versionMinor: decoded.versionMinor,
tileCount: packedTiles.length,
tiles: packedTiles,
}
}
if (missingFiles.length > 0) {
throw new Error(`pack-tiles: ${String(missingFiles.length)} DT1 files missing: ${missingFiles.join(', ')}`)
}
if (!invisWalFloorVerified) {
throw new Error('pack-tiles: InvisWal.dt1 style=49 sequence=7 floor tile was not found')
}
// 5. Write per-act atlas pages (.r8, .png, palette.bin, index.json)
await mkdir(outDir, { recursive: true })
const actSummaries: Record<string, PackedActAtlasSummary> = {}
for (let act = 1; act <= 5; act += 1) {
const actDir = join(outDir, `act${String(act)}`)
await mkdir(actDir, { recursive: true })
for (const existing of await readdir(actDir)) {
if (existing.endsWith('.png') || existing.endsWith('.r8')) {
await unlink(join(actDir, existing))
}
}
const palette = palettes.get(act)!
await writeFile(join(actDir, 'palette.bin'), palette)
const finalizedPages = packers.get(act)!.finalize(palette)
const pageMeta: {
readonly page: number
readonly r8File: string
readonly pngFile: string
readonly width: number
readonly height: number
readonly frames: number
readonly r8Bytes: number
readonly pngBytes: number
}[] = []
for (const p of finalizedPages) {
const r8File = `tiles-${String(p.page)}.r8`
const pngFile = `tiles-${String(p.page)}.png`
await writeFile(join(actDir, r8File), p.r8)
await writeFile(join(actDir, pngFile), p.png)
pageMeta.push({
page: p.page,
r8File,
pngFile,
width: p.width,
height: p.height,
frames: p.frames,
r8Bytes: p.r8.byteLength,
pngBytes: p.png.byteLength,
})
}
const actLibKeys = sortedDt1Keys.filter(k => libraries[k]!.acts.includes(act))
const actSummary: PackedActAtlasSummary = {
act,
paletteBytes: palette.byteLength,
pages: pageMeta,
libraries: actLibKeys,
}
actSummaries[`act${String(act)}`] = actSummary
const actLibraries: Record<string, PackedDt1LibraryEntry> = {}
for (const k of actLibKeys) {
actLibraries[k] = libraries[k]!
}
await writeFile(
join(actDir, 'index.json'),
JSON.stringify({ schema: 1, ...actSummary, librariesByPath: actLibraries }, null, 2),
)
}
// 6. Pack DRLG runtime bundle (`drlg-bundle.bin` + `drlg-bundle.json`)
const ds1FilesList = drlgReferencedDs1Files(tables)
const ds1BytesByKey = new Map<string, Uint8Array>()
for (const ds1Path of ds1FilesList) {
const key = normalizeDrlgPath(ds1Path)
if (!ds1BytesByKey.has(key)) {
ds1BytesByKey.set(key, await archives.read(ds1Path))
}
}
const chunks: Uint8Array[] = []
let currentOffset = 0
const appendChunk = (bytes: Uint8Array): { readonly offset: number; readonly length: number } => {
const offset = currentOffset
const length = bytes.byteLength
chunks.push(bytes)
currentOffset += length
return { offset, length }
}
const bundleTables = {} as Record<DrlgTableName, { readonly offset: number; readonly length: number }>
for (const name of DRLG_TABLE_NAMES) {
bundleTables[name] = appendChunk(tableBytes.get(name)!)
}
const bundleDs1Files: Record<string, { readonly offset: number; readonly length: number }> = {}
for (const [key, bytes] of [...ds1BytesByKey.entries()].sort((a, b) => a[0].localeCompare(b[0]))) {
bundleDs1Files[key] = appendChunk(bytes)
}
const bundleDt1Headers: Record<string, { readonly offset: number; readonly length: number }> = {}
for (const key of sortedDt1Keys) {
bundleDt1Headers[key] = appendChunk(dt1HeaderSlices.get(key)!)
}
const bundleBinary = new Uint8Array(currentOffset)
let writeCursor = 0
for (const chunk of chunks) {
bundleBinary.set(chunk, writeCursor)
writeCursor += chunk.byteLength
}
const levelsMeta: PackedLevelMetaEntry[] = []
for (const row of actTables.levels.rows) {
const id = Number(cell(actTables.levels, row, 'Id'))
if (!Number.isFinite(id) || id <= 0) continue
const name = cell(actTables.levels, row, 'LevelName') || cell(actTables.levels, row, 'Name')
const act = Number(cell(actTables.levels, row, 'Act')) + 1
const drlgType = Number(cell(actTables.levels, row, 'DrlgType'))
const levelType = Number(cell(actTables.levels, row, 'LevelType'))
const layer = Number(cell(actTables.levels, row, 'Layer'))
const wpRaw = cell(actTables.levels, row, 'Waypoint')
const waypoint = wpRaw === '' ? 255 : Number(wpRaw)
const vis: number[] = []
const warp: number[] = []
for (let s = 0; s < 8; s += 1) {
vis.push(Number(cell(actTables.levels, row, `Vis${String(s)}`) || '0'))
warp.push(Number(cell(actTables.levels, row, `Warp${String(s)}`) || '-1'))
}
levelsMeta.push({ id, name, act, drlgType, levelType, layer, waypoint, vis, warp })
}
const warpsMeta: PackedWarpMetaEntry[] = []
for (const row of actTables.lvlwarp.rows) {
const id = Number(cell(actTables.lvlwarp, row, 'Id'))
if (!Number.isFinite(id)) continue
warpsMeta.push({
id,
selectX: Number(cell(actTables.lvlwarp, row, 'SelectX') || '0'),
selectY: Number(cell(actTables.lvlwarp, row, 'SelectY') || '0'),
selectDX: Number(cell(actTables.lvlwarp, row, 'SelectDX') || '0'),
selectDY: Number(cell(actTables.lvlwarp, row, 'SelectDY') || '0'),
exitWalkX: Number(cell(actTables.lvlwarp, row, 'ExitWalkX') || '0'),
exitWalkY: Number(cell(actTables.lvlwarp, row, 'ExitWalkY') || '0'),
offsetX: Number(cell(actTables.lvlwarp, row, 'OffsetX') || '0'),
offsetY: Number(cell(actTables.lvlwarp, row, 'OffsetY') || '0'),
litVersion: Number(cell(actTables.lvlwarp, row, 'LitVersion') || '0') !== 0,
tiles: Number(cell(actTables.lvlwarp, row, 'Tiles') || '0'),
direction: cell(actTables.lvlwarp, row, 'Direction'),
})
}
const bundleManifest: PackedDrlgBundleManifest = {
schema: 1,
binaryFile: 'drlg-bundle.bin',
totalBytes: bundleBinary.byteLength,
tables: bundleTables,
ds1Files: bundleDs1Files,
dt1HeaderFiles: bundleDt1Headers,
superUniqueIds,
levels: levelsMeta,
warps: warpsMeta,
}
await writeFile(join(outDir, 'drlg-bundle.bin'), bundleBinary)
await writeFile(join(outDir, 'drlg-bundle.json'), JSON.stringify(bundleManifest, null, 2))
const catalogIndex: PackedTileCatalogIndex = {
schema: 1,
totalLibraries: sortedDt1Keys.length,
totalTiles,
totalPlacedFrames,
missingFiles,
universalDt1s: [...DRLG_UNIVERSAL_DT1],
hardcodedDt1s: DRLG_HARDCODED_DT1_FILES.map(normalizeDrlgPath),
invisWalFloorVerified,
acts: actSummaries,
libraries,
drlgBundle: {
manifestFile: 'drlg-bundle.json',
binaryFile: 'drlg-bundle.bin',
totalBytes: bundleBinary.byteLength,
},
}
await writeFile(join(outDir, 'index.json'), JSON.stringify(catalogIndex, null, 2))
return catalogIndex
}
const isMain = process.argv[1] !== undefined && import.meta.url === pathToFileURL(resolve(process.argv[1])).href
if (isMain) {
const [archiveArg, outArg] = process.argv.slice(2)
const t0 = performance.now()
const result = await runPackTiles(archiveArg, outArg)
const elapsedSec = ((performance.now() - t0) / 1000).toFixed(2)
console.log(
`[pack-tiles] Packed ${String(result.totalLibraries)} DT1 libraries (${String(result.totalTiles)} tiles, ` +
`${String(result.totalPlacedFrames)} atlas frames) + DRLG bundle (${String(result.drlgBundle.totalBytes)} bytes) ` +
`in ${elapsedSec}s — missingFiles=${String(result.missingFiles.length)}, invisWalFloorVerified=${String(result.invisWalFloorVerified)}`,
)
}