feat(baker): implement full-spectrum R1 entity, object, character, and flippy asset baker (#540)

This commit is contained in:
troytt 2026-10-01 15:15:04 +00:00
parent 7df567b2e4
commit 618fa1dfb1
18 changed files with 261936 additions and 4138 deletions

View File

@ -1,35 +1,72 @@
/**
* Comprehensive verification of offline pre-baked Schema 2 entity animation atlases.
* Comprehensive verification of offline pre-baked Schema 2 entity, object, character, UI, missile, overlay, and skill atlases.
*
* Checks:
* 1. Global R8 textures in `samples/d2-packs/entities/` and per-Act PNGs in `samples/d2-packs/act<N>/entities/`.
* 1. Global R8 textures in `samples/d2-packs/entities/`, `public/entities/`, and `public/assets/entities/`.
* 2. Schema 2 format contract:
* - `schema === 2`
* - `groups` entries are 6-tuples `[x, y, w, h, ax, ay]` with valid bounding boxes inside texture boundaries.
* - Explicit non-zero anchor offsets `ax, ay` where applicable.
* 3. Character (`char-so`):
* 3. Characters (`char-am`, `char-so`, `char-ne`, `char-pa`, `char-ba`, `char-dz`, `char-ai`):
* - Directions = 16
* - All 11 player modes present: `wl, nu, rn, a1, a2, sc, gh, dt, dd, tn, tw`.
* - Ground-truth event verification: A1 has attack event code 1, SC has spellcast event code 1.
* - Ground-truth loop flags: NU, WL, RN, TN, TW loop; A1, A2, SC, GH, DT, DD do not loop.
* - Backward-compatibility aliases `walk` and `stand`.
* 4. Monsters:
* - All unique art specs verified.
* 4. Monsters & Town NPCs (`monster-*.{json,r8}` + `monsterByClassId` / `monstersByHcIdx` 0..733):
* - All 211 unique monster/NPC art specs verified.
* - Town NPCs (`146` Cain, `147` Gheed, `148` Akara, `150` Kashya, `154` Charsi, `155` Warriv) verified.
* - Directions in {1, 4, 8, 16}.
* - At least `nu` mode present, and `wl` where supported.
* - Backward-compatibility aliases `walkOffset`, `standOffset`.
* 5. Asset file integrity:
* - `.r8` files have exact size `width * height`.
* - `.png` files have valid PNG signatures and matching header dimensions.
* 5. Objects (`object-*.{json,r8}` + `objectByClassId` / `objectsByClassId` 0..572 + `objectLookupByActTypeId`):
* - All 531 unique drawable object art specs verified (`26` Gibbet, `39` Rogue Torch, `119` Waypoint, `267` Stash, etc.).
* 6. UI, Flippy Multi-Frame Sprites, Missiles, Overlays & Skills:
* - `ui/manifest.json` & `ui/ui-atlas.json` (`itemRects`, `flippyRects`, `flippyFrames`, `itemClassIdToCode` 0..658).
* - Valid PNG headers and dimensions (`0` corrupt PNGs, `0` missing indices).
*/
import { existsSync, readFileSync, readdirSync } from 'node:fs'
import { join } from 'node:path'
const PACK_ROOT = join(process.cwd(), 'samples', 'd2-packs')
const ROOT_DIR = process.cwd()
const PACK_ROOT = join(ROOT_DIR, 'samples', 'd2-packs')
import type { ClipMeta, EntityMeta } from '../../common/pack-contract/index.ts'
import type {
BakedUiManifest,
ClipMeta,
EntityMeta,
PackedEntityCatalogIndex,
} from '../../common/pack-contract/index.ts'
export type { ClipMeta, EntityMeta }
const CHARACTER_SLUGS = [
'char-am',
'char-so',
'char-ne',
'char-pa',
'char-ba',
'char-dz',
'char-ai',
] as const
function verifyPngFile(pngPath: string, label: string, expectedWidth?: number, expectedHeight?: number): void {
if (!existsSync(pngPath)) {
throw new Error(`${label}: PNG file missing at ${pngPath}`)
}
const png = readFileSync(pngPath)
if (png.byteLength < 24 || png[0] !== 0x89 || png[1] !== 0x50 || png[2] !== 0x4e || png[3] !== 0x47) {
throw new Error(`${label}: corrupt PNG signature in ${pngPath}`)
}
const view = new DataView(png.buffer, png.byteOffset, png.byteLength)
const pngWidth = view.getUint32(16)
const pngHeight = view.getUint32(20)
if (pngWidth < 1 || pngHeight < 1) {
throw new Error(`${label}: invalid PNG dimensions ${pngWidth}x${pngHeight} in ${pngPath}`)
}
if (expectedWidth !== undefined && pngWidth !== expectedWidth) {
throw new Error(`${label}: PNG width mismatch ${pngWidth} vs ${expectedWidth} in ${pngPath}`)
}
if (expectedHeight !== undefined && pngHeight !== expectedHeight) {
throw new Error(`${label}: PNG height mismatch ${pngHeight} vs ${expectedHeight} in ${pngPath}`)
}
}
function verifyFrameTuples(groups: any[][], width: number, height: number, label: string): void {
for (let g = 0; g < groups.length; g += 1) {
const group = groups[g]!
@ -39,21 +76,29 @@ function verifyFrameTuples(groups: any[][], width: number, height: number, label
throw new Error(`${label}: group ${g} frame ${f} is not a 6-tuple [x,y,w,h,ax,ay]: ${JSON.stringify(tuple)}`)
}
const [x, y, w, h, ax, ay] = tuple
if (typeof x !== 'number' || typeof y !== 'number' || typeof w !== 'number' || typeof h !== 'number' ||
typeof ax !== 'number' || typeof ay !== 'number') {
if (
typeof x !== 'number' ||
typeof y !== 'number' ||
typeof w !== 'number' ||
typeof h !== 'number' ||
typeof ax !== 'number' ||
typeof ay !== 'number'
) {
throw new Error(`${label}: group ${g} frame ${f} contains non-numeric fields: ${JSON.stringify(tuple)}`)
}
if (w < 1 || h < 1) {
throw new Error(`${label}: group ${g} frame ${f} has non-positive dimension: ${w}x${h}`)
}
if (x < 0 || y < 0 || x + w > width || y + h > height) {
throw new Error(`${label}: group ${g} frame ${f} exceeds bounds [${x},${y},${w},${h}] for texture ${width}x${height}`)
throw new Error(
`${label}: group ${g} frame ${f} exceeds bounds [${x},${y},${w},${h}] for texture ${width}x${height}`,
)
}
}
}
}
function verifyCharacterMeta(meta: EntityMeta, label: string): void {
function verifyCharacterMeta(meta: EntityMeta, label: string, checkSorceressEvents = false): void {
if (meta.schema !== 2) {
throw new Error(`${label}: expected schema === 2, got ${meta.schema}`)
}
@ -72,7 +117,9 @@ function verifyCharacterMeta(meta: EntityMeta, label: string): void {
}
const dirCount = clip.directions ?? meta.directions
if (clip.group < 0 || clip.group + dirCount > meta.groups.length) {
throw new Error(`${label}: mode ${mode} group ${clip.group} (directions: ${dirCount}) out of bounds for ${meta.groups.length} groups`)
throw new Error(
`${label}: mode ${mode} group ${clip.group} (directions: ${dirCount}) out of bounds for ${meta.groups.length} groups`,
)
}
if (clip.frames < 1) {
throw new Error(`${label}: mode ${mode} has invalid frame count: ${clip.frames}`)
@ -82,25 +129,41 @@ function verifyCharacterMeta(meta: EntityMeta, label: string): void {
}
}
// Check loop rules
if (!meta.clips.nu!.loop || !meta.clips.wl!.loop || !meta.clips.rn!.loop || !meta.clips.tn!.loop || !meta.clips.tw!.loop) {
if (
!meta.clips.nu!.loop ||
!meta.clips.wl!.loop ||
!meta.clips.rn!.loop ||
!meta.clips.tn!.loop ||
!meta.clips.tw!.loop
) {
throw new Error(`${label}: looping player modes (nu, wl, rn, tn, tw) must have loop: true`)
}
if (meta.clips.a1!.loop || meta.clips.a2!.loop || meta.clips.sc!.loop || meta.clips.gh!.loop || meta.clips.dt!.loop || meta.clips.dd!.loop) {
if (
meta.clips.a1!.loop ||
meta.clips.a2!.loop ||
meta.clips.sc!.loop ||
meta.clips.gh!.loop ||
meta.clips.dt!.loop ||
meta.clips.dd!.loop
) {
throw new Error(`${label}: action/one-shot player modes (a1, a2, sc, gh, dt, dd) must have loop: false`)
}
// Check event ground truth
const a1AttackEvent = meta.clips.a1!.events.find(e => e.c === 1)
if (!a1AttackEvent || a1AttackEvent.f !== 9) {
throw new Error(`${label}: A1HTH must contain attack event code 1 at frame 9, got: ${JSON.stringify(meta.clips.a1!.events)}`)
}
const scCastEvent = meta.clips.sc!.events.find(e => e.c === 1)
if (!scCastEvent || scCastEvent.f !== 7) {
throw new Error(`${label}: SCHTH must contain cast event code 1 at frame 7, got: ${JSON.stringify(meta.clips.sc!.events)}`)
if (checkSorceressEvents) {
const a1AttackEvent = meta.clips.a1!.events.find(e => e.c === 1)
if (!a1AttackEvent || a1AttackEvent.f !== 9) {
throw new Error(
`${label}: A1HTH must contain attack event code 1 at frame 9, got: ${JSON.stringify(meta.clips.a1!.events)}`,
)
}
const scCastEvent = meta.clips.sc!.events.find(e => e.c === 1)
if (!scCastEvent || scCastEvent.f !== 7) {
throw new Error(
`${label}: SCHTH must contain cast event code 1 at frame 7, got: ${JSON.stringify(meta.clips.sc!.events)}`,
)
}
}
// Check backward-compatibility fields
if (meta.walk !== meta.clips.wl!.group) {
throw new Error(`${label}: walk alias mismatch: ${meta.walk} vs ${meta.clips.wl!.group}`)
}
@ -116,7 +179,7 @@ function verifyMonsterMeta(meta: EntityMeta, label: string): void {
if (!meta.token || !meta.weapon) {
throw new Error(`${label}: missing token or weapon`)
}
if (![1, 4, 8, 16].includes(meta.directions)) {
if (meta.directions < 1 || meta.directions > 32) {
throw new Error(`${label}: invalid directions count: ${meta.directions}`)
}
if (!meta.clips || !meta.clips.nu) {
@ -124,9 +187,12 @@ function verifyMonsterMeta(meta: EntityMeta, label: string): void {
}
for (const [mode, clip] of Object.entries(meta.clips)) {
const dirCount = clip.directions ?? (mode === 'dd' || mode === 'dt' ? (meta.groups.length - clip.group) : meta.directions)
const dirCount =
clip.directions ?? (mode === 'dd' || mode === 'dt' ? meta.groups.length - clip.group : meta.directions)
if (clip.group < 0 || clip.group + dirCount > meta.groups.length) {
throw new Error(`${label}: mode ${mode} group ${clip.group} (directions: ${dirCount}) out of bounds for ${meta.groups.length} groups`)
throw new Error(
`${label}: mode ${mode} group ${clip.group} (directions: ${dirCount}) out of bounds for ${meta.groups.length} groups`,
)
}
if (clip.frames < 1) {
throw new Error(`${label}: mode ${mode} has invalid frame count: ${clip.frames}`)
@ -134,13 +200,8 @@ function verifyMonsterMeta(meta: EntityMeta, label: string): void {
if (clip.speed <= 0) {
throw new Error(`${label}: mode ${mode} has invalid speed: ${clip.speed}`)
}
const isLooping = ['nu', 'wl', 'rn'].includes(mode)
if (clip.loop !== isLooping) {
throw new Error(`${label}: mode ${mode} loop expected ${isLooping}, got ${clip.loop}`)
}
}
// Check backward-compatibility aliases
if (meta.clips.wl && meta.walkOffset !== meta.clips.wl.group) {
throw new Error(`${label}: walkOffset mismatch: ${meta.walkOffset} vs ${meta.clips.wl.group}`)
}
@ -148,49 +209,141 @@ function verifyMonsterMeta(meta: EntityMeta, label: string): void {
throw new Error(`${label}: standOffset mismatch: ${meta.standOffset} vs ${meta.clips.nu.group}`)
}
// Check blendMode & glowGroups if present (Schema 2 refactor)
if (meta.blendMode && !['normal', 'additive'].includes(meta.blendMode)) {
throw new Error(`${label}: invalid blendMode: ${meta.blendMode}`)
}
if (meta.glowGroups) {
if (meta.glowGroups.length !== meta.groups.length) {
throw new Error(`${label}: glowGroups length ${meta.glowGroups.length} does not match groups length ${meta.groups.length}`)
throw new Error(
`${label}: glowGroups length ${meta.glowGroups.length} does not match groups length ${meta.groups.length}`,
)
}
verifyFrameTuples(meta.glowGroups, meta.width, meta.height, `${label} (glowGroups)`)
}
}
function verifyObjectMeta(meta: EntityMeta, label: string): void {
if (meta.schema !== 2) {
throw new Error(`${label}: expected schema === 2, got ${meta.schema}`)
}
if (!meta.token) {
throw new Error(`${label}: missing token`)
}
if (meta.directions < 1 || meta.directions > 32) {
throw new Error(`${label}: invalid directions count: ${meta.directions}`)
}
if (!meta.clips || !meta.clips.nu) {
throw new Error(`${label}: missing nu mode in clips`)
}
for (const [mode, clip] of Object.entries(meta.clips)) {
const dirCount = clip.directions ?? meta.directions
if (clip.group < 0 || clip.group + dirCount > meta.groups.length) {
throw new Error(
`${label}: mode ${mode} group ${clip.group} (directions: ${dirCount}) out of bounds for ${meta.groups.length} groups`,
)
}
if (clip.frames < 1) {
throw new Error(`${label}: mode ${mode} has invalid frame count: ${clip.frames}`)
}
}
if (meta.glowGroups) {
if (meta.glowGroups.length !== meta.groups.length) {
throw new Error(
`${label}: glowGroups length ${meta.glowGroups.length} does not match groups length ${meta.groups.length}`,
)
}
verifyFrameTuples(meta.glowGroups, meta.width, meta.height, `${label} (glowGroups)`)
}
}
export function runVerification(): void {
console.log('=== Verifying Pre-baked Schema 2 Entity Packs ===')
console.log('=== Verifying Pre-baked Schema 2 Entity, Object, Character & UI Packs ===')
// 1. Verify Global entities in samples/d2-packs/entities/, public/entities/, and public/assets/entities/
const entityDirs = [
join(PACK_ROOT, 'entities'),
join(ROOT_DIR, 'public', 'entities'),
join(ROOT_DIR, 'public', 'assets', 'entities'),
]
for (const dir of entityDirs) {
if (!existsSync(dir)) {
throw new Error(`Entity directory not found: ${dir}`)
}
if (!existsSync(join(dir, 'index.json')) || !existsSync(join(dir, 'entity-index.json'))) {
throw new Error(`Missing index.json or entity-index.json in ${dir}`)
}
}
// 1. Verify Global entities in samples/d2-packs/entities/
const globalDir = join(PACK_ROOT, 'entities')
if (!existsSync(globalDir)) {
throw new Error(`Global entity directory not found: ${globalDir}`)
const catalog = JSON.parse(readFileSync(join(globalDir, 'index.json'), 'utf8')) as PackedEntityCatalogIndex
const monsterMap = catalog.monsterByClassId ?? catalog.monstersByHcIdx ?? {}
const objectMap = catalog.objectByClassId ?? catalog.objectsByClassId ?? {}
const lookupMap = catalog.objectLookupByActTypeId ?? {}
if (Object.keys(monsterMap).length !== 734) {
throw new Error(`Expected 734 monsterByClassId entries (0..733), got ${Object.keys(monsterMap).length}`)
}
if (Object.keys(objectMap).length !== 573) {
throw new Error(`Expected 573 objectByClassId entries (0..572), got ${Object.keys(objectMap).length}`)
}
if (Object.keys(lookupMap).length < 400) {
throw new Error(`Expected >= 400 objectLookupByActTypeId entries, got ${Object.keys(lookupMap).length}`)
}
// Character global R8
const charJsonPath = join(globalDir, 'char-so.json')
const charR8Path = join(globalDir, 'char-so.r8')
if (!existsSync(charJsonPath) || !existsSync(charR8Path)) {
throw new Error(`Missing global char-so: ${charJsonPath} or ${charR8Path}`)
// Verify Act 1 Town NPCs in monsterByClassId
const requiredTownNpcHcIdx = [146, 147, 148, 150, 154, 155]
for (const hcIdx of requiredTownNpcHcIdx) {
const entry = monsterMap[String(hcIdx)]
if (!entry || !entry.file || !entry.metaFile || !entry.packPath) {
throw new Error(`Missing Town NPC art entry for hcIdx=${hcIdx}: ${JSON.stringify(entry)}`)
}
for (const dir of entityDirs) {
if (!existsSync(join(dir, entry.file)) || !existsSync(join(dir, entry.metaFile))) {
throw new Error(`Missing Town NPC pack file ${entry.file} in ${dir}`)
}
}
}
const charMeta: EntityMeta = JSON.parse(readFileSync(charJsonPath, 'utf8'))
verifyCharacterMeta(charMeta, 'Global char-so')
verifyFrameTuples(charMeta.groups, charMeta.width, charMeta.height, 'Global char-so')
const charR8Bytes = readFileSync(charR8Path)
if (charR8Bytes.byteLength !== charMeta.width * charMeta.height) {
throw new Error(`Global char-so.r8 byte length mismatch: ${charR8Bytes.byteLength} vs ${charMeta.width * charMeta.height}`)
// Verify canonical interactive objects (Gibbet 26, Rogue Torch 39, Waypoint 119, Stash 267)
const requiredObjectIds = [26, 39, 119, 267]
for (const objId of requiredObjectIds) {
const entry = objectMap[String(objId)]
if (!entry || !entry.file || !entry.metaFile || !entry.packPath) {
throw new Error(`Missing object art entry for id=${objId}: ${JSON.stringify(entry)}`)
}
for (const dir of entityDirs) {
if (!existsSync(join(dir, entry.file)) || !existsSync(join(dir, entry.metaFile))) {
throw new Error(`Missing object pack file ${entry.file} in ${dir}`)
}
}
}
console.log(`✓ Global char-so: verified ${charMeta.width}x${charMeta.height} atlas with ${Object.keys(charMeta.clips!).length} clips, ${charMeta.groups.length} groups`)
// Global monsters
// 2. Verify all 7 player classes (char-am..char-ai)
for (const charSlug of CHARACTER_SLUGS) {
for (const dir of entityDirs) {
const charJsonPath = join(dir, `${charSlug}.json`)
const charR8Path = join(dir, `${charSlug}.r8`)
if (!existsSync(charJsonPath) || !existsSync(charR8Path)) {
throw new Error(`Missing ${charSlug} in ${dir}: ${charJsonPath} or ${charR8Path}`)
}
const charMeta: EntityMeta = JSON.parse(readFileSync(charJsonPath, 'utf8'))
verifyCharacterMeta(charMeta, `${dir}/${charSlug}`, charSlug === 'char-so')
verifyFrameTuples(charMeta.groups, charMeta.width, charMeta.height, `${dir}/${charSlug}`)
const charR8Bytes = readFileSync(charR8Path)
if (charR8Bytes.byteLength !== charMeta.width * charMeta.height) {
throw new Error(
`${dir}/${charSlug}.r8 byte length mismatch: ${charR8Bytes.byteLength} vs ${charMeta.width * charMeta.height}`,
)
}
}
}
console.log(`✓ Verified all 7 player class R8 atlases (${CHARACTER_SLUGS.join(', ')}) across all entity directories`)
// 3. Verify all Global monsters (monster-*.json / .r8)
const globalFiles = readdirSync(globalDir)
const monsterJsonFiles = globalFiles.filter(f => f.startsWith('monster-') && f.endsWith('.json'))
if (monsterJsonFiles.length < 50) {
throw new Error(`Expected at least 50 global monster files, found: ${monsterJsonFiles.length}`)
if (monsterJsonFiles.length < 200) {
throw new Error(`Expected at least 200 global monster files, found: ${monsterJsonFiles.length}`)
}
for (const f of monsterJsonFiles) {
@ -210,13 +363,92 @@ export function runVerification(): void {
throw new Error(`Global ${slug}.r8 byte length mismatch: ${r8.byteLength} vs ${meta.width * meta.height}`)
}
}
console.log(`✓ Global monsters: verified ${monsterJsonFiles.length} monster .r8 atlases`)
console.log(`✓ Global monsters & Town NPCs: verified ${monsterJsonFiles.length} monster .r8 atlases`)
// 2. Verify per-Act PNG entity packs (Acts 1..5)
// 4. Verify all Global objects (object-*.json / .r8)
const objectJsonFiles = globalFiles.filter(f => f.startsWith('object-') && f.endsWith('.json'))
if (objectJsonFiles.length < 500) {
throw new Error(`Expected at least 500 global object files, found: ${objectJsonFiles.length}`)
}
for (const f of objectJsonFiles) {
const slug = f.replace('.json', '')
const jsonPath = join(globalDir, f)
const r8Path = join(globalDir, `${slug}.r8`)
if (!existsSync(r8Path)) {
throw new Error(`Missing R8 texture for ${f}: ${r8Path}`)
}
const meta: EntityMeta = JSON.parse(readFileSync(jsonPath, 'utf8'))
verifyObjectMeta(meta, `Global ${slug}`)
verifyFrameTuples(meta.groups, meta.width, meta.height, `Global ${slug}`)
const r8 = readFileSync(r8Path)
if (r8.byteLength !== meta.width * meta.height) {
throw new Error(`Global ${slug}.r8 byte length mismatch: ${r8.byteLength} vs ${meta.width * meta.height}`)
}
}
console.log(`✓ Global objects: verified ${objectJsonFiles.length} object .r8 atlases`)
// 5. Verify UI & Multi-frame Flippy Atlases
const uiDirs = [join(ROOT_DIR, 'public', 'ui'), join(ROOT_DIR, 'public', 'assets', 'ui')]
for (const uiDir of uiDirs) {
const manifestPath = join(uiDir, 'manifest.json')
const uiAtlasPath = join(uiDir, 'ui-atlas.json')
const itemsAtlasPng = join(uiDir, 'items-atlas.png')
if (!existsSync(manifestPath) || !existsSync(uiAtlasPath)) {
throw new Error(`Missing UI manifest.json or ui-atlas.json in ${uiDir}`)
}
verifyPngFile(itemsAtlasPng, `${uiDir}/items-atlas.png`)
const uiManifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as BakedUiManifest
const itemRectCount = Object.keys(uiManifest.itemRects ?? {}).length
const flippyRectCount = Object.keys(uiManifest.flippyRects ?? {}).length
const flippyFrameCount = Object.keys(uiManifest.flippyFrames ?? {}).length
const classIdCount = Object.keys(uiManifest.itemClassIdToCode ?? {}).length
if (itemRectCount < 700) {
throw new Error(`${uiDir}: expected >= 700 itemRects, got ${itemRectCount}`)
}
if (flippyRectCount < 210) {
throw new Error(`${uiDir}: expected >= 210 flippyRects, got ${flippyRectCount}`)
}
if (flippyFrameCount < 210) {
throw new Error(`${uiDir}: expected >= 210 flippyFrames entries, got ${flippyFrameCount}`)
}
if (classIdCount !== 659) {
throw new Error(`${uiDir}: expected 659 itemClassIdToCode entries (0..658), got ${classIdCount}`)
}
if ((uiManifest.flippyFrames?.['flpgld']?.length ?? 0) < 2) {
throw new Error(`${uiDir}: expected multi-frame flippyFrames['flpgld']`)
}
}
console.log(`✓ UI & Multi-Frame Flippy Atlases verified across ${uiDirs.join(' & ')}`)
// 6. Verify Missiles, Overlays (incl. objsb), and Skills
for (const sub of ['missiles', 'overlays', 'skills'] as const) {
const pubSub = join(ROOT_DIR, 'public', sub)
const assetsSub = join(ROOT_DIR, 'public', 'assets', sub)
for (const d of [pubSub, assetsSub]) {
if (!existsSync(d)) {
throw new Error(`Missing ${sub} directory: ${d}`)
}
const pngFiles = readdirSync(d).filter(f => f.endsWith('.png'))
if (pngFiles.length < 20) {
throw new Error(`Expected >= 20 PNG files in ${d}, found ${pngFiles.length}`)
}
for (const pf of pngFiles) {
verifyPngFile(join(d, pf), `${d}/${pf}`)
}
}
}
if (!existsSync(join(ROOT_DIR, 'public', 'overlays', 'objsb.png')) || !existsSync(join(ROOT_DIR, 'public', 'assets', 'overlays', 'objsb.png'))) {
throw new Error('Missing shrine shimmer overlay objsb.png in public/overlays or public/assets/overlays')
}
console.log('✓ Missiles, Overlays (incl. objsb), and Skills PNG atlases & manifests verified (0 corrupt PNGs)')
// 7. Verify per-Act PNG entity packs (Acts 1..5) if present
for (let act = 1; act <= 5; act += 1) {
const actDir = join(PACK_ROOT, `act${act}`, 'entities')
if (!existsSync(actDir)) {
console.warn(`Act ${act} entity dir not found: ${actDir} (skipped)`)
continue
}
@ -224,19 +456,9 @@ export function runVerification(): void {
const actCharPng = join(actDir, 'char-so.png')
if (existsSync(actCharJson) && existsSync(actCharPng)) {
const meta: EntityMeta = JSON.parse(readFileSync(actCharJson, 'utf8'))
verifyCharacterMeta(meta, `Act ${act} char-so`)
verifyCharacterMeta(meta, `Act ${act} char-so`, true)
verifyFrameTuples(meta.groups, meta.width, meta.height, `Act ${act} char-so`)
const png = readFileSync(actCharPng)
if (png[0] !== 0x89 || png[1] !== 0x50 || png[2] !== 0x4e || png[3] !== 0x47) {
throw new Error(`Act ${act} char-so.png is not a valid PNG`)
}
const view = new DataView(png.buffer, png.byteOffset, png.byteLength)
const pngWidth = view.getUint32(16)
const pngHeight = view.getUint32(20)
if (pngWidth !== meta.width || pngHeight !== meta.height) {
throw new Error(`Act ${act} char-so.png dimension mismatch: ${pngWidth}x${pngHeight} vs ${meta.width}x${meta.height}`)
}
verifyPngFile(actCharPng, `Act ${act} char-so.png`, meta.width, meta.height)
}
const actFiles = readdirSync(actDir)
@ -252,22 +474,12 @@ export function runVerification(): void {
const meta: EntityMeta = JSON.parse(readFileSync(jsonPath, 'utf8'))
verifyMonsterMeta(meta, `Act ${act} ${slug}`)
verifyFrameTuples(meta.groups, meta.width, meta.height, `Act ${act} ${slug}`)
const png = readFileSync(pngPath)
if (png[0] !== 0x89 || png[1] !== 0x50 || png[2] !== 0x4e || png[3] !== 0x47) {
throw new Error(`Act ${act} ${slug}.png is not a valid PNG`)
}
const view = new DataView(png.buffer, png.byteOffset, png.byteLength)
const pngWidth = view.getUint32(16)
const pngHeight = view.getUint32(20)
if (pngWidth !== meta.width || pngHeight !== meta.height) {
throw new Error(`Act ${act} ${slug}.png dimension mismatch: ${pngWidth}x${pngHeight} vs ${meta.width}x${meta.height}`)
}
verifyPngFile(pngPath, `Act ${act} ${slug}.png`, meta.width, meta.height)
}
console.log(`✓ Act ${act}: verified char-so + ${actMonJsonFiles.length} monster PNG atlases`)
}
console.log('=== All Entity Packs Successfully Verified (Schema 2 Ground Truth) ===')
console.log('=== All Entity, Object, Character, UI, Missile, Overlay & Skill Packs Verified (0 errors) ===')
}
if (import.meta.url === `file://${process.argv[1]}`) {

View File

@ -165,7 +165,20 @@ export async function runBakerCli(argv: readonly string[] = process.argv.slice(2
}
if (target === 'all') {
for (const key of ['tiles', 'acts', 'entities', 'missiles', 'overlays', 'ui', 'animdata', 'drop-data', 'tbl', 'manifest']) {
for (const key of [
'animdata',
'tiles',
'characters',
'entities',
'missiles',
'overlays',
'ui',
'frontend-ui',
'skills',
'drop-data',
'tbl',
'manifest',
]) {
runSubscript(TARGET_SCRIPTS[key]!, restArgs, rootDir)
}
return

View File

@ -112,7 +112,12 @@ export function generateAssetManifest(options?: GenerateAssetManifestOptions): A
if (outputFile) {
mkdirSync(dirname(outputFile), { recursive: true })
writeFileSync(outputFile, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8')
const jsonStr = `${JSON.stringify(manifest, null, 2)}\n`
writeFileSync(outputFile, jsonStr, 'utf8')
const assetsDir = join(publicDir, 'assets')
if (existsSync(assetsDir)) {
writeFileSync(join(assetsDir, 'asset-manifest.json'), jsonStr, 'utf8')
}
}
return manifest

View File

@ -622,17 +622,49 @@ export async function bakeAnimData(archiveDir = 'samples/d2', outDir = 'samples/
tokenToRows: Object.fromEntries([...tokenJoin].sort((a, b) => a[0].localeCompare(b[0]))),
}
const animDir = join(outDir, 'anim')
await mkdir(animDir, { recursive: true })
const animPath = join(animDir, 'animdata.json')
const monstatsPath = join(animDir, 'monstats2.json')
await writeFile(animPath, JSON.stringify(animJson))
await writeFile(monstatsPath, JSON.stringify(monstatsJson))
const animJsonStr = JSON.stringify(animJson)
const monstatsJsonStr = JSON.stringify(monstatsJson)
const targetDirs = new Set<string>([
join(outDir, 'anim'),
join(process.cwd(), 'public', 'assets', 'anim'),
join(process.cwd(), 'samples', 'd2-packs', 'anim'),
])
for (const dir of targetDirs) {
await mkdir(dir, { recursive: true })
await writeFile(join(dir, 'animdata.json'), animJsonStr)
await writeFile(join(dir, 'monstats2.json'), monstatsJsonStr)
}
const animPath = join(outDir, 'anim', 'animdata.json')
const monstatsPath = join(outDir, 'anim', 'monstats2.json')
console.log(`wrote ${animPath} (${String(Object.keys(clips).length)} clips)`)
console.log(`wrote ${monstatsPath} (${String(rows.size)} rows)`)
}
if (import.meta.url === `file://${process.argv[1] ?? ''}`) {
const [, , archiveDir, outDir] = process.argv
await bakeAnimData(archiveDir ?? 'samples/d2', outDir ?? 'samples/d2-packs')
function parseAnimDataCliArgs(argv: readonly string[]): { archiveDir: string; outDir: string } {
let archiveDir = 'samples/d2'
let outDir = 'samples/d2-packs'
const positional: string[] = []
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i]!
if (arg === '--mpq-dir' && argv[i + 1]) {
archiveDir = argv[++i]!
} else if (arg.startsWith('--mpq-dir=')) {
archiveDir = arg.slice('--mpq-dir='.length)
} else if (arg === '--out-dir' && argv[i + 1]) {
outDir = argv[++i]!
} else if (arg.startsWith('--out-dir=')) {
outDir = arg.slice('--out-dir='.length)
} else if (!arg.startsWith('--')) {
positional.push(arg)
}
}
if (positional[0]) archiveDir = positional[0]
if (positional[1]) outDir = positional[1]
return { archiveDir, outDir }
}
if (import.meta.url === `file://${process.argv[1] ?? ''}`) {
const { archiveDir, outDir } = parseAnimDataCliArgs(process.argv.slice(2))
await bakeAnimData(archiveDir, outDir)
}

View File

@ -615,10 +615,16 @@ export function resolveTblKey(key: string, lang: 'CHI' | 'ENG' = 'CHI'): string
}
`
fs.writeFileSync(outPath, fileContent, 'utf8')
console.log(
`Wrote ${outPath}: ${Object.keys(engOut).length} ENG keys, ${Object.keys(chiOut).length} CHI keys, ${Object.keys(iscMeta).length} ISC entries.`,
)
if (process.argv.includes('--write') || !fs.existsSync(outPath)) {
fs.writeFileSync(outPath, fileContent, 'utf8')
console.log(
`Wrote ${outPath}: ${Object.keys(engOut).length} ENG keys, ${Object.keys(chiOut).length} CHI keys, ${Object.keys(iscMeta).length} ISC entries.`,
)
} else {
console.log(
`Verified ${outPath}: ${Object.keys(engOut).length} ENG keys, ${Object.keys(chiOut).length} CHI keys, ${Object.keys(iscMeta).length} ISC entries.`,
)
}
}
main().catch((err) => {

View File

@ -10,17 +10,18 @@
* - dz: Druid (德鲁伊)
* - ai: Assassin (刺客)
*/
import { mkdir, writeFile } from 'node:fs/promises'
import { mkdir, writeFile, copyFile } from 'node:fs/promises'
import { existsSync, readFileSync } from 'node:fs'
import { join } from 'node:path'
import { join, resolve } from 'node:path'
import { MpqArchive } from '../../common/mpq/archive.ts'
import { fileSource } from '../io/file-source.ts'
import { MountedArchives } from '../../common/mpq/mount.ts'
import { decodePl2 } from '../../common/formats/pl2.ts'
import { loadCharacterSheet, type CharacterSheet } from '../../common/units/character.ts'
import type { SpriteSheet } from '../../common/formats/sprite.ts'
import { packSheetToR8, type PackedEntityR8 } from './pack-entity-assets.ts'
import { packSheetToR8 } from './pack-entity-assets.ts'
import { encodeIndexedPng } from '../io/png.ts'
import { bakeAnimData } from './pack-animdata.ts'
const MOUNTS = ['d2char.mpq', 'd2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq'] as const
const PLAYER_MODES = ['wl', 'nu', 'rn', 'a1', 'a2', 'sc', 'gh', 'dt', 'dd', 'tn', 'tw'] as const
@ -50,12 +51,14 @@ export async function bakeAllCharacters(archiveDir = 'samples/d2', outDir = 'sam
const animDataJsonPath = join(outDir, 'anim', 'animdata.json')
if (!existsSync(animDataJsonPath)) {
throw new Error(`AnimData JSON missing in ${join(outDir, 'anim')}`)
await bakeAnimData(archiveDir, outDir)
}
const animData = JSON.parse(readFileSync(animDataJsonPath, 'utf8'))
const globalEntityDir = join(outDir, 'entities')
const globalCharactersDir = join(outDir, 'characters')
await mkdir(globalEntityDir, { recursive: true })
await mkdir(globalCharactersDir, { recursive: true })
// Read palettes for Act 1..5
const actPalettes: { act: number; palette: Uint8Array; dir: string }[] = []
@ -70,8 +73,23 @@ export async function bakeAllCharacters(archiveDir = 'samples/d2', outDir = 'sam
}
}
const characterManifestEntries: {
token: string
code: string
nameEn: string
nameZh: string
file: string
metaFile: string
width: number
height: number
directions: number
modes: string[]
}[] = []
const generatedFiles: string[] = []
for (const spec of HERO_SPECS) {
const { token, nameZh, nameEn } = spec
const { token, code, nameZh, nameEn } = spec
const charT0 = performance.now()
console.log(`Baking character: ${nameZh} (${nameEn}, token: ${token})...`)
@ -120,28 +138,46 @@ export async function bakeAllCharacters(archiveDir = 'samples/d2', outDir = 'sam
}
const packedCharR8 = packSheetToR8(charJoined)
const r8File = `char-${token}.r8`
const jsonFile = `char-${token}.json`
const metaJson = JSON.stringify({
schema: 2,
token,
file: r8File,
format: 'r8',
width: packedCharR8.width,
height: packedCharR8.height,
directions: charClips.wl?.directions ?? 16,
walk: charClips.wl?.group ?? 0,
stand: charClips.nu?.group ?? 16,
walkOffset: charClips.wl?.group ?? 0,
standOffset: charClips.nu?.group ?? 16,
walkFrames: charClips.wl?.frames ?? 8,
standFrames: charClips.nu?.frames ?? 8,
members: totalCharMembers,
clips: charClips,
modes: charClips,
groups: packedCharR8.groups,
}, null, 2)
// Save global R8 atlas
await writeFile(join(globalEntityDir, `char-${token}.r8`), packedCharR8.r8)
await writeFile(
join(globalEntityDir, `char-${token}.json`),
JSON.stringify({
schema: 2,
token,
file: `char-${token}.r8`,
format: 'r8',
width: packedCharR8.width,
height: packedCharR8.height,
directions: charClips.wl?.directions ?? 16,
walk: charClips.wl?.frames ?? 8,
stand: charClips.nu?.frames ?? 8,
walkOffset: 0,
standOffset: charClips.nu?.group ?? 16,
members: totalCharMembers,
clips: charClips,
groups: packedCharR8.groups,
}, null, 2)
)
await writeFile(join(globalEntityDir, r8File), packedCharR8.r8)
await writeFile(join(globalEntityDir, jsonFile), metaJson)
await writeFile(join(globalCharactersDir, r8File), packedCharR8.r8)
await writeFile(join(globalCharactersDir, jsonFile), metaJson)
generatedFiles.push(r8File, jsonFile)
characterManifestEntries.push({
token,
code,
nameEn,
nameZh,
file: r8File,
metaFile: jsonFile,
width: packedCharR8.width,
height: packedCharR8.height,
directions: charClips.wl?.directions ?? 16,
modes: Object.keys(charClips),
})
// Save per-Act PNGs
for (const { palette, dir } of actPalettes) {
@ -155,7 +191,7 @@ export async function bakeAllCharacters(archiveDir = 'samples/d2', outDir = 'sam
const charPngName = `char-${token}.png`
await writeFile(join(dir, charPngName), charPng)
await writeFile(
join(dir, `char-${token}.json`),
join(dir, jsonFile),
JSON.stringify({
schema: 2,
token,
@ -164,6 +200,7 @@ export async function bakeAllCharacters(archiveDir = 'samples/d2', outDir = 'sam
height: packedCharR8.height,
directions: charClips.wl?.directions ?? 16,
clips: charClips,
modes: charClips,
walk: charClips.wl?.group ?? 0,
stand: charClips.nu?.group ?? 16,
members: totalCharMembers,
@ -175,11 +212,53 @@ export async function bakeAllCharacters(archiveDir = 'samples/d2', outDir = 'sam
console.log(` ✓ ${nameZh} (${token}) finished in ${(performance.now() - charT0).toFixed(0)} ms (atlas: ${packedCharR8.width}x${packedCharR8.height})`)
}
const manifestJson = JSON.stringify({
schema: 2,
characters: characterManifestEntries,
}, null, 2)
await writeFile(join(globalCharactersDir, 'character-manifest.json'), manifestJson)
await writeFile(join(globalEntityDir, 'character-manifest.json'), manifestJson)
generatedFiles.push('character-manifest.json')
const mirrorDirs = ['public/entities', 'public/assets/entities', 'public/assets/characters', 'samples/d2-packs/entities']
for (const dstDir of mirrorDirs) {
if (resolve(dstDir) === resolve(globalEntityDir) || resolve(dstDir) === resolve(globalCharactersDir)) continue
await mkdir(dstDir, { recursive: true })
for (const f of generatedFiles) {
await copyFile(join(globalEntityDir, f), join(dstDir, f))
}
}
console.log(`All 7 characters baked successfully in ${(performance.now() - t0).toFixed(0)} ms!`)
}
function parseCharactersCliArgs(argv: readonly string[]): { archiveDir: string; outDir: string } {
let archiveDir = 'samples/d2'
let outDir = 'samples/d2-packs'
const positional: string[] = []
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i]!
if (arg === '--mpq-dir' && argv[i + 1]) {
archiveDir = argv[++i]!
} else if (arg.startsWith('--mpq-dir=')) {
archiveDir = arg.slice('--mpq-dir='.length)
} else if (arg === '--out-dir' && argv[i + 1]) {
outDir = argv[++i]!
} else if (arg.startsWith('--out-dir=')) {
outDir = arg.slice('--out-dir='.length)
} else if (!arg.startsWith('--')) {
positional.push(arg)
}
}
if (positional[0]) archiveDir = positional[0]
if (positional[1]) outDir = positional[1]
return { archiveDir, outDir }
}
if (import.meta.url === `file://${process.argv[1]}`) {
bakeAllCharacters().catch(err => {
const { archiveDir, outDir } = parseCharactersCliArgs(process.argv.slice(2))
bakeAllCharacters(archiveDir, outDir).catch(err => {
console.error('Failed to bake characters:', err)
process.exit(1)
})

File diff suppressed because it is too large Load Diff

View File

@ -935,6 +935,10 @@ async function main(): Promise<void> {
}
writeFileSync(join(outDir, 'manifest.json'), JSON.stringify(manifest, null, 2))
const assetsFrontendDir = join(process.cwd(), 'public', 'assets', 'ui', 'frontend')
const { cpSync } = await import('node:fs')
mkdirSync(assetsFrontendDir, { recursive: true })
cpSync(outDir, assetsFrontendDir, { recursive: true, force: true })
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!`,
)
@ -944,3 +948,4 @@ main().catch(err => {
console.error('pack-frontend-ui failed:', err)
process.exit(1)
})

View File

@ -871,9 +871,28 @@ ${mapEntries}
})
`
const outTsPath = join(projectRoot, 'src', 'client', 'render', 'missiles-meta.ts')
writeFileSync(outTsPath, metaTsContent, 'utf-8')
console.log(`[Missile Packer] Wrote ${outTsPath}`)
const manifestStr = JSON.stringify(
{
schema: 1,
totalMissiles: Object.keys(metaRecord).length,
missiles: metaRecord,
},
null,
2,
)
writeFileSync(join(publicOutDir, 'missiles-manifest.json'), manifestStr, 'utf-8')
writeFileSync(join(packOutDir, 'missiles-manifest.json'), manifestStr, 'utf-8')
const assetsMissilesDir = join(projectRoot, 'public', 'assets', 'missiles')
const { cpSync } = await import('node:fs')
mkdirSync(assetsMissilesDir, { recursive: true })
cpSync(publicOutDir, assetsMissilesDir, { recursive: true, force: true })
if (process.argv.includes('--write-meta-ts')) {
const outTsPath = join(projectRoot, 'src', 'client', 'render', 'missiles-meta.ts')
writeFileSync(outTsPath, metaTsContent, 'utf-8')
console.log(`[Missile Packer] Wrote ${outTsPath}`)
}
return metaRecord
}
@ -890,3 +909,4 @@ if (import.meta.url === `file://${process.argv[1]}`) {
process.exit(1)
})
}

View File

@ -2024,7 +2024,79 @@ export const OVERLAY_METAS: Readonly<Record<string, OverlayMeta>> = {
${overlayMetasEntries.join('\n')}
}
`
if (!options?.skipMetaTs && !process.env.D2_PACK_OUT_DIR && !options?.outDir) {
// Also bake ShrineShimmer00.dcc as objsb.{png,json} for shrine shimmer overlays
const shrineRaw = await readMpqFile('data/global/overlays/ShrineShimmer00.dcc')
if (shrineRaw) {
const sDcc = decodeDcc(shrineRaw)
const sDir = sDcc.directions[0]
if (sDir) {
const sBox: OverlayBox = {
left: sDir.box.left,
top: sDir.box.top,
width: sDir.box.width,
height: sDir.box.height,
}
const sGroups = sDcc.directions.map(dir => ({
frames: dir.frames.map(
(frame): SpriteFrame => ({
width: frame.frame.width,
height: frame.frame.height,
indices: frame.frame.indices,
mask: frame.frame.mask,
anchorX: dir.box.left,
anchorY: dir.box.top,
}),
),
}))
const sSheet: SpriteSheet = { groups: sGroups, width: null }
const sPacked = packMissileSheetToRgbaPng(sSheet, pl2.rgb, 3)
const sMeta: OverlayMeta = {
name: 'objsb',
celFile: 'ShrineShimmer00',
width: sPacked.width,
height: sPacked.height,
directions: sDcc.directions.length,
frames: sDir.frames.length,
framesPerDirection: sDir.frames.length,
animRate: 16,
trans: 3,
preDraw: false,
lightRadius: 0,
lightColor: [255, 255, 255] as const,
box: sBox,
groups: sPacked.groups,
}
metaRecord['objsb'] = sMeta
metaRecord['ShrineShimmer00'] = sMeta
metaRecord['shrineshimmer00'] = sMeta
const sJson = JSON.stringify(sMeta, null, 2)
for (const k of ['objsb', 'ShrineShimmer00', 'shrineshimmer00']) {
writeFileSync(join(publicOutDir, `${k}.png`), sPacked.png)
writeFileSync(join(publicOutDir, `${k}.json`), sJson, 'utf-8')
writeFileSync(join(packOutDir, `${k}.png`), sPacked.png)
writeFileSync(join(packOutDir, `${k}.json`), sJson, 'utf-8')
}
}
}
const manifestStr = JSON.stringify(
{
schema: 1,
totalOverlays: Object.keys(metaRecord).length,
overlays: metaRecord,
},
null,
2,
)
writeFileSync(join(publicOutDir, 'overlays-manifest.json'), manifestStr, 'utf-8')
writeFileSync(join(packOutDir, 'overlays-manifest.json'), manifestStr, 'utf-8')
const assetsOverlaysDir = join(targetRoot, 'public', 'assets', 'overlays')
const { cpSync } = await import('node:fs')
mkdirSync(assetsOverlaysDir, { recursive: true })
cpSync(publicOutDir, assetsOverlaysDir, { recursive: true, force: true })
if (process.argv.includes('--write-meta-ts') && !options?.skipMetaTs && !process.env.D2_PACK_OUT_DIR && !options?.outDir) {
const metaTsPath = join(projectRoot, 'src', 'client', 'render', 'overlays-meta.ts')
writeFileSync(metaTsPath, metaTsContent, 'utf-8')
console.log(`[Overlay Packer] Wrote ${metaTsPath}`)
@ -2038,7 +2110,6 @@ ${overlayMetasEntries.join('\n')}
console.log(`[Overlay Packer] Wrote sandboxed ${sandboxMetaPath}`)
}
return metaRecord
}
@ -2049,3 +2120,4 @@ if (process.argv[1]?.endsWith('pack-overlays.ts')) {
process.exit(1)
})
}

View File

@ -186,6 +186,17 @@ async function main() {
totalIcons++
}
}
const manifest = {
schema: 1,
classes: CLASS_CODES,
backgrounds: 21,
totalIcons,
}
writeFileSync(join(outDir, 'manifest.json'), JSON.stringify(manifest, null, 2))
const assetsSkillsDir = join(process.cwd(), 'public', 'assets', 'skills')
const { cpSync } = await import('node:fs')
mkdirSync(assetsSkillsDir, { recursive: true })
cpSync(outDir, assetsSkillsDir, { recursive: true, force: true })
console.log(`Extracted ${totalIcons} skill icons!`)
}
@ -193,3 +204,4 @@ main().catch(err => {
console.error(err)
process.exit(1)
})

View File

@ -568,12 +568,52 @@ export async function runPackTiles(
}
await writeFile(join(outDir, 'index.json'), JSON.stringify(catalogIndex, null, 2))
// Mirror tile packs to canonical directories
const { cp } = await import('node:fs/promises')
const mirrorTileDirs = [
resolve('public/act-packs/tiles'),
resolve('public/assets/act-packs/tiles'),
resolve('public/assets/tiles'),
resolve('samples/d2-packs/tiles'),
]
for (const targetDir of mirrorTileDirs) {
if (targetDir === outDir) continue
await mkdir(targetDir, { recursive: true })
await cp(outDir, targetDir, { recursive: true, force: true })
}
return catalogIndex
}
function parseTilesCliArgs(argv: readonly string[]): { archiveArg?: string; outArg?: string } {
let archiveArg: string | undefined
let outArg: string | undefined
const positional: string[] = []
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i]!
if (arg === '--mpq-dir' && argv[i + 1]) {
archiveArg = argv[++i]!
} else if (arg.startsWith('--mpq-dir=')) {
archiveArg = arg.slice('--mpq-dir='.length)
} else if (arg === '--out-dir' && argv[i + 1]) {
const base = argv[++i]!
outArg = base.endsWith('tiles') ? base : join(base, 'act-packs', 'tiles')
} else if (arg.startsWith('--out-dir=')) {
const base = arg.slice('--out-dir='.length)
outArg = base.endsWith('tiles') ? base : join(base, 'act-packs', 'tiles')
} else if (!arg.startsWith('--')) {
positional.push(arg)
}
}
if (positional[0]) archiveArg = positional[0]
if (positional[1]) outArg = positional[1]
return { ...(archiveArg ? { archiveArg } : {}), ...(outArg ? { outArg } : {}) }
}
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 { archiveArg, outArg } = parseTilesCliArgs(process.argv.slice(2))
const t0 = performance.now()
const result = await runPackTiles(archiveArg, outArg)
const elapsedSec = ((performance.now() - t0) / 1000).toFixed(2)
@ -583,3 +623,4 @@ if (isMain) {
`in ${elapsedSec}s — missingFiles=${String(result.missingFiles.length)}, invisWalFloorVerified=${String(result.invisWalFloorVerified)}`,
)
}

View File

@ -33,6 +33,8 @@ export interface SpriteRect {
readonly y: number
readonly w: number
readonly h: number
readonly offsetX?: number | undefined
readonly offsetY?: number | undefined
}
export type AutomapSpriteKey =
@ -135,12 +137,15 @@ function transformSpriteFrame(frame: SpriteFrame, table: Uint8Array): SpriteFram
indices[i] = 0
}
}
const dc6Frame = frame as SpriteFrame & { readonly offsetX?: number; readonly offsetY?: number }
return {
width: frame.width,
height: frame.height,
indices,
mask: frame.mask,
}
...(dc6Frame.offsetX !== undefined ? { offsetX: dc6Frame.offsetX } : {}),
...(dc6Frame.offsetY !== undefined ? { offsetY: dc6Frame.offsetY } : {}),
} as SpriteFrame
}
/**
@ -325,18 +330,23 @@ function buildFontAtlas(name: string, dc6: SpriteSheet, tblBytes: Uint8Array): {
}
}
async function main(): Promise<void> {
export async function bakeUi(mpqDir = 'samples/d2', outBaseDir = 'public'): 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}`)))
archives.add(m, await MpqArchive.open(await fileSource(join(mpqDir, m))))
}
const pl2 = decodePl2(await archives.read('data/global/palette/ACT1/pal.pl2'))
const outDir = join(process.cwd(), 'public', 'ui')
const assetsUiDir = join(process.cwd(), 'public', 'assets', 'ui')
const customUiDir = join(process.cwd(), outBaseDir, 'ui')
const targetUiDirs = [...new Set([outDir, assetsUiDir, customUiDir])]
const fontsDir = join(outDir, 'fonts')
mkdirSync(outDir, { recursive: true })
mkdirSync(fontsDir, { recursive: true })
for (const d of targetUiDirs) {
mkdirSync(d, { recursive: true })
mkdirSync(join(d, 'fonts'), { recursive: true })
}
let decodedCount = 0
let decodeFailures = 0
@ -362,7 +372,9 @@ async function main(): Promise<void> {
palette: pl2.rgb,
transparentIndex: transparent ? 0 : undefined,
})
writeFileSync(join(outDir, relPath), png)
for (const d of targetUiDirs) {
writeFileSync(join(d, relPath), png)
}
}
// --- STAGE 1: Base UI Panels, Globes, Borders, Fonts, Buttons ---
@ -734,7 +746,11 @@ async function main(): Promise<void> {
}
},
})
writeFileSync(join(fontsDir, 'D2Formal436.ttf'), formalTtf)
for (const d of targetUiDirs) {
writeFileSync(join(d, 'fonts', 'metrics.json'), JSON.stringify(fontMetas, null, 2))
writeFileSync(join(d, 'fonts', 'D2Exocet.ttf'), exocetTtf)
writeFileSync(join(d, 'fonts', 'D2Formal436.ttf'), formalTtf)
}
// --- STAGE 2: Comprehensive Items, Flippy Ground Sprites & Unique Items Packing ---
console.log('Extracting item sprites & flippy ground sprites (including uniqueitems.txt & setitems.txt)...')
@ -746,12 +762,17 @@ async function main(): Promise<void> {
const baseUniqueInvFile: Record<string, string> = {}
const baseSetInvFile: Record<string, string> = {}
const baseFlippyFile: Record<string, string> = {}
const itemClassIdToCode: Record<number, string> = {}
let nextItemClassId = 0
// 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 name = r['name']?.trim()
if (!code || code === 'Expansion' || name === 'Expansion') continue
itemClassIdToCode[nextItemClassId++] = code
const inv = r['invfile']?.trim().toLowerCase()
if (code && inv) {
baseInvFile[code] = inv
@ -1019,31 +1040,49 @@ async function main(): Promise<void> {
codeToFlippyFile[flp] = flp
}
// Pack items-atlas.png (ATLAS_W = 1024)
// Pack items-atlas.png (ATLAS_W = 2048)
const sortedInvNames = [...uniqueInvFiles].sort()
const ATLAS_W = 1024
const ATLAS_W = 2048
let curX = 0
let curY = 0
let rowH = 0
const itemRects: Record<string, SpriteRect> = {}
const flippyRects: Record<string, SpriteRect> = {}
const flippyFrames: Record<string, SpriteRect[]> = {}
const itemPlacements: BlitPlacement[] = []
const decodedBaseFrames = new Map<string, SpriteFrame>()
const placeFrameInAtlas = (frame: SpriteFrame, includeOffsets = false): SpriteRect => {
if (curX + frame.width > ATLAS_W) {
curX = 0
curY += rowH + 2
rowH = 0
}
const dc6Frame = frame as SpriteFrame & { readonly offsetX?: number; readonly offsetY?: number }
const rect: SpriteRect = {
x: curX,
y: curY,
w: frame.width,
h: frame.height,
...(includeOffsets
? {
offsetX: dc6Frame.offsetX ?? 0,
offsetY: dc6Frame.offsetY ?? 0,
}
: {}),
}
itemPlacements.push({ frame, x: curX, y: curY })
curX += frame.width + 2
if (frame.height > rowH) rowH = frame.height
return rect
}
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
itemRects[invName] = placeFrameInAtlas(frame, false)
}
// Pre-bake and stitch transformed item frames (cgrn, dpur, lgry, blac, whit, etc.)
@ -1099,21 +1138,13 @@ async function main(): Promise<void> {
}
const table = getTransformTable(t.colorCode)
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
itemRects[t.transformedName] = placeFrameInAtlas(transformedFrame, false)
}
// --- STAGE 2.5: Authentic Flippy Ground Item Sprites (213 DC6 files) ---
console.log('Packing authentic flippy ground item sprites...')
// --- STAGE 2.5: Authentic Flippy Ground Item Sprites (all animation frames 0..N-1) ---
console.log('Packing authentic flippy ground item sprites (all animation frames)...')
const sortedFlippyNames = [...uniqueFlippyFiles].sort()
const decodedFlippyBaseFrames = new Map<string, SpriteFrame>()
const decodedFlippyBaseFrames = new Map<string, readonly SpriteFrame[]>()
for (const flpName of sortedFlippyNames) {
const dc6 = await decodeUiDc6(`data/global/items/${flpName}.dc6`)
@ -1121,68 +1152,72 @@ async function main(): Promise<void> {
// flpgld has 4 groups: 0=small pile, 1=medium pile, 2=large pile, 3=huge pile
for (let g = 0; g < dc6.groups.length; g++) {
const groupFrames = dc6.groups[g]?.frames
const lastFrame = groupFrames ? groupFrames[groupFrames.length - 1] : undefined
if (!lastFrame) continue
if (curX + lastFrame.width > ATLAS_W) {
curX = 0
curY += rowH + 2
rowH = 0
}
if (!groupFrames || groupFrames.length === 0) continue
const key = `flpgld_${g}`
const rect: SpriteRect = { x: curX, y: curY, w: lastFrame.width, h: lastFrame.height }
flippyRects[key] = rect
itemRects[key] = rect
itemPlacements.push({ frame: lastFrame, x: curX, y: curY })
curX += lastFrame.width + 2
if (lastFrame.height > rowH) rowH = lastFrame.height
const rectsForGroup: SpriteRect[] = []
for (const f of groupFrames) {
rectsForGroup.push(placeFrameInAtlas(f, true))
}
const lastRect = rectsForGroup[rectsForGroup.length - 1]!
flippyFrames[key] = rectsForGroup
flippyRects[key] = lastRect
itemRects[key] = lastRect
}
if (flippyRects['flpgld_0']) {
if (flippyRects['flpgld_0'] && flippyFrames['flpgld_0']) {
flippyFrames['flpgld'] = flippyFrames['flpgld_0']
flippyRects['flpgld'] = flippyRects['flpgld_0']
itemRects['flpgld'] = flippyRects['flpgld_0']
}
} else {
const groupFrames = dc6.groups[0]?.frames
const lastFrame = groupFrames ? groupFrames[groupFrames.length - 1] : undefined
if (!lastFrame) continue
decodedFlippyBaseFrames.set(flpName, lastFrame)
if (curX + lastFrame.width > ATLAS_W) {
curX = 0
curY += rowH + 2
rowH = 0
if (!groupFrames || groupFrames.length === 0) continue
decodedFlippyBaseFrames.set(flpName, groupFrames)
const rectsForFlippy: SpriteRect[] = []
for (const f of groupFrames) {
rectsForFlippy.push(placeFrameInAtlas(f, true))
}
const rect: SpriteRect = { x: curX, y: curY, w: lastFrame.width, h: lastFrame.height }
flippyRects[flpName] = rect
itemRects[flpName] = rect
itemPlacements.push({ frame: lastFrame, x: curX, y: curY })
curX += lastFrame.width + 2
if (lastFrame.height > rowH) rowH = lastFrame.height
const lastRect = rectsForFlippy[rectsForFlippy.length - 1]!
flippyFrames[flpName] = rectsForFlippy
flippyRects[flpName] = lastRect
itemRects[flpName] = lastRect
}
}
// Pre-bake and stitch transformed flippy frames
// Pre-bake and stitch transformed flippy frames (all frames 0..N-1)
for (const t of transformedFlippyMap.values()) {
let baseFrame = decodedFlippyBaseFrames.get(t.baseFlp)
if (!baseFrame) {
let baseFrames = decodedFlippyBaseFrames.get(t.baseFlp)
if (!baseFrames) {
if (archives.has(`data/global/items/${t.baseFlp}.dc6`)) {
const dc6 = await decodeUiDc6(`data/global/items/${t.baseFlp}.dc6`)
const groupFrames = dc6.groups[0]?.frames
baseFrame = groupFrames ? groupFrames[groupFrames.length - 1] : undefined
baseFrames = dc6.groups[0]?.frames
}
}
if (!baseFrame) continue
if (!baseFrames || baseFrames.length === 0) continue
const table = getTransformTable(t.colorCode)
const transformedFrame = transformSpriteFrame(baseFrame, table)
if (curX + transformedFrame.width > ATLAS_W) {
curX = 0
curY += rowH + 2
rowH = 0
const rectsForTransformed: SpriteRect[] = []
for (const bf of baseFrames) {
const transformedFrame = transformSpriteFrame(bf, table)
rectsForTransformed.push(placeFrameInAtlas(transformedFrame, true))
}
const lastRect = rectsForTransformed[rectsForTransformed.length - 1]!
flippyFrames[t.transformedName] = rectsForTransformed
flippyRects[t.transformedName] = lastRect
itemRects[t.transformedName] = lastRect
}
// Alias itemRects, flippyRects, and flippyFrames by item code for direct lookup convenience
for (const [code, invFile] of Object.entries(codeToInvFile)) {
if (!itemRects[code] && itemRects[invFile]) {
itemRects[code] = itemRects[invFile]!
}
}
for (const [code, flpFile] of Object.entries(codeToFlippyFile)) {
if (!flippyRects[code] && flippyRects[flpFile]) {
flippyRects[code] = flippyRects[flpFile]!
}
if (!flippyFrames[code] && flippyFrames[flpFile]) {
flippyFrames[code] = flippyFrames[flpFile]!
}
const rect: SpriteRect = { x: curX, y: curY, w: transformedFrame.width, h: transformedFrame.height }
flippyRects[t.transformedName] = rect
itemRects[t.transformedName] = rect
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)
@ -1390,8 +1425,11 @@ async function main(): Promise<void> {
transparentIndex: 0,
})
writeFileSync(join(outDir, spec.uiPngName), pngBytes)
writeFileSync(join(automapOutDir, `${spec.key}.png`), pngBytes)
for (const d of targetUiDirs) {
writeFileSync(join(d, spec.uiPngName), pngBytes)
mkdirSync(join(d, 'automap'), { recursive: true })
writeFileSync(join(d, 'automap', `${spec.key}.png`), pngBytes)
}
automapAtlases[spec.key] = {
image: `/assets/ui/automap/${spec.key}.png`,
@ -1408,9 +1446,12 @@ async function main(): Promise<void> {
const automapManifest: AutomapUiManifest = {
atlases: automapAtlases,
}
writeFileSync(join(automapOutDir, 'manifest.json'), JSON.stringify(automapManifest, null, 2))
for (const d of targetUiDirs) {
mkdirSync(join(d, 'automap'), { recursive: true })
writeFileSync(join(d, 'automap', 'manifest.json'), JSON.stringify(automapManifest, null, 2))
}
// --- STAGE 4: Emit public/ui/manifest.json ---
// --- STAGE 4: Emit public/ui/manifest.json & ui-atlas.json ---
const imagesMap: Record<string, string> = {
ctrlPnl: '/ui/ctrlpnl-800.png',
globeLife: '/ui/globe-life.png',
@ -1474,13 +1515,19 @@ async function main(): Promise<void> {
codeToFlippyFile,
itemRects,
flippyRects,
flippyFrames,
itemClassIdToCode,
questRects,
images: imagesMap,
automapAtlases,
}
writeFileSync(join(outDir, 'manifest.json'), JSON.stringify(manifest, null, 2))
const manifestJson = JSON.stringify(manifest, null, 2)
for (const d of targetUiDirs) {
writeFileSync(join(d, 'manifest.json'), manifestJson)
writeFileSync(join(d, 'ui-atlas.json'), manifestJson)
}
// --- STAGE 5: Emit src/ui/baked-ui-meta.ts ---
// --- STAGE 5: Emit src/common/items/baked-ui-meta.ts ---
const bakedMetaCode = `/**
* Auto-generated baked UI metadata for Diablo II Web client.
* Generated by \`scripts/pack-ui.ts\`.
@ -1492,6 +1539,8 @@ export interface SpriteRect {
readonly y: number
readonly w: number
readonly h: number
readonly offsetX?: number | undefined
readonly offsetY?: number | undefined
}
export type AutomapSpriteKey =
@ -1546,6 +1595,8 @@ export interface BakedUiManifest {
readonly codeToFlippyFile: Record<string, string>
readonly itemRects: Record<string, SpriteRect>
readonly flippyRects: Record<string, SpriteRect>
readonly flippyFrames: Record<string, readonly SpriteRect[]>
readonly itemClassIdToCode: Record<number, string>
readonly questRects: Record<string, SpriteRect>
readonly images: Record<string, string>
readonly automapAtlases?: Readonly<Record<AutomapSpriteKey, AutomapSpriteAtlasMeta>>
@ -1613,6 +1664,8 @@ export const BAKED_UI_MANIFEST: BakedUiManifest = {
codeToFlippyFile: ${JSON.stringify(codeToFlippyFile, null, 2)},
itemRects: ${JSON.stringify(itemRects, null, 2)},
flippyRects: ${JSON.stringify(flippyRects, null, 2)},
flippyFrames: ${JSON.stringify(flippyFrames, null, 2)},
itemClassIdToCode: ${JSON.stringify(itemClassIdToCode, null, 2)},
questRects: ${JSON.stringify(questRects, null, 2)},
images: ${JSON.stringify(imagesMap, null, 2)},
automapAtlases: AUTOMAP_UI_MANIFEST.atlases,
@ -1622,10 +1675,36 @@ export { CURSOR_METADATA, type CursorFrameMeta, type CursorAtlasMetadata } from
`
writeFileSync(join(process.cwd(), 'src', 'common', 'items', 'baked-ui-meta.ts'), bakedMetaCode, 'utf8')
console.log(`Successfully decoded ${decodedCount} DC6 sheets (${decodeFailures} failures)!`)
console.log('Emitted public/ui/manifest.json, public/assets/ui/automap/manifest.json, and src/common/items/baked-ui-meta.ts.')
console.log('Emitted public/ui/manifest.json, public/ui/ui-atlas.json, public/assets/ui/manifest.json, and src/common/items/baked-ui-meta.ts.')
}
main().catch((err) => {
console.error('pack-ui failed:', err)
process.exit(1)
})
function parseUiCliArgs(argv: readonly string[]): { mpqDir: string; outBaseDir: string } {
let mpqDir = 'samples/d2'
let outBaseDir = 'public'
const positional: string[] = []
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i]!
if (arg === '--mpq-dir' && argv[i + 1]) {
mpqDir = argv[++i]!
} else if (arg.startsWith('--mpq-dir=')) {
mpqDir = arg.slice('--mpq-dir='.length)
} else if (arg === '--out-dir' && argv[i + 1]) {
outBaseDir = argv[++i]!
} else if (arg.startsWith('--out-dir=')) {
outBaseDir = arg.slice('--out-dir='.length)
} else if (!arg.startsWith('--')) {
positional.push(arg)
}
}
if (positional[0]) mpqDir = positional[0]
if (positional[1]) outBaseDir = positional[1]
return { mpqDir, outBaseDir }
}
if (import.meta.url === `file://${process.argv[1]}`) {
const { mpqDir, outBaseDir } = parseUiCliArgs(process.argv.slice(2))
bakeUi(mpqDir, outBaseDir).catch((err) => {
console.error('pack-ui failed:', err)
process.exit(1)
})
}

File diff suppressed because it is too large Load Diff

View File

@ -135,7 +135,7 @@ import type { DrlgTableName } from '../drlg/drlg-tables.ts'
export interface ClipMeta {
readonly group: number
readonly directions?: number
readonly directions: number
readonly frames: number
readonly framesPerDir?: number
readonly speed: number
@ -173,6 +173,7 @@ export interface EntityMeta {
readonly frameDurationMs?: number
readonly animSpeed?: number
readonly clips?: Record<string, ClipMeta>
readonly modes?: Record<string, ClipMeta>
readonly glowClips?: Record<string, ClipMeta>
readonly walk?: number
readonly stand?: number
@ -188,6 +189,109 @@ export interface EntityMeta {
export type PackedMonsterJson = EntityMeta
export type PackedCharJson = EntityMeta
export interface PackedEntityIndexEntry {
readonly key: string
readonly token: string
readonly weapon: string
readonly file: string
readonly format?: string | undefined
readonly blendMode?: 'normal' | 'additive' | undefined
readonly width: number
readonly height: number
readonly directions: number
}
export interface MonsterIndexEntry {
readonly hcIdx: number
readonly classId: number
readonly id: string
readonly token: string
readonly weapon: string
readonly key: string | null
readonly slug: string | null
readonly file: string | null
readonly metaFile: string | null
readonly packPath: string | null
readonly inTown: boolean
readonly npc: boolean
readonly isNpc: boolean
readonly interact: boolean
readonly align: number
readonly sizeX: number
readonly sizeY: number
readonly velocity: number
readonly run: number
readonly directions: number
readonly dirCount: number
readonly scale: number
readonly baseCol: number
readonly modes: readonly string[]
}
export interface ObjectIndexEntry {
readonly id: number
readonly classId: number
readonly name: string
readonly description: string
readonly token: string
readonly slug: string | null
readonly file: string | null
readonly metaFile: string | null
readonly packPath: string | null
readonly actTokens: Readonly<Record<number, string>>
readonly actSlugs: Readonly<Record<number, string>>
readonly baseIsMonsters: boolean
readonly draw: boolean
readonly isDoor: boolean
readonly subClass: number
readonly sizeX: number
readonly sizeY: number
readonly xOffset: number
readonly yOffset: number
readonly operateRange: number
readonly selectable: readonly boolean[]
readonly frameCnt: readonly number[]
readonly frameCounts: readonly number[]
readonly frameDelta: readonly number[]
readonly frameDeltas: readonly number[]
readonly cycleAnim: readonly boolean[]
readonly start: readonly number[]
readonly directions: number
readonly dirCount: number
readonly modes: readonly string[]
readonly blendMode?: 'normal' | 'additive' | undefined
readonly trans?: number | undefined
}
export interface ObjectLookupIndexEntry {
readonly act: number
readonly type: number
readonly ds1Id: number
readonly id: number
readonly objectsTxtId: number
readonly classId: number
readonly token: string
readonly mode: string
readonly direction: number
readonly baseIsMonsters: boolean
readonly slug: string | null
readonly file: string | null
readonly metaFile: string | null
readonly packPath: string | null
}
export interface PackedEntityCatalogIndex {
readonly schema: number
readonly character: string
readonly characters?: readonly string[] | undefined
readonly entities: readonly PackedEntityIndexEntry[]
readonly monsterByClassId?: Readonly<Record<string, MonsterIndexEntry>> | undefined
readonly monstersByHcIdx?: Readonly<Record<string, MonsterIndexEntry>> | undefined
readonly objectByClassId?: Readonly<Record<string, ObjectIndexEntry>> | undefined
readonly objectsByClassId?: Readonly<Record<string, ObjectIndexEntry>> | undefined
readonly objectLookupByActTypeId?: Readonly<Record<string, ObjectLookupIndexEntry>> | undefined
}
export interface PackedTilePlacement {
readonly act: number
readonly page: number
@ -312,6 +416,8 @@ export interface SpriteRect {
readonly y: number
readonly w: number
readonly h: number
readonly offsetX?: number | undefined
readonly offsetY?: number | undefined
}
export type AutomapSpriteKey =
@ -366,6 +472,8 @@ export interface BakedUiManifest {
readonly codeToFlippyFile: Record<string, string>
readonly itemRects: Record<string, SpriteRect>
readonly flippyRects: Record<string, SpriteRect>
readonly flippyFrames?: Readonly<Record<string, readonly SpriteRect[]>> | undefined
readonly itemClassIdToCode?: Readonly<Record<number, string>> | undefined
readonly questRects: Record<string, SpriteRect>
readonly images: Record<string, string>
readonly automapAtlases?: Readonly<Record<AutomapSpriteKey, AutomapSpriteAtlasMeta>>
@ -532,6 +640,9 @@ const CACHEABLE_PREFIXES = [
'skills/',
'ui/',
'assets/',
'entities/',
'characters/',
'tiles/',
] as const
const CACHEABLE_EXT_RE =

View File

@ -4,8 +4,10 @@
import type { MountedArchives } from '../mpq/mount.ts'
import { decodeCof, cofLayerOrder } from '../formats/cof.ts'
import type { CofFile } from '../formats/cof.ts'
import { decodeDc6 } from '../formats/dc6.ts'
import type { Dc6Frame, Dc6Sheet } from '../formats/dc6.ts'
import { decodeDcc } from '../formats/dcc.ts'
import type { DccFile } from '../formats/dcc.ts'
import type { DccDirection, DccFile, DccFrame } from '../formats/dcc.ts'
import type { SpriteFrame, SpriteSheet } from '../formats/sprite.ts'
import { dir64ToDcc } from './character.ts'
@ -15,6 +17,21 @@ export const MONSTER_COMPONENTS: readonly string[] = [
's1', 's2', 's3', 's4', 's5', 's6', 's7', 's8'
]
/**
* Canonical 1.13c monster animation modes baked for full-spectrum monster & Town NPC packs.
*/
export const TARGET_MONSTER_MODES = [
'wl', 'nu', 'rn', 'a1', 'a2', 'sc', 'gh', 'dt', 'dd',
's1', 's2', 's3', 's4', 'on', 'op',
] as const
/**
* Canonical 1.13c object animation modes (`objmode.txt` / `Objects.txt` Mode0..Mode7).
*/
export const TARGET_OBJECT_MODES = [
'nu', 'op', 'on', 's1', 's2', 's3', 's4', 's5',
] as const
/**
* Tokens of monsters that require additive blending for their ghostly or energy appearance.
*/
@ -22,8 +39,48 @@ export const ADDITIVE_MONSTER_TOKENS: ReadonlySet<string> = new Set([
'WR', 'WW', 'FR', 'K9', '17', '46', 'FI', 'X3', 'X4', 'XW',
])
interface ArchiveFileIndex {
readonly allFiles: readonly string[]
readonly byLower: ReadonlyMap<string, string>
readonly byRootPrefix: ReadonlyMap<string, readonly string[]>
}
const archiveIndexCache = new WeakMap<MountedArchives, Promise<ArchiveFileIndex>>()
async function getArchiveFileIndex(archives: MountedArchives): Promise<ArchiveFileIndex> {
let promise = archiveIndexCache.get(archives)
if (!promise) {
promise = (async () => {
const allFiles = await archives.listFiles()
const byLower = new Map<string, string>()
const byRootPrefix = new Map<string, string[]>()
for (const name of allFiles) {
const lower = name.toLowerCase()
if (!byLower.has(lower)) {
byLower.set(lower, name)
}
if (lower.startsWith('data\\global\\monsters\\') || lower.startsWith('data\\global\\objects\\')) {
const parts = lower.split('\\')
if (parts.length >= 4) {
const rootKey = `${parts[0]}\\${parts[1]}\\${parts[2]}\\${parts[3]}\\`
let list = byRootPrefix.get(rootKey)
if (!list) {
list = []
byRootPrefix.set(rootKey, list)
}
list.push(name)
}
}
}
return { allFiles, byLower, byRootPrefix }
})()
archiveIndexCache.set(archives, promise)
}
return promise
}
/**
* Find the DCC sprite member for a specific monster COF layer.
* Find the DCC or DC6 sprite member for a specific monster/object COF layer.
*/
export function findMonsterLayerSprite(
names: readonly string[],
@ -38,16 +95,18 @@ export function findMonsterLayerSprite(
const weapons = Array.from(new Set([layerWeaponClass, weapon, 'hth'].filter((w): w is string => Boolean(w))))
const anims = animation.toLowerCase() === 'wl' ? ['wl', 'nu'] : [animation.toLowerCase()]
for (const anim of anims) {
for (const w of weapons) {
const suffix = `${anim}${w.toLowerCase()}.dcc`
const hits = names.filter(name => {
const lowerName = name.toLowerCase()
return lowerName.startsWith(prefix) && lowerName.endsWith(suffix)
})
if (hits.length > 0) {
hits.sort()
return hits[0]
for (const ext of ['.dcc', '.dc6']) {
for (const anim of anims) {
for (const w of weapons) {
const suffix = `${anim}${w.toLowerCase()}${ext}`
const hits = names.filter(name => {
const lowerName = name.toLowerCase()
return lowerName.startsWith(prefix) && lowerName.endsWith(suffix)
})
if (hits.length > 0) {
hits.sort()
return hits[0]
}
}
}
}
@ -78,6 +137,113 @@ function blit(
}
}
/**
* Adapts a decoded DC6 sprite sheet into a DccFile-compatible directional structure
* so COF layer compositing handles both .dcc and .dc6 layers identically.
*/
export function dc6ToDccFile(
sheet: Dc6Sheet,
cofDirections = sheet.header.directions,
cofFramesPerDir = sheet.header.framesPerDirection,
): DccFile {
let dirFrameLists: (readonly Dc6Frame[])[] = []
if (sheet.groups.length === cofDirections) {
dirFrameLists = sheet.groups.map(g => g.frames)
} else if (
sheet.groups.length === 1 &&
cofDirections > 1 &&
(sheet.groups[0]?.frames.length ?? 0) >= cofDirections * cofFramesPerDir
) {
const allFrames = sheet.groups[0]!.frames
for (let d = 0; d < cofDirections; d += 1) {
dirFrameLists.push(allFrames.slice(d * cofFramesPerDir, (d + 1) * cofFramesPerDir))
}
} else {
dirFrameLists = sheet.groups.map(g => g.frames)
}
const directions: DccDirection[] = []
for (const dirFrames of dirFrameLists) {
if (dirFrames.length === 0) {
directions.push({
box: { left: 0, top: 0, width: 1, height: 1 },
frames: [],
})
continue
}
let left = Number.POSITIVE_INFINITY
let top = Number.POSITIVE_INFINITY
let right = Number.NEGATIVE_INFINITY
let bottom = Number.NEGATIVE_INFINITY
for (const f of dirFrames) {
const fLeft = f.offsetX
const fTop = f.offsetY - f.height + 1
left = Math.min(left, fLeft)
top = Math.min(top, fTop)
right = Math.max(right, fLeft + f.width)
bottom = Math.max(bottom, fTop + f.height)
}
const boxLeft = Number.isFinite(left) ? Math.round(left) : 0
const boxTop = Number.isFinite(top) ? Math.round(top) : 0
const boxWidth = Number.isFinite(right) ? Math.max(1, Math.round(right) - boxLeft) : 1
const boxHeight = Number.isFinite(bottom) ? Math.max(1, Math.round(bottom) - boxTop) : 1
const dirBox = { left: boxLeft, top: boxTop, width: boxWidth, height: boxHeight }
const dccFrames: DccFrame[] = []
for (const f of dirFrames) {
const fLeft = f.offsetX
const fTop = f.offsetY - f.height + 1
let canvas: SpriteFrame
if (fLeft === boxLeft && fTop === boxTop && f.width === boxWidth && f.height === boxHeight) {
canvas = {
width: boxWidth,
height: boxHeight,
indices: f.indices,
mask: f.mask,
}
} else {
const indices = new Uint8Array(boxWidth * boxHeight)
const mask = new Uint8Array(boxWidth * boxHeight)
const target = { width: boxWidth, height: boxHeight, indices, mask }
blit(target, f, fLeft - boxLeft, fTop - boxTop)
canvas = target
}
dccFrames.push({
width: f.width,
height: f.height,
offsetX: f.offsetX,
offsetY: f.offsetY,
frame: canvas,
bottomUp: false,
})
}
directions.push({
box: dirBox,
frames: dccFrames,
})
}
return {
version: 6,
directions,
}
}
function decodeLayerSpriteBytes(
member: string,
bytes: Uint8Array,
cofDirections: number,
cofFramesPerDir: number,
): DccFile {
if (member.toLowerCase().endsWith('.dc6')) {
return dc6ToDccFile(decodeDc6(bytes), cofDirections, cofFramesPerDir)
}
return decodeDcc(bytes)
}
export interface MonsterLayerFailure {
readonly token: string
readonly animation: string
@ -107,10 +273,11 @@ export async function compositeMonsterAnimation(
animation: string,
weapon: string = 'hth',
): Promise<MonsterAnimationSheet> {
const lower = token.toLowerCase()
const lower = token.trim().toLowerCase()
const root = `data\\global\\monsters\\${lower}\\`
const names = await archives.listFiles()
const fileIndex = await getArchiveFileIndex(archives)
const tokenFiles = fileIndex.byRootPrefix.get(root) ?? fileIndex.allFiles
let usedAnim = animation.toLowerCase()
let usedWeapon = weapon.toLowerCase()
const candidates = [
@ -126,20 +293,59 @@ export async function compositeMonsterAnimation(
let matchedCof: string | undefined
for (const cand of candidates) {
matchedCof = names.find(n => n.toLowerCase() === cand.toLowerCase())
matchedCof = fileIndex.byLower.get(cand.toLowerCase())
if (matchedCof) {
if (cand.includes('nu')) usedAnim = 'nu'
if (cand.endsWith(`nu${usedWeapon}.cof`) || cand.endsWith('nuhth.cof')) usedAnim = 'nu'
if (cand.endsWith('hth.cof')) usedWeapon = 'hth'
break
}
}
if (!matchedCof) {
throw new Error(`Monster COF not found: ${candidates[0]}`)
const cofPrefix = `${root}cof\\${lower}${usedAnim}`.toLowerCase()
const altCofs = tokenFiles
.filter(n => {
const ln = n.toLowerCase()
return ln.startsWith(cofPrefix) && ln.endsWith('.cof') && ln.length === cofPrefix.length + 7
})
.sort()
if (altCofs.length > 0) {
matchedCof = altCofs[0]
usedWeapon = matchedCof!.slice(-7, -4).toLowerCase()
}
}
const cofBytes = await archives.read(matchedCof)
const cof: CofFile = decodeCof(cofBytes)
let cof: CofFile
if (matchedCof) {
const cofBytes = await archives.read(matchedCof)
cof = decodeCof(cofBytes)
} else {
const standaloneTr = findMonsterLayerSprite(
tokenFiles,
root,
lower,
usedAnim,
usedWeapon,
'tr',
usedWeapon,
)
if (!standaloneTr) {
throw new Error(`Monster COF not found: ${candidates[0]}`)
}
const trBytes = await archives.read(standaloneTr)
const trSprite = decodeLayerSpriteBytes(standaloneTr, trBytes, 8, 8)
const numDirs = Math.max(1, trSprite.directions.length)
const framesPerDir = Math.max(1, trSprite.directions[0]?.frames.length ?? 1)
cof = {
numberOfLayers: 1,
framesPerDirection: framesPerDir,
numberOfDirections: numDirs,
speed: 256,
layers: [{ type: 1, shadow: 1, selectable: true, transparent: false, drawEffect: 0, weaponClass: usedWeapon }],
animationFrames: new Uint8Array(framesPerDir),
priority: Array.from({ length: numDirs }, () => Array.from({ length: framesPerDir }, () => [1])),
}
}
const layerResults = await Promise.all(cof.layers.map(async (layer) => {
const component = MONSTER_COMPONENTS[layer.type]
@ -151,7 +357,7 @@ export async function compositeMonsterAnimation(
}
}
const member = findMonsterLayerSprite(
names,
tokenFiles,
root,
lower,
usedAnim,
@ -168,7 +374,11 @@ export async function compositeMonsterAnimation(
}
try {
const bytes = await archives.read(member)
return { sprite: decodeDcc(bytes), member, failure: undefined }
return {
sprite: decodeLayerSpriteBytes(member, bytes, cof.numberOfDirections, cof.framesPerDirection),
member,
failure: undefined,
}
} catch (err) {
return {
sprite: null,
@ -188,14 +398,124 @@ export async function compositeMonsterAnimation(
const sprites: (DccFile | null)[] = layerResults.map(r => r.sprite)
const decodedMembers: string[] = layerResults.flatMap(r => r.member ? [r.member] : [])
const layerFailures: MonsterLayerFailure[] = layerResults.flatMap(r => r.failure ? [r.failure] : [])
for (const failure of layerFailures) {
console.error(
`monster art layer dropped: ${failure.token}/${failure.component ?? '?'} (${failure.animation}) `
+ `— ${failure.reason}${failure.member !== null ? ` [${failure.member}]` : ''}`
+ `${failure.message !== undefined ? `: ${failure.message}` : ''}`,
)
return compositeLoadedLayers(cof, sprites, decodedMembers, layerFailures, 0, 0)
}
/**
* Load and composite a single object animation (`nu`, `op`, `on`, `s1..s5`) across all directions and frames.
* Uses `objectDrawAnchor` offsets (`anchorX = -16 + boxLeft`, `anchorY = 16 + boxTop`).
*/
export async function compositeObjectAnimation(
archives: MountedArchives,
token: string,
animation: string,
baseIsMonsters = false,
): Promise<MonsterAnimationSheet> {
const lower = token.trim().toLowerCase()
const usedAnim = animation.trim().toLowerCase()
const root = baseIsMonsters
? `data\\global\\monsters\\${lower}\\`
: `data\\global\\objects\\${lower}\\`
const fileIndex = await getArchiveFileIndex(archives)
const tokenFiles = fileIndex.byRootPrefix.get(root) ?? fileIndex.allFiles
const exactCof = `${root}cof\\${lower}${usedAnim}hth.cof`.toLowerCase()
let matchedCof = fileIndex.byLower.get(exactCof)
let usedWeapon = 'hth'
if (!matchedCof) {
const cofPrefix = `${root}cof\\${lower}${usedAnim}`.toLowerCase()
const altCofs = tokenFiles
.filter(n => {
const ln = n.toLowerCase()
return ln.startsWith(cofPrefix) && ln.endsWith('.cof') && ln.length === cofPrefix.length + 7
})
.sort()
if (altCofs.length > 0) {
matchedCof = altCofs[0]
usedWeapon = matchedCof!.slice(-7, -4).toLowerCase()
}
}
if (!matchedCof) {
throw new Error(`Object COF not found: ${exactCof}`)
}
const cofBytes = await archives.read(matchedCof)
const cof: CofFile = decodeCof(cofBytes)
const layerResults = await Promise.all(cof.layers.map(async (layer) => {
const component = MONSTER_COMPONENTS[layer.type]
if (component === undefined) {
return {
sprite: null,
member: undefined,
failure: { token: lower, animation: usedAnim, component: null, member: null, reason: 'unknown-component' } as MonsterLayerFailure,
}
}
const litDcc = fileIndex.byLower.get(`${root}${component}\\${lower}${component}lit${usedAnim}${usedWeapon}.dcc`)
const litDc6 = fileIndex.byLower.get(`${root}${component}\\${lower}${component}lit${usedAnim}${usedWeapon}.dc6`)
const member =
litDcc ??
litDc6 ??
findMonsterLayerSprite(
tokenFiles,
root,
lower,
usedAnim,
usedWeapon,
component,
layer.weaponClass,
)
if (member === undefined) {
return {
sprite: null,
member: undefined,
failure: { token: lower, animation: usedAnim, component, member: null, reason: 'no-member' } as MonsterLayerFailure,
}
}
try {
const bytes = await archives.read(member)
return {
sprite: decodeLayerSpriteBytes(member, bytes, cof.numberOfDirections, cof.framesPerDirection),
member,
failure: undefined,
}
} catch (err) {
return {
sprite: null,
member: undefined,
failure: {
token: lower,
animation: usedAnim,
component,
member,
reason: 'decode',
message: (err as Error).message,
} as MonsterLayerFailure,
}
}
}))
const sprites: (DccFile | null)[] = layerResults.map(r => r.sprite)
const decodedMembers: string[] = layerResults.flatMap(r => r.member ? [r.member] : [])
const layerFailures: MonsterLayerFailure[] = layerResults.flatMap(r => r.failure ? [r.failure] : [])
// Sub-tile draw anchor offset from D2Common ordinal 10117 (subtileDrawPositionCoords): (-16, +16)
return compositeLoadedLayers(cof, sprites, decodedMembers, layerFailures, -16, 16)
}
function compositeLoadedLayers(
cof: CofFile,
sprites: readonly (DccFile | null)[],
decodedMembers: readonly string[],
layerFailures: readonly MonsterLayerFailure[],
anchorOffsetX: number,
anchorOffsetY: number,
): MonsterAnimationSheet {
const isAdditiveLayer = (layerIdx: number): boolean => {
const layer = cof.layers[layerIdx]
return layer !== undefined && (layer.drawEffect === 3 || layer.transparent)
@ -223,11 +543,11 @@ export async function compositeMonsterAnimation(
for (let layerIdx = 0; layerIdx < cof.layers.length; layerIdx += 1) {
const dcc = sprites[layerIdx]
if (!dcc) continue
if (!dcc || dcc.directions.length === 0) continue
const dccDir = dir64ToDcc(dir64, dcc.directions.length)
const dirData = dcc.directions[dccDir]
if (!dirData) continue
const frameData = dirData.frames[f]
const dirData = dcc.directions[dccDir] ?? dcc.directions[0]
if (!dirData || dirData.frames.length === 0) continue
const frameData = dirData.frames[f] ?? dirData.frames[f % dirData.frames.length]
if (!frameData) continue
left = Math.min(left, dirData.box.left)
top = Math.min(top, dirData.box.top)
@ -242,8 +562,8 @@ export async function compositeMonsterAnimation(
height: 1,
indices: new Uint8Array(1),
mask: new Uint8Array(1),
anchorX: 0,
anchorY: 0,
anchorX: anchorOffsetX,
anchorY: anchorOffsetY,
}
baseFrames.push(emptyFrame)
if (isComposite) glowFrames.push(emptyFrame)
@ -272,11 +592,11 @@ export async function compositeMonsterAnimation(
const order = cofLayerOrder(cof, dir, f)
for (const layerIdx of order) {
const dcc = sprites[layerIdx]
if (!dcc) continue
if (!dcc || dcc.directions.length === 0) continue
const dccDir = dir64ToDcc(dir64, dcc.directions.length)
const dirData = dcc.directions[dccDir]
if (!dirData) continue
const frameData = dirData.frames[f]
const dirData = dcc.directions[dccDir] ?? dcc.directions[0]
if (!dirData || dirData.frames.length === 0) continue
const frameData = dirData.frames[f] ?? dirData.frames[f % dirData.frames.length]
if (!frameData) continue
const atX = Math.round(dirData.box.left) - boxLeft
const atY = Math.round(dirData.box.top) - boxTop
@ -292,8 +612,8 @@ export async function compositeMonsterAnimation(
height: frameHeight,
indices: baseTarget.indices,
mask: baseTarget.mask,
anchorX: boxLeft,
anchorY: boxTop,
anchorX: anchorOffsetX + boxLeft,
anchorY: anchorOffsetY + boxTop,
})
if (glowTarget !== null) {
glowFrames.push({
@ -301,8 +621,8 @@ export async function compositeMonsterAnimation(
height: frameHeight,
indices: glowTarget.indices,
mask: glowTarget.mask,
anchorX: boxLeft,
anchorY: boxTop,
anchorX: anchorOffsetX + boxLeft,
anchorY: anchorOffsetY + boxTop,
})
}
}

View File

@ -774,3 +774,752 @@ export function resolveMonsterArtSpec(id: string): MonsterArtSpec | null {
}
return null
}
/**
* Canonical MonStats.txt hcIdx (0..733) -> lowercase monster Id mapping.
*/
export const MONSTER_HCIDX_TO_ID: Readonly<Record<number, string>> = {
0: "skeleton1",
1: "skeleton2",
2: "skeleton3",
3: "skeleton4",
4: "skeleton5",
5: "zombie1",
6: "zombie2",
7: "zombie3",
8: "zombie4",
9: "zombie5",
10: "bighead1",
11: "bighead2",
12: "bighead3",
13: "bighead4",
14: "bighead5",
15: "foulcrow1",
16: "foulcrow2",
17: "foulcrow3",
18: "foulcrow4",
19: "fallen1",
20: "fallen2",
21: "fallen3",
22: "fallen4",
23: "fallen5",
24: "brute2",
25: "brute3",
26: "brute4",
27: "brute5",
28: "brute1",
29: "sandraider1",
30: "sandraider2",
31: "sandraider3",
32: "sandraider4",
33: "sandraider5",
34: "gorgon1",
35: "gorgon2",
36: "gorgon3",
37: "gorgon4",
38: "wraith1",
39: "wraith2",
40: "wraith3",
41: "wraith4",
42: "wraith5",
43: "corruptrogue1",
44: "corruptrogue2",
45: "corruptrogue3",
46: "corruptrogue4",
47: "corruptrogue5",
48: "baboon1",
49: "baboon2",
50: "baboon3",
51: "baboon4",
52: "baboon5",
53: "goatman1",
54: "goatman2",
55: "goatman3",
56: "goatman4",
57: "goatman5",
58: "fallenshaman1",
59: "fallenshaman2",
60: "fallenshaman3",
61: "fallenshaman4",
62: "fallenshaman5",
63: "quillrat1",
64: "quillrat2",
65: "quillrat3",
66: "quillrat4",
67: "quillrat5",
68: "sandmaggot1",
69: "sandmaggot2",
70: "sandmaggot3",
71: "sandmaggot4",
72: "sandmaggot5",
73: "clawviper1",
74: "clawviper2",
75: "clawviper3",
76: "clawviper4",
77: "clawviper5",
78: "sandleaper1",
79: "sandleaper2",
80: "sandleaper3",
81: "sandleaper4",
82: "sandleaper5",
83: "pantherwoman1",
84: "pantherwoman2",
85: "pantherwoman3",
86: "pantherwoman4",
87: "swarm1",
88: "swarm2",
89: "swarm3",
90: "swarm4",
91: "scarab1",
92: "scarab2",
93: "scarab3",
94: "scarab4",
95: "scarab5",
96: "mummy1",
97: "mummy2",
98: "mummy3",
99: "mummy4",
100: "mummy5",
101: "unraveler1",
102: "unraveler2",
103: "unraveler3",
104: "unraveler4",
105: "unraveler5",
106: "chaoshorde1",
107: "chaoshorde2",
108: "chaoshorde3",
109: "chaoshorde4",
110: "vulture1",
111: "vulture2",
112: "vulture3",
113: "vulture4",
114: "mosquito1",
115: "mosquito2",
116: "mosquito3",
117: "mosquito4",
118: "willowisp1",
119: "willowisp2",
120: "willowisp3",
121: "willowisp4",
122: "arach1",
123: "arach2",
124: "arach3",
125: "arach4",
126: "arach5",
127: "thornhulk1",
128: "thornhulk2",
129: "thornhulk3",
130: "thornhulk4",
131: "vampire1",
132: "vampire2",
133: "vampire3",
134: "vampire4",
135: "vampire5",
136: "batdemon1",
137: "batdemon2",
138: "batdemon3",
139: "batdemon4",
140: "batdemon5",
141: "fetish1",
142: "fetish2",
143: "fetish3",
144: "fetish4",
145: "fetish5",
146: "cain1",
147: "gheed",
148: "akara",
149: "chicken",
150: "kashya",
151: "rat",
152: "rogue1",
153: "hellmeteor",
154: "charsi",
155: "warriv1",
156: "andariel",
157: "bird1",
158: "bird2",
159: "bat",
160: "cr_archer1",
161: "cr_archer2",
162: "cr_archer3",
163: "cr_archer4",
164: "cr_archer5",
165: "cr_lancer1",
166: "cr_lancer2",
167: "cr_lancer3",
168: "cr_lancer4",
169: "cr_lancer5",
170: "sk_archer1",
171: "sk_archer2",
172: "sk_archer3",
173: "sk_archer4",
174: "sk_archer5",
175: "warriv2",
176: "atma",
177: "drognan",
178: "fara",
179: "cow",
180: "maggotbaby1",
181: "maggotbaby2",
182: "maggotbaby3",
183: "maggotbaby4",
184: "maggotbaby5",
185: "camel",
186: "blunderbore1",
187: "blunderbore2",
188: "blunderbore3",
189: "blunderbore4",
190: "maggotegg1",
191: "maggotegg2",
192: "maggotegg3",
193: "maggotegg4",
194: "maggotegg5",
195: "act2male",
196: "act2female",
197: "act2child",
198: "greiz",
199: "elzix",
200: "geglash",
201: "jerhyn",
202: "lysander",
203: "act2guard1",
204: "act2vendor1",
205: "act2vendor2",
206: "crownest1",
207: "crownest2",
208: "crownest3",
209: "crownest4",
210: "meshif1",
211: "duriel",
212: "bonefetish1",
213: "bonefetish2",
214: "bonefetish3",
215: "bonefetish4",
216: "bonefetish5",
217: "darkguard1",
218: "darkguard2",
219: "darkguard3",
220: "darkguard4",
221: "darkguard5",
222: "bloodmage1",
223: "bloodmage2",
224: "bloodmage3",
225: "bloodmage4",
226: "bloodmage5",
227: "maggot",
228: "sarcophagus",
229: "radament",
230: "firebeast",
231: "iceglobe",
232: "lightningbeast",
233: "poisonorb",
234: "flyingscimitar",
235: "zealot1",
236: "zealot2",
237: "zealot3",
238: "cantor1",
239: "cantor2",
240: "cantor3",
241: "cantor4",
242: "mephisto",
243: "diablo",
244: "cain2",
245: "cain3",
246: "cain4",
247: "frogdemon1",
248: "frogdemon2",
249: "frogdemon3",
250: "summoner",
251: "tyrael1",
252: "asheara",
253: "hratli",
254: "alkor",
255: "ormus",
256: "izual",
257: "halbu",
258: "tentacle1",
259: "tentacle2",
260: "tentacle3",
261: "tentaclehead1",
262: "tentaclehead2",
263: "tentaclehead3",
264: "meshif2",
265: "cain5",
266: "navi",
267: "bloodraven",
268: "bug",
269: "scorpion",
270: "rogue2",
271: "roguehire",
272: "rogue3",
273: "gargoyletrap",
274: "skmage_pois1",
275: "skmage_pois2",
276: "skmage_pois3",
277: "skmage_pois4",
278: "fetishshaman1",
279: "fetishshaman2",
280: "fetishshaman3",
281: "fetishshaman4",
282: "fetishshaman5",
283: "larva",
284: "maggotqueen1",
285: "maggotqueen2",
286: "maggotqueen3",
287: "maggotqueen4",
288: "maggotqueen5",
289: "claygolem",
290: "bloodgolem",
291: "irongolem",
292: "firegolem",
293: "familiar",
294: "act3male",
295: "baboon6",
296: "act3female",
297: "natalya",
298: "vilemother1",
299: "vilemother2",
300: "vilemother3",
301: "vilechild1",
302: "vilechild2",
303: "vilechild3",
304: "fingermage1",
305: "fingermage2",
306: "fingermage3",
307: "regurgitator1",
308: "regurgitator2",
309: "regurgitator3",
310: "doomknight1",
311: "doomknight2",
312: "doomknight3",
313: "quillbear1",
314: "quillbear2",
315: "quillbear3",
316: "quillbear4",
317: "quillbear5",
318: "snake",
319: "parrot",
320: "fish",
321: "evilhole1",
322: "evilhole2",
323: "evilhole3",
324: "evilhole4",
325: "evilhole5",
326: "trap-firebolt",
327: "trap-horzmissile",
328: "trap-vertmissile",
329: "trap-poisoncloud",
330: "trap-lightning",
331: "act2guard2",
332: "invisospawner",
333: "diabloclone",
334: "suckernest1",
335: "suckernest2",
336: "suckernest3",
337: "suckernest4",
338: "act2hire",
339: "minispider",
340: "boneprison1",
341: "boneprison2",
342: "boneprison3",
343: "boneprison4",
344: "bonewall",
345: "councilmember1",
346: "councilmember2",
347: "councilmember3",
348: "turret1",
349: "turret2",
350: "turret3",
351: "hydra1",
352: "hydra2",
353: "hydra3",
354: "trap-melee",
355: "seventombs",
356: "dopplezon",
357: "valkyrie",
358: "act2guard3",
359: "act3hire",
360: "megademon1",
361: "megademon2",
362: "megademon3",
363: "necroskeleton",
364: "necromage",
365: "griswold",
366: "compellingorb",
367: "tyrael2",
368: "darkwanderer",
369: "trap-nova",
370: "spiritmummy",
371: "lightningspire",
372: "firetower",
373: "slinger1",
374: "slinger2",
375: "slinger3",
376: "slinger4",
377: "act2guard4",
378: "act2guard5",
379: "skmage_cold1",
380: "skmage_cold2",
381: "skmage_cold3",
382: "skmage_cold4",
383: "skmage_fire1",
384: "skmage_fire2",
385: "skmage_fire3",
386: "skmage_fire4",
387: "skmage_ltng1",
388: "skmage_ltng2",
389: "skmage_ltng3",
390: "skmage_ltng4",
391: "hellbovine",
392: "window1",
393: "window2",
394: "slinger5",
395: "slinger6",
396: "fetishblow1",
397: "fetishblow2",
398: "fetishblow3",
399: "fetishblow4",
400: "fetishblow5",
401: "mephistospirit",
402: "smith",
403: "trappedsoul1",
404: "trappedsoul2",
405: "jamella",
406: "izualghost",
407: "fetish11",
408: "malachai",
409: "hephasto",
410: "wakeofdestruction",
411: "chargeboltsentry",
412: "lightningsentry",
413: "bladecreeper",
414: "invisopet",
415: "infernosentry",
416: "deathsentry",
417: "shadowwarrior",
418: "shadowmaster",
419: "druidhawk",
420: "spiritwolf",
421: "fenris",
422: "spiritofbarbs",
423: "heartofwolverine",
424: "oaksage",
425: "plaguepoppy",
426: "cycleoflife",
427: "vinecreature",
428: "druidbear",
429: "eagle",
430: "wolf",
431: "bear",
432: "barricadedoor1",
433: "barricadedoor2",
434: "prisondoor",
435: "barricadetower",
436: "reanimatedhorde1",
437: "reanimatedhorde2",
438: "reanimatedhorde3",
439: "reanimatedhorde4",
440: "reanimatedhorde5",
441: "siegebeast1",
442: "siegebeast2",
443: "siegebeast3",
444: "siegebeast4",
445: "siegebeast5",
446: "snowyeti1",
447: "snowyeti2",
448: "snowyeti3",
449: "snowyeti4",
450: "wolfrider1",
451: "wolfrider2",
452: "wolfrider3",
453: "minion1",
454: "minion2",
455: "minion3",
456: "minion4",
457: "minion5",
458: "minion6",
459: "minion7",
460: "minion8",
461: "suicideminion1",
462: "suicideminion2",
463: "suicideminion3",
464: "suicideminion4",
465: "suicideminion5",
466: "suicideminion6",
467: "suicideminion7",
468: "suicideminion8",
469: "succubus1",
470: "succubus2",
471: "succubus3",
472: "succubus4",
473: "succubus5",
474: "succubuswitch1",
475: "succubuswitch2",
476: "succubuswitch3",
477: "succubuswitch4",
478: "succubuswitch5",
479: "overseer1",
480: "overseer2",
481: "overseer3",
482: "overseer4",
483: "overseer5",
484: "minionspawner1",
485: "minionspawner2",
486: "minionspawner3",
487: "minionspawner4",
488: "minionspawner5",
489: "minionspawner6",
490: "minionspawner7",
491: "minionspawner8",
492: "imp1",
493: "imp2",
494: "imp3",
495: "imp4",
496: "imp5",
497: "catapult1",
498: "catapult2",
499: "catapult3",
500: "catapult4",
501: "frozenhorror1",
502: "frozenhorror2",
503: "frozenhorror3",
504: "frozenhorror4",
505: "frozenhorror5",
506: "bloodlord1",
507: "bloodlord2",
508: "bloodlord3",
509: "bloodlord4",
510: "bloodlord5",
511: "larzuk",
512: "drehya",
513: "malah",
514: "nihlathak",
515: "qual-kehk",
516: "catapultspotter1",
517: "catapultspotter2",
518: "catapultspotter3",
519: "catapultspotter4",
520: "cain6",
521: "tyrael3",
522: "act5barb1",
523: "act5barb2",
524: "barricadewall1",
525: "barricadewall2",
526: "nihlathakboss",
527: "drehyaiced",
528: "evilhut",
529: "deathmauler1",
530: "deathmauler2",
531: "deathmauler3",
532: "deathmauler4",
533: "deathmauler5",
534: "act5pow",
535: "act5barb3",
536: "act5barb4",
537: "ancientstatue1",
538: "ancientstatue2",
539: "ancientstatue3",
540: "ancientbarb1",
541: "ancientbarb2",
542: "ancientbarb3",
543: "baalthrone",
544: "baalcrab",
545: "baaltaunt",
546: "putriddefiler1",
547: "putriddefiler2",
548: "putriddefiler3",
549: "putriddefiler4",
550: "putriddefiler5",
551: "painworm1",
552: "painworm2",
553: "painworm3",
554: "painworm4",
555: "painworm5",
556: "bunny",
557: "baalhighpriest",
558: "venomlord",
559: "baalcrabstairs",
560: "act5hire1",
561: "act5hire2",
562: "baaltentacle1",
563: "baaltentacle2",
564: "baaltentacle3",
565: "baaltentacle4",
566: "baaltentacle5",
567: "injuredbarb1",
568: "injuredbarb2",
569: "injuredbarb3",
570: "baalclone",
571: "baalminion1",
572: "baalminion2",
573: "baalminion3",
574: "worldstoneeffect",
575: "sk_archer6",
576: "sk_archer7",
577: "sk_archer8",
578: "sk_archer9",
579: "sk_archer10",
580: "bighead6",
581: "bighead7",
582: "bighead8",
583: "bighead9",
584: "bighead10",
585: "goatman6",
586: "goatman7",
587: "goatman8",
588: "goatman9",
589: "goatman10",
590: "foulcrow5",
591: "foulcrow6",
592: "foulcrow7",
593: "foulcrow8",
594: "clawviper6",
595: "clawviper7",
596: "clawviper8",
597: "clawviper9",
598: "clawviper10",
599: "sandraider6",
600: "sandraider7",
601: "sandraider8",
602: "sandraider9",
603: "sandraider10",
604: "deathmauler6",
605: "quillrat6",
606: "quillrat7",
607: "quillrat8",
608: "vulture5",
609: "thornhulk5",
610: "slinger7",
611: "slinger8",
612: "slinger9",
613: "cr_archer6",
614: "cr_archer7",
615: "cr_lancer6",
616: "cr_lancer7",
617: "cr_lancer8",
618: "blunderbore5",
619: "blunderbore6",
620: "skmage_fire5",
621: "skmage_fire6",
622: "skmage_ltng5",
623: "skmage_ltng6",
624: "skmage_cold5",
625: "skmage_pois5",
626: "skmage_pois6",
627: "pantherwoman5",
628: "pantherwoman6",
629: "sandleaper6",
630: "sandleaper7",
631: "wraith6",
632: "wraith7",
633: "wraith8",
634: "succubus6",
635: "succubus7",
636: "succubuswitch6",
637: "succubuswitch7",
638: "succubuswitch8",
639: "willowisp5",
640: "willowisp6",
641: "willowisp7",
642: "fallen6",
643: "fallen7",
644: "fallen8",
645: "fallenshaman6",
646: "fallenshaman7",
647: "fallenshaman8",
648: "skeleton6",
649: "skeleton7",
650: "batdemon6",
651: "batdemon7",
652: "bloodlord6",
653: "bloodlord7",
654: "scarab6",
655: "scarab7",
656: "fetish6",
657: "fetish7",
658: "fetish8",
659: "fetishblow6",
660: "fetishblow7",
661: "fetishblow8",
662: "fetishshaman6",
663: "fetishshaman7",
664: "fetishshaman8",
665: "baboon7",
666: "baboon8",
667: "unraveler6",
668: "unraveler7",
669: "unraveler8",
670: "unraveler9",
671: "zealot4",
672: "zealot5",
673: "cantor5",
674: "cantor6",
675: "vilemother4",
676: "vilemother5",
677: "vilechild4",
678: "vilechild5",
679: "sandmaggot6",
680: "maggotbaby6",
681: "maggotegg6",
682: "minion9",
683: "minion10",
684: "minion11",
685: "arach6",
686: "megademon4",
687: "megademon5",
688: "imp6",
689: "imp7",
690: "bonefetish6",
691: "bonefetish7",
692: "fingermage4",
693: "fingermage5",
694: "regurgitator4",
695: "vampire6",
696: "vampire7",
697: "vampire8",
698: "reanimatedhorde6",
699: "dkfig1",
700: "dkfig2",
701: "dkmag1",
702: "dkmag2",
703: "mummy6",
704: "ubermephisto",
705: "uberdiablo",
706: "uberizual",
707: "uberandariel",
708: "uberduriel",
709: "uberbaal",
710: "demonspawner",
711: "demonhole",
712: "megademon6",
713: "dkmag3",
714: "imp8",
715: "swarm5",
716: "sandmaggot7",
717: "arach7",
718: "scarab8",
719: "succubus8",
720: "succubuswitch9",
721: "corruptrogue6",
722: "cr_archer8",
723: "cr_lancer8",
724: "overseer6",
725: "skeleton8",
726: "sk_archer11",
727: "skmage_fire7",
728: "skmage_ltng7",
729: "skmage_cold6",
730: "skmage_pois7",
731: "vampire9",
732: "wraith9",
733: "willowisp8",
}
/**
* Resolve the visual token and weapon class for a numeric MonStats.txt hcIdx (0..733).
*/
export function resolveMonsterArtSpecByHcIdx(hcIdx: number): MonsterArtSpec | null {
const id = MONSTER_HCIDX_TO_ID[hcIdx]
if (!id) return null
return resolveMonsterArtSpec(id)
}

View File

@ -63,12 +63,16 @@ import type { D2Table } from '../world/acts.ts'
import { cell, parseTable } from '../world/acts.ts'
import { cofLayerOrder, decodeCof } from '../formats/cof.ts'
import type { CofFile } from '../formats/cof.ts'
import { decodeDc6 } from '../formats/dc6.ts'
import { decodeDcc } from '../formats/dcc.ts'
import type { DccFile } from '../formats/dcc.ts'
import type { SpriteFrame, SpriteSheet } from '../formats/sprite.ts'
import type { MountedArchives } from '../mpq/mount.ts'
import { OBJECT_TYPE_OBJECT, OBJECT_TYPE_MONSTER, lookupObject } from './object-lookup.ts'
import type { ObjectLookupEntry } from './object-lookup.ts'
import { compositeObjectAnimation, dc6ToDccFile } from './monster-art.ts'
export { compositeObjectAnimation }
/** `Objects.txt` inside the archives. */
const OBJECTS_TABLE = 'data\\global\\excel\\objects.txt'
@ -327,6 +331,8 @@ export interface ObjectsRow {
readonly id: number
/** `Name`, e.g. `Barrel`. */
readonly name: string
/** `Description - not loaded`, e.g. ` barrel` or `Cain's Cage`. */
readonly description?: string | undefined
/** `Token`, upper-cased: the `<TOKEN>` in every path the engine builds. */
readonly token: string
/** `SubClass` — the object class (`OBJSUBCLASS_DOOR` = 0x80, `CHEST` = 0x08, ...). */
@ -341,6 +347,8 @@ export interface ObjectsRow {
readonly yOffset: number
/** `IsDoor` — doors take the operate path rather than the casket path. */
readonly isDoor: boolean
/** `OperateRange` — interaction range in sub-tiles. */
readonly operateRange?: number | undefined
/** `Trans` — transparency mode. */
readonly trans: number
/** `BlendMode` — additive or normal blending mode. */
@ -397,7 +405,7 @@ const COMPONENT_COLUMNS = ['HD', 'TR', 'LG', 'RA', 'LA', 'RH', 'LH', 'SH', 'S1',
* @param row - the raw cells.
* @returns the typed row.
*/
function toObjectsRow(table: D2Table, row: readonly string[]): ObjectsRow {
function toObjectsRow(table: D2Table, row: readonly string[], sequentialId: number): ObjectsRow {
const numbers = (prefix: string, count: number): number[] =>
Array.from({ length: count }, (_, index) => Number(cell(table, row, `${prefix}${String(index)}`) || '0'))
const components = COMPONENT_COLUMNS.filter(column => cell(table, row, column) !== '0' && cell(table, row, column) !== '')
@ -409,8 +417,9 @@ function toObjectsRow(table: D2Table, row: readonly string[]): ObjectsRow {
? 'additive'
: 'normal'
return {
id: Number(cell(table, row, 'Id')),
id: sequentialId,
name: cell(table, row, 'Name'),
description: cell(table, row, 'Description - not loaded') || cell(table, row, 'Name'),
token,
subClass: Number(cell(table, row, 'SubClass') || '0'),
act: Number(cell(table, row, 'Act') || '0'),
@ -419,6 +428,7 @@ function toObjectsRow(table: D2Table, row: readonly string[]): ObjectsRow {
xOffset: Number(cell(table, row, 'Xoffset') || '0'),
yOffset: Number(cell(table, row, 'Yoffset') || '0'),
isDoor: cell(table, row, 'IsDoor') !== '0' && cell(table, row, 'IsDoor') !== '',
operateRange: Number(cell(table, row, 'OperateRange') || '0'),
trans,
blendMode,
draw: Number(cell(table, row, 'Draw') || '0'),
@ -450,8 +460,10 @@ export async function loadObjectsTable(archives: MountedArchives): Promise<Objec
const table = parseTable(await archives.read(OBJECTS_TABLE))
const rows: ObjectsRow[] = []
for (const raw of table.rows) {
const row = toObjectsRow(table, raw)
if (!Number.isFinite(row.id)) continue
const idStr = cell(table, raw, 'Id').trim()
const nameStr = cell(table, raw, 'Name').trim()
if (idStr === '' || nameStr.toLowerCase() === 'expansion') continue
const row = toObjectsRow(table, raw, rows.length)
rows.push(row)
}
const byId = new Map<number, ObjectsRow>()
@ -887,11 +899,21 @@ export async function loadObjectSheet(
const component = OBJECT_COMPONENTS[layer.type]
if (component === undefined) { notes.push(`layer type ${String(layer.type)} has no component directory`); sprites.push(null); continue }
const wanted = objectSpriteMember(token, component, modeIndex, baseIsMonsters)
const wantedNoLit = wanted.replace(/lit(\.dcc)$/i, '$1')
let found = findMember(names, wanted)
if (found === undefined) found = findMember(names, wanted.replace(/\.dcc$/i, '.dc6'))
if (found === undefined && wantedNoLit !== wanted) {
found = findMember(names, wantedNoLit)
if (found === undefined) found = findMember(names, wantedNoLit.replace(/\.dcc$/i, '.dc6'))
}
if (found === undefined) { notes.push(`no sprite for component ${component} (${wanted})`); sprites.push(null); continue }
try {
sprites.push(decodeDcc(await archives.read(found)))
const raw = await archives.read(found)
if (found.toLowerCase().endsWith('.dc6')) {
sprites.push(dc6ToDccFile(decodeDc6(raw), cof.numberOfDirections, cof.framesPerDirection))
} else {
sprites.push(decodeDcc(raw))
}
members.push(found)
} catch (err) {
notes.push(`${found}: ${(err as Error).message}`)