From 6601dcde828c93f978e8e415bdbf714be89e0dfa Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Mon, 28 Sep 2026 12:42:21 +0000 Subject: [PATCH] feat(char,items): dynamic Sorceress equipment paperdoll appearance, weapon classes, and 1.13c palette dye shifts --- package.json | 2 + scripts/pack-char-so-layers.ts | 216 +++++ scripts/pack-entity-assets.ts | 14 +- scripts/verify-char-appearance.ts | 691 ++++++++++++++++ src/game/actor-animator.ts | 11 + src/game/armor.ts | 41 + src/game/char-appearance.ts | 746 ++++++++++++++++++ src/game/gems-data.ts | 53 ++ src/game/items.ts | 22 + src/game/weapons.ts | 21 + src/scene/act-scene.ts | 125 ++- src/ui/item-bridge.ts | 191 ++++- .../issue-506-equipment-restrictions.test.ts | 26 +- tests/sorceress-appearance.test.ts | 198 +++++ 14 files changed, 2321 insertions(+), 36 deletions(-) create mode 100644 scripts/pack-char-so-layers.ts create mode 100644 scripts/verify-char-appearance.ts create mode 100644 src/game/char-appearance.ts create mode 100644 tests/sorceress-appearance.test.ts diff --git a/package.json b/package.json index 354395c..3649de7 100644 --- a/package.json +++ b/package.json @@ -31,9 +31,11 @@ "pack:ui": "tsx scripts/pack-ui.ts", "pack:animdata": "tsx scripts/pack-animdata.ts", "pack:entities": "tsx scripts/pack-entity-assets.ts", + "pack:char-so": "tsx scripts/pack-char-so-layers.ts", "pack:missiles": "tsx scripts/pack-missiles.ts", "pack:overlays": "tsx scripts/pack-overlays.ts", "verify:entity-packs": "tsx scripts/verify-entity-packs.ts", + "verify:char-appearance": "tsx scripts/verify-char-appearance.ts", "verify:packs": "tsx scripts/verify-packs.ts", "verify:bundle-no-mpq": "tsx scripts/verify-bundle-no-mpq.ts", "verify:animation": "tsx scripts/verify-animation-browser.ts", diff --git a/scripts/pack-char-so-layers.ts b/scripts/pack-char-so-layers.ts new file mode 100644 index 0000000..dee96d2 --- /dev/null +++ b/scripts/pack-char-so-layers.ts @@ -0,0 +1,216 @@ +/** + * Bake Sorceress (`so`) modular paperdoll DCC layers, COF descriptors, AnimData clips, + * and `Palette/*.dat` color shift tables into `samples/d2-packs/chars/so/`. + * + * Ground Truth (Diablo II v1.13c): + * - `data\global\chars\so\cof\*.cof` (94 COF files) + * - `data\global\chars\so\\so.dcc` (1,669 DCC files across 77 `-` pairs) + * - `data\global\items\Palette\*.dat` (8 palette shift tables, each 21 colors × 256 bytes = 5,376 bytes) + * + * This offline pack script eliminates any runtime dependency on MPQ archives while allowing + * instant on-demand loading and caching of paperdoll component variants and dye transforms. + */ +import { mkdir, writeFile, rename } from 'node:fs/promises' +import { existsSync, readFileSync } from 'node:fs' +import { join } from 'node:path' +import { MpqArchive } from '../src/mpq/archive.ts' +import { fileSource } from '../src/mpq/file-source.ts' +import { MountedArchives } from '../src/mpq/mount.ts' + +const MOUNTS = ['d2char.mpq', 'd2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq'] as const + +/** + * 1.13c `D2CMP.dll` `D2Comp.cpp` palette shift `.dat` files indexed by `Transform` (1..8). + */ +export const PALETTE_SHIFT_FILES: Readonly> = { + 1: 'data\\global\\items\\Palette\\grey.dat', + 2: 'data\\global\\items\\Palette\\grey2.dat', + 3: 'data\\global\\items\\Palette\\gold.dat', + 4: 'data\\global\\items\\Palette\\brown.dat', + 5: 'data\\global\\items\\Palette\\greybrown.dat', + 6: 'data\\global\\items\\Palette\\invgrey.dat', + 7: 'data\\global\\items\\Palette\\invgrey2.dat', + 8: 'data\\global\\items\\Palette\\invgreybrown.dat', +} + +export interface SorceressLayerPackManifest { + readonly schema: 1 + readonly metaFile: string + readonly cofs: Record + readonly palettes: Record + readonly animClips: Record + readonly layers: Record< + string, + { + readonly file: string + readonly dccs: Record + } + > +} + +async function writeFileAtomic(targetPath: string, content: string | Uint8Array): Promise { + const tmpPath = `${targetPath}.tmp.${process.pid}.${Math.random().toString(36).slice(2)}` + await writeFile(tmpPath, content) + await rename(tmpPath, targetPath) +} + +export async function packSorceressLayers( + archiveDir = 'samples/d2', + outDir = 'samples/d2-packs', +): Promise { + const archives = new MountedArchives() + for (const name of MOUNTS) { + const fullPath = join(archiveDir, name) + if (!existsSync(fullPath)) { + throw new Error(`Required MPQ archive missing: ${fullPath}`) + } + const archive = await MpqArchive.open(await fileSource(fullPath)) + archives.add(name, archive) + } + + const animDataPath = join(outDir, 'anim', 'animdata.json') + if (!existsSync(animDataPath)) { + throw new Error(`Missing ${animDataPath}. Run 'npm run pack:animdata' first.`) + } + const animData = JSON.parse(readFileSync(animDataPath, 'utf8')) as { + clips: Record + } + + const soOutDir = join(outDir, 'chars', 'so') + const layersOutDir = join(soOutDir, 'layers') + await mkdir(layersOutDir, { recursive: true }) + + // 1. Collect all members under data\global\chars\so\ + const allMembers = await archives.listFiles() + const cofPaths: string[] = [] + const dccByCompVariant = new Map() + + for (const member of allMembers) { + const norm = member.replace(/\//g, '\\') + const lower = norm.toLowerCase() + if (!lower.startsWith('data\\global\\chars\\so\\')) continue + + if (lower.startsWith('data\\global\\chars\\so\\cof\\') && lower.endsWith('.cof')) { + cofPaths.push(norm) + continue + } + + if (lower.endsWith('.dcc')) { + // Format: data\global\chars\so\\so.dcc + const parts = lower.split('\\') + if (parts.length !== 6) continue + const comp = parts[4]! + const fileName = parts[5]! + const baseName = fileName.slice(0, -4) // strip .dcc + // baseName is e.g. "sohdcapnu1hs" (2 + 2 + 3 + 2 + 3 = 12 chars) + if (!baseName.startsWith(`so${comp}`) || baseName.length !== 12) continue + const variant = baseName.slice(4, 7) + const modeWclass = baseName.slice(7, 12).toUpperCase() + const key = `${comp}-${variant}` + const list = dccByCompVariant.get(key) ?? [] + list.push({ modeWclass, path: norm }) + dccByCompVariant.set(key, list) + } + } + + cofPaths.sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase())) + + // 2. Pack meta.bin (COFs + Palette shift .dat files) + const metaChunks: Uint8Array[] = [] + let metaOffset = 0 + const cofs: Record = {} + const animClips: Record = {} + + for (const cofPath of cofPaths) { + const fileName = cofPath.split('\\').pop()! + const cofToken = fileName.slice(0, -4).toUpperCase() // e.g. SONUHTH + const bytes = await archives.read(cofPath) + cofs[cofToken] = [metaOffset, bytes.byteLength] + metaChunks.push(bytes) + metaOffset += bytes.byteLength + + const clipRec = animData.clips[cofToken] + animClips[cofToken] = { + speed: clipRec?.speed ?? 256, + events: clipRec?.events ?? [], + } + } + + const palettes: Record = {} + for (let transIdx = 1; transIdx <= 8; transIdx += 1) { + const palPath = PALETTE_SHIFT_FILES[transIdx]! + const bytes = await archives.read(palPath) + if (bytes.byteLength !== 21 * 256) { + throw new Error(`Unexpected palette shift size for ${palPath}: ${bytes.byteLength} (expected 5376)`) + } + palettes[transIdx] = [metaOffset, bytes.byteLength] + metaChunks.push(bytes) + metaOffset += bytes.byteLength + } + + const metaBin = new Uint8Array(metaOffset) + let cursor = 0 + for (const chunk of metaChunks) { + metaBin.set(chunk, cursor) + cursor += chunk.byteLength + } + await writeFileAtomic(join(soOutDir, 'meta.bin'), metaBin) + + // 3. Pack each -.bin + const sortedLayerKeys = [...dccByCompVariant.keys()].sort() + const layers: Record }> = {} + + let totalDccCount = 0 + let totalDccBytes = 0 + + for (const key of sortedLayerKeys) { + const entries = dccByCompVariant.get(key)! + entries.sort((a, b) => a.modeWclass.localeCompare(b.modeWclass)) + + const chunks: Uint8Array[] = [] + let offset = 0 + const dccs: Record = {} + + for (const entry of entries) { + const bytes = await archives.read(entry.path) + dccs[entry.modeWclass] = [offset, bytes.byteLength] + chunks.push(bytes) + offset += bytes.byteLength + totalDccCount += 1 + } + + const layerBin = new Uint8Array(offset) + let pos = 0 + for (const chunk of chunks) { + layerBin.set(chunk, pos) + pos += chunk.byteLength + } + + const relFile = `layers/${key}.bin` + await writeFileAtomic(join(soOutDir, relFile), layerBin) + layers[key] = { file: relFile, dccs } + totalDccBytes += offset + } + + const manifest: SorceressLayerPackManifest = { + schema: 1, + metaFile: 'meta.bin', + cofs, + palettes, + animClips, + layers, + } + + await writeFileAtomic(join(soOutDir, 'manifest.json'), JSON.stringify(manifest)) + console.log( + `Packed Sorceress paperdoll layers: ${Object.keys(cofs).length} COFs, 8 Palette tables (${(metaOffset / 1024).toFixed(1)} KB meta.bin), ${sortedLayerKeys.length} layer packs (${totalDccCount} DCCs, ${(totalDccBytes / 1024 / 1024).toFixed(2)} MB)`, + ) + return manifest +} + +if (import.meta.url === `file://${process.argv[1]}`) { + packSorceressLayers().catch((err) => { + console.error(err) + process.exit(1) + }) +} diff --git a/scripts/pack-entity-assets.ts b/scripts/pack-entity-assets.ts index 991b7dc..73d39be 100644 --- a/scripts/pack-entity-assets.ts +++ b/scripts/pack-entity-assets.ts @@ -298,7 +298,7 @@ async function processCharJob(job: CharJob, ctx: EntityWorkerContext): Promise => new Promise(r => setTimeout(r, ms)) + +function assert(cond: boolean, message: string): void { + if (!cond) { + throw new Error(`ASSERTION FAILED: ${message}`) + } +} + +function makeItem(overrides: Partial & { code: string; name: string }): ResolvableEquippedItem { + return { + quality: 'normal', + identified: true, + ...overrides, + } +} + +interface CdpSession { + send: (method: string, params?: Record, timeoutMs?: number) => Promise + evalJs: (expression: string) => Promise + close: () => void +} + +async function connectCdp(debugPort: number, onEvent: (msg: Record) => void): Promise { + let wsUrl: string | null = null + let lastErr = 'none' + for (let i = 0; i < 80; i++) { + await sleep(200) + try { + const res = await fetch(`http://127.0.0.1:${debugPort}/json/list`) + const pages = (await res.json()) as { type: string; webSocketDebuggerUrl?: string }[] + const page = pages.find(p => p.type === 'page') + if (page?.webSocketDebuggerUrl !== undefined) { + wsUrl = page.webSocketDebuggerUrl + break + } + lastErr = 'no page target yet' + } catch (err) { + lastErr = err instanceof Error ? err.message : String(err) + } + } + if (wsUrl === null) throw new Error(`could not reach Chrome CDP endpoint (last error: ${lastErr})`) + + const ws = new WebSocket(wsUrl) + await new Promise((res, rej) => { + ws.onopen = () => res() + ws.onerror = () => rej(new Error('CDP WebSocket failed to open')) + }) + + let idCounter = 1 + const send = ( + method: string, + params: Record = {}, + timeoutMs = 20000, + ): Promise => + new Promise((res, rej) => { + const id = idCounter++ + const timer = setTimeout(() => { + ws.removeEventListener('message', handler) + rej(new Error(`CDP ${method} timed out after ${timeoutMs}ms`)) + }, timeoutMs) + const handler = (event: MessageEvent): void => { + const msg = JSON.parse(String(event.data)) as Record + if (msg['id'] === id) { + clearTimeout(timer) + ws.removeEventListener('message', handler) + if (msg['error'] !== undefined) rej(new Error(`CDP ${method}: ${JSON.stringify(msg['error'])}`)) + else res(msg['result'] as T) + } + } + ws.addEventListener('message', handler) + ws.send(JSON.stringify({ id, method, params })) + }) + + ws.addEventListener('message', (event: MessageEvent) => { + onEvent(JSON.parse(String(event.data)) as Record) + }) + + const evalJs = async (expression: string): Promise => { + const res = await send<{ result: { value: T }; exceptionDetails?: unknown }>('Runtime.evaluate', { + expression, + awaitPromise: true, + returnByValue: true, + }) + if (res.exceptionDetails !== undefined) { + throw new Error(`in-page JS threw: ${JSON.stringify(res.exceptionDetails)}\n expression: ${expression}`) + } + return res.result.value + } + + return { send, evalJs, close: () => ws.close() } +} + +async function runUnitAndCompositorChecks(): Promise { + console.log('--- 1. Verifying Item Color & Palette Tables ---') + assert(COLOR_CODES.length === 21, `Expected 21 COLOR_CODES, got ${COLOR_CODES.length}`) + assert(COLOR_CODES[0] === 'whit' && COLOR_CODES[20] === 'bwht', 'COLOR_CODES boundary mismatch') + assert(Object.keys(GEM_TRANSFORM_BY_CODE).length === 35, 'Expected 35 gem transform entries') + + const dropTables = getEmbeddedDropTables() + + // Unidentified item -> undefined + const unidUnique = resolveItemColorTransform( + { name: 'The Oculus', identified: false } as any, + 'unique', + 'oba', + dropTables, + ) + assert( + unidUnique.chrtransform === undefined, + `Unidentified item must resolve to undefined chrtransform, got ${String(unidUnique.chrtransform)}`, + ) + + // Unique items + assert( + resolveItemColorTransform({ name: "Ginther's Rift", identified: true } as any, 'unique', '9cr', dropTables).chrtransform === 'cblu', + "Ginther's Rift should resolve to cblu", + ) + assert( + resolveItemColorTransform({ name: 'Harlequin Crest', identified: true } as any, 'unique', 'uap', dropTables).chrtransform === 'cgrn', + 'Harlequin Crest should resolve to cgrn', + ) + assert( + resolveItemColorTransform({ name: 'Skin of the Vipermagi', identified: true } as any, 'unique', 'xea', dropTables).chrtransform === 'dblu', + 'Skin of the Vipermagi should resolve to dblu', + ) + + // Set items (including display name vs internal index fallback) + assert( + resolveItemColorTransform({ name: "Tal Rasha's Horadric Crest", identified: true } as any, 'set', 'xsk', dropTables).chrtransform === 'dpur', + "Tal Rasha's Horadric Crest should resolve to dpur", + ) + assert( + resolveItemColorTransform({ name: "Tal Rasha's Guardianship", identified: true } as any, 'set', 'uth', dropTables).chrtransform === 'dpur', + "Tal Rasha's Guardianship (uth) should resolve to dpur", + ) + + // Magic/Rare suffix > prefix priority + assert( + resolveItemColorTransform( + { + name: 'Cobalt Cap of the Mammoth', + identified: true, + rolledMagicAffixes: { + prefixes: [{ name: 'Cobalt', transform: true, transformColor: 'cblu' }], + suffixes: [{ name: 'of the Mammoth', transform: true, transformColor: 'cred' }], + }, + } as any, + 'magic', + 'cap', + dropTables, + ).chrtransform === 'cred', + 'Magic suffix color (cred) must override prefix color (cblu)', + ) + assert( + resolveItemColorTransform( + { + name: 'Cobalt Cap', + identified: true, + rolledMagicAffixes: { + prefixes: [{ name: 'Cobalt', transform: true, transformColor: 'cblu' }], + suffixes: [{ name: 'of Dexterity', transform: false, transformColor: '' }], + }, + } as any, + 'magic', + 'cap', + dropTables, + ).chrtransform === 'cblu', + 'Magic prefix color (cblu) must apply when suffix has no transform', + ) + + // Socketed gems vs Runeword exclusion + assert( + resolveItemColorTransform( + { + name: 'Socketed Cap', + identified: true, + socketedItems: [{ code: 'gpw', name: 'Perfect Diamond' }], + } as any, + 'normal', + 'cap', + dropTables, + ).chrtransform === 'whit', + 'Socketed Perfect Diamond (gpw) must dye normal item whit', + ) + assert( + resolveItemColorTransform( + { + name: 'Enigma', + identified: true, + runewordRunes: "'JahIthBer'", + socketedItems: [{ code: 'gpw', name: 'Perfect Diamond' }], + } as any, + 'rune', + 'utp', + dropTables, + ).chrtransform === undefined, + 'Runeword must not inherit Unique or Gem color transform', + ) + + // AutoMagic + assert( + resolveItemColorTransform( + { + name: 'Plate Mail', + identified: true, + autoAffix: { name: 'Cobalt', transform: true, transformColor: 'cblu' }, + } as any, + 'normal', + 'plt', + dropTables, + ).chrtransform === 'cblu', + 'AutoMagic Cobalt must resolve to cblu', + ) + console.log('✓ Item color resolution (ITEMS_GetColor) passed all checks.') + + console.log('--- 2. Verifying Sorceress 10-Component Appearance Mapping ---') + // Unarmed + const unarmed = resolveSorceressAppearance({}, dropTables) + assert(unarmed.wclass === 'hth', `Unarmed wclass should be hth, got ${unarmed.wclass}`) + assert(unarmed.layers.hd.variant === 'lit', 'Unarmed hd should be lit') + assert(unarmed.layers.tr.variant === 'lit', 'Unarmed tr should be lit') + assert( + unarmed.layers.rh.variant === null && + unarmed.layers.lh.variant === null && + unarmed.layers.sh.variant === null, + 'Unarmed hands/shield must be null', + ) + + // Circlet exemption (D2Inventory.cpp:2624) + const circletSpec = resolveSorceressAppearance( + { helm: makeItem({ code: 'ci3', name: "Griffon's Eye", quality: 'unique', chrtransform: 'cblu' }) }, + dropTables, + ) + assert( + circletSpec.layers.hd.variant === 'lit' && circletSpec.layers.hd.color === null, + 'Circlet (ci3) must keep hd=lit with null color', + ) + + // Asymmetric Studded Leather (stu) vs Breast Plate (brs) + const stuSpec = resolveSorceressAppearance( + { armor: makeItem({ code: 'stu', name: 'Studded Leather', chrtransform: 'cgrn' }) }, + dropTables, + ) + assert( + stuSpec.layers.tr.variant === 'lit' && + stuSpec.layers.lg.variant === 'med' && + stuSpec.layers.ra.variant === 'med' && + stuSpec.layers.la.variant === 'lit' && + stuSpec.layers.s1.variant === 'med' && + stuSpec.layers.s2.variant === 'med' && + stuSpec.layers.tr.color === 'cgrn', + 'Studded Leather (stu) asymmetric component mapping mismatch', + ) + + const brsSpec = resolveSorceressAppearance( + { armor: makeItem({ code: 'brs', name: 'Breast Plate', chrtransform: 'lgld' }) }, + dropTables, + ) + assert( + brsSpec.layers.tr.variant === 'hvy' && + brsSpec.layers.lg.variant === 'lit' && + brsSpec.layers.ra.variant === 'lit' && + brsSpec.layers.la.variant === 'lit' && + brsSpec.layers.s1.variant === 'hvy' && + brsSpec.layers.s2.variant === 'hvy' && + brsSpec.layers.tr.color === 'lgld', + 'Breast Plate (brs) component/color mapping mismatch', + ) + + // Weapons: Orb + Shield, Bow (lh!), Crossbow (rh!) + const orbShieldSpec = resolveSorceressAppearance( + { + weapon1: makeItem({ code: 'oba', name: 'The Oculus', quality: 'unique', chrtransform: 'whit' }), + weapon2: makeItem({ code: 'uit', name: 'Monarch', chrtransform: 'cblu' }), + }, + dropTables, + ) + assert( + orbShieldSpec.wclass === '1hs' && + orbShieldSpec.layers.rh.variant === 'ob4' && + orbShieldSpec.layers.rh.color === 'whit' && + orbShieldSpec.layers.sh.variant === 'kit' && + orbShieldSpec.layers.sh.color === 'cblu', + `Orb + Monarch shield appearance mismatch: ${JSON.stringify(orbShieldSpec)}`, + ) + + const bowSpec = resolveSorceressAppearance( + { + weapon1: makeItem({ code: 'sbw', name: 'Short Bow' }), + }, + dropTables, + ) + assert( + bowSpec.wclass === 'bow' && + bowSpec.layers.lh.variant === 'sbw' && + bowSpec.layers.rh.variant === null && + bowSpec.layers.sh.variant === null, + 'Short Bow must set wclass=bow, lh=sbw, rh=null, sh=null', + ) + + const xbwSpec = resolveSorceressAppearance( + { weapon1: makeItem({ code: 'lxb', name: 'Light Crossbow' }) }, + dropTables, + ) + assert( + xbwSpec.wclass === 'xbw' && xbwSpec.layers.rh.variant === 'lxb' && xbwSpec.layers.lh.variant === null, + 'Crossbow must set wclass=xbw, rh=lxb, lh=null', + ) + console.log('✓ Sorceress 10-component appearance mapping passed all checks.') + + console.log('--- 3. Verifying SorceressLayerCompositor Offline Pack & Palette Shifts ---') + const origFetch = globalThis.fetch + const rootDir = resolve('.') + globalThis.fetch = (async (input: RequestInfo | URL) => { + const urlStr = String(input) + const relPath = urlStr.replace(/^\/+/, '') + const buf = await readFile(join(rootDir, relPath)) + return new Response(buf, { status: 200 }) + }) as typeof fetch + + try { + const compositor = new SorceressLayerCompositor('samples/d2-packs/chars/so') + const compUnarmed = await compositor.composite(unarmed) + const compOrbShield = await compositor.composite(orbShieldSpec) + const compOrbShieldRed = await compositor.composite( + resolveSorceressAppearance( + { + helm: makeItem({ code: 'crn', name: 'Crown', chrtransform: 'cred' }), + armor: makeItem({ code: 'ful', name: 'Full Plate Mail', chrtransform: 'cred' }), + weapon1: makeItem({ code: 'oba', name: 'The Oculus', quality: 'unique' }), + weapon2: makeItem({ code: 'uit', name: 'Monarch', chrtransform: 'cred' }), + }, + dropTables, + ), + ) + const compOrbShieldBlue = await compositor.composite( + resolveSorceressAppearance( + { + helm: makeItem({ code: 'crn', name: 'Crown', chrtransform: 'cblu' }), + armor: makeItem({ code: 'ful', name: 'Full Plate Mail', chrtransform: 'cblu' }), + weapon1: makeItem({ code: 'oba', name: 'The Oculus', quality: 'unique' }), + weapon2: makeItem({ code: 'uit', name: 'Monarch', chrtransform: 'cblu' }), + }, + dropTables, + ), + ) + + assert(compUnarmed.members === 47, `Expected 47 unarmed members across 11 modes, got ${compUnarmed.members}`) + assert(compOrbShield.members > compUnarmed.members, `Expected orb+shield members > unarmed, got ${compOrbShield.members}`) + assert(compOrbShieldRed.members > compOrbShield.members, `Expected full plate+crown+orb+shield members > orb+shield, got ${compOrbShieldRed.members}`) + + // Compare palette indices between Red and Blue dyed identical gear + assert( + compOrbShieldRed.r8.length === compOrbShieldBlue.r8.length, + 'Identical gear with different dye colors must produce same atlas dimensions', + ) + let diffPixels = 0 + for (let i = 0; i < compOrbShieldRed.r8.length; i += 1) { + if (compOrbShieldRed.r8[i] !== compOrbShieldBlue.r8[i]) diffPixels += 1 + } + assert( + diffPixels > 50_000, + `Expected >50,000 palette-shifted pixels between cred and cblu gear, got ${diffPixels}`, + ) + console.log(`✓ SorceressLayerCompositor verified (${diffPixels.toLocaleString()} dyed pixels shifted between cred and cblu).`) + } finally { + globalThis.fetch = origFetch + } +} + +async function startViteServer(port: number): Promise { + const child = spawn('npx', ['vite', '--host', '127.0.0.1', '--port', String(port)], { + cwd: resolve('.'), + stdio: ['ignore', 'pipe', 'pipe'], + }) + const start = Date.now() + while (Date.now() - start < 20_000) { + try { + const resp = await fetch(`http://127.0.0.1:${String(port)}/acts.html`) + if (resp.ok) return child + } catch { + // wait + } + await sleep(250) + } + child.kill('SIGTERM') + throw new Error('Timed out waiting for Vite dev server') +} + +async function runBrowserE2ECheck(): Promise { + console.log('--- 4. Verifying Live WebGL Browser E2E Appearance & Dye Updates ---') + const port = 5173 + let server: ChildProcess | null = null + const debugPort = 9249 + let chrome: ChildProcess | null = null + try { + const probe = await fetch(`http://127.0.0.1:${String(port)}/acts.html`).catch(() => null) + if (!probe || !probe.ok) { + server = await startViteServer(port) + } + + const consoleErrors: string[] = [] + const mpqRequests: string[] = [] + + chrome = spawn('/usr/bin/google-chrome', [ + '--headless=new', + `--remote-debugging-port=${String(debugPort)}`, + '--no-sandbox', + '--disable-dev-shm-usage', + '--enable-webgl', + '--ignore-gpu-blocklist', + '--use-gl=angle', + '--use-angle=swiftshader', + '--window-size=1280,720', + 'about:blank', + ]) + + const cdp = await connectCdp(debugPort, msg => { + const method = msg['method'] as string | undefined + if (method === 'Network.requestWillBeSent') { + const req = msg['params'].request as { url: string } + if (/\.mpq(\?|$)/i.test(req.url)) mpqRequests.push(req.url) + } else if (method === 'Runtime.consoleAPICalled') { + if (msg['params'].type === 'error') { + const args = (msg['params'].args ?? []) as { value?: unknown; description?: string }[] + const text = args.map(a => String(a.value ?? a.description ?? '')).join(' ') + consoleErrors.push(text) + } + } else if (method === 'Runtime.exceptionThrown') { + const d = msg['params'].exceptionDetails as { text?: string; exception?: { description?: string } } + consoleErrors.push(d.exception?.description ?? d.text ?? 'unknown exception') + } + }) + + try { + await cdp.send('Page.enable') + await cdp.send('Runtime.enable') + await cdp.send('Network.enable') + + const targetUrl = `http://127.0.0.1:${String(port)}/acts.html?source=pack&base=samples/d2-packs&act=1&hero=so` + await cdp.send('Page.navigate', { url: targetUrl }) + + let ready = false + for (let i = 0; i < 120; i += 1) { + ready = await cdp.evalJs(`Boolean(window.__d2webAct?.ready)`) + if (ready) break + await sleep(300) + } + assert(ready, 'Scene did not become ready within timeout') + + // 1. Check initial Sorceress starter equipment appearance (Shako + Tal Armor + Oculus + Spirit Monarch) + const initialInfo = await cdp.evalJs<{ + heroToken: string + weaponClass: string + members: number + key: string + appearance: any + }>(`(() => { + const act = window.__d2webAct + return { + heroToken: act?.heroToken, + weaponClass: act?.weaponClass, + members: act?.characterMembers, + key: act?.characterAppearanceKey, + appearance: act?.characterAppearance, + } + })()`) + + assert(initialInfo.heroToken === 'so', `Expected heroToken=so, got ${String(initialInfo.heroToken)}`) + assert(initialInfo.weaponClass === '1hs', `Expected starter weaponClass=1hs, got ${String(initialInfo.weaponClass)}`) + assert( + initialInfo.appearance?.layers?.hd?.variant === 'msk' && + initialInfo.appearance?.layers?.hd?.color === 'dpur', + `Expected starter hd=msk:dpur (Tal Rasha's Horadric Crest), got ${JSON.stringify(initialInfo.appearance?.layers?.hd)}`, + ) + assert( + initialInfo.appearance?.layers?.tr?.variant === 'med' && + initialInfo.appearance?.layers?.tr?.color === 'dpur' && + initialInfo.appearance?.layers?.ra?.variant === 'hvy', + `Expected starter tr=med:dpur, ra=hvy (Tal Rasha's Guardianship), got ${JSON.stringify(initialInfo.appearance?.layers?.tr)}`, + ) + assert( + initialInfo.appearance?.layers?.rh?.variant === 'ob4' && + initialInfo.appearance?.layers?.rh?.color === 'dpur', + `Expected starter rh=ob4:dpur (Tal Rasha's Lidless Eye), got ${JSON.stringify(initialInfo.appearance?.layers?.rh)}`, + ) + assert( + initialInfo.appearance?.layers?.sh?.variant === null && + initialInfo.appearance?.layers?.sh?.color === null, + `Expected starter sh=null:null (5-piece Tal Rasha set has no shield), got ${JSON.stringify(initialInfo.appearance?.layers?.sh)}`, + ) + + // 1b. Equip Monarch Shield (uit -> kit, lgld) into weapon2 alongside Tal Rasha's Lidless Eye + const gearUpdateShield = await cdp.evalJs<{ + changed: boolean + weaponClass: string + appearance: any + }>(`(async () => { + const hud = window.__d2webHudInstance + const scene = window.__d2webActScene + const act = window.__d2webAct + hud.inventory.equipped = { + ...hud.inventory.equipped, + weapon2: { + id: 'gold-monarch', + code: 'uit', + name: "Holy Monarch", + nameZh: '神圣统治者大盾', + invFile: 'invuit', + quality: 'magic', + chrtransform: 'lgld', + identified: true, + invWidth: 2, + invHeight: 3, + allowedSlots: ['weapon2'], + stats: [], + }, + } + const changed = await scene.syncAppearance() + return { + changed, + weaponClass: act.weaponClass, + appearance: act.characterAppearance, + } + })()`) + + assert(gearUpdateShield.changed === true, 'Expected syncAppearance() to return true after equipping Monarch shield') + assert( + gearUpdateShield.appearance?.layers?.sh?.variant === 'kit' && + gearUpdateShield.appearance?.layers?.sh?.color === 'lgld', + `Expected sh=kit:lgld (Holy Monarch), got ${JSON.stringify(gearUpdateShield.appearance?.layers?.sh)}`, + ) + + // 2. Equip Crown (cred) + Studded Leather (cblu) + Short Staff (sst) + const gearUpdate1 = await cdp.evalJs<{ + changed: boolean + weaponClass: string + members: number + key: string + appearance: any + }>(`(async () => { + const hud = window.__d2webHudInstance + const scene = window.__d2webActScene + const act = window.__d2webAct + hud.inventory.equipped = { + helm: { + id: 'red-crown', + code: 'crn', + name: 'Ruby Crown', + nameZh: '红宝石皇冠', + invFile: 'invcrn', + quality: 'magic', + chrtransform: 'cred', + identified: true, + invWidth: 2, + invHeight: 2, + allowedSlots: ['helm'], + stats: [], + }, + armor: { + id: 'blue-stu', + code: 'stu', + name: 'Cobalt Studded Leather', + nameZh: '钴蓝镶嵌皮甲', + invFile: 'invstu', + quality: 'magic', + chrtransform: 'cblu', + identified: true, + invWidth: 2, + invHeight: 3, + allowedSlots: ['armor'], + stats: [], + }, + weapon1: { + id: 'short-staff', + code: 'sst', + name: 'Short Staff', + nameZh: '短棍', + invFile: 'invsst', + quality: 'normal', + identified: true, + invWidth: 1, + invHeight: 3, + allowedSlots: ['weapon1'], + stats: [], + }, + } + const changed = await scene.syncAppearance() + return { + changed, + weaponClass: act.weaponClass, + members: act.characterMembers, + key: act.characterAppearanceKey, + appearance: act.characterAppearance, + } + })()`) + + assert(gearUpdate1.changed === true, 'Expected syncAppearance() to return true after equipping Crown + Studded Leather + Short Staff') + assert(gearUpdate1.weaponClass === 'stf', `Expected weaponClass=stf, got ${String(gearUpdate1.weaponClass)}`) + assert( + gearUpdate1.appearance.layers.hd.variant === 'crn' && + gearUpdate1.appearance.layers.hd.color === 'cred', + 'Ruby Crown should resolve to crn:cred', + ) + assert( + gearUpdate1.appearance.layers.tr.variant === 'lit' && + gearUpdate1.appearance.layers.tr.color === 'cblu' && + gearUpdate1.appearance.layers.lg.variant === 'med' && + gearUpdate1.appearance.layers.ra.variant === 'med' && + gearUpdate1.appearance.layers.la.variant === 'lit', + `Cobalt Studded Leather should resolve to tr=lit:cblu, lg=med, ra=med, la=lit, got ${JSON.stringify(gearUpdate1.appearance.layers)}`, + ) + assert( + gearUpdate1.appearance.layers.rh.variant === 'bst' && + gearUpdate1.appearance.layers.sh.variant === null, + `Short Staff (sst -> alternateGfx=bst) should resolve to rh=bst and sh=null, got ${JSON.stringify(gearUpdate1.appearance.layers)}`, + ) + + // 3. Equip Bow (Short Bow) and verify wclass='bow' and lh='sbw' + const gearUpdateBow = await cdp.evalJs<{ + changed: boolean + weaponClass: string + members: number + appearance: any + }>(`(async () => { + const hud = window.__d2webHudInstance + const scene = window.__d2webActScene + const act = window.__d2webAct + hud.inventory.equipped = { + weapon1: { + id: 'test-bow', + code: 'sbw', + name: 'Short Bow', + nameZh: '短弓', + invFile: 'invsbw', + quality: 'normal', + identified: true, + invWidth: 2, + invHeight: 3, + allowedSlots: ['weapon1'], + stats: [], + }, + } + const changed = await scene.syncAppearance() + return { + changed, + weaponClass: act.weaponClass, + members: act.characterMembers, + appearance: act.characterAppearance, + } + })()`) + + assert(gearUpdateBow.changed === true, 'Expected syncAppearance() to return true when equipping Short Bow') + assert(gearUpdateBow.weaponClass === 'bow', `Expected weaponClass=bow, got ${String(gearUpdateBow.weaponClass)}`) + assert(gearUpdateBow.appearance.layers.lh?.variant === 'sbw', 'Short Bow must equip on LH layer') + assert(gearUpdateBow.appearance.layers.rh?.variant === null, 'Short Bow must leave RH layer null') + + assert(consoleErrors.length === 0, `Browser console/page errors: ${consoleErrors.join('; ')}`) + assert(mpqRequests.length === 0, `Forbidden runtime MPQ requests: ${mpqRequests.join('; ')}`) + console.log('✓ Live WebGL Browser E2E appearance & dye verification passed with 0 errors and 0 MPQ requests.') + } finally { + cdp.close() + } + } finally { + if (chrome) chrome.kill('SIGKILL') + if (server) server.kill('SIGTERM') + } +} + +async function main(): Promise { + await runUnitAndCompositorChecks() + await runBrowserE2ECheck() + console.log('\n🎉 ALL SORCERESS APPEARANCE & COLOR VERIFICATION CHECKS PASSED!') +} + +main().catch(err => { + console.error(err) + process.exit(1) +}) diff --git a/src/game/actor-animator.ts b/src/game/actor-animator.ts index c5933ec..c8955b7 100644 --- a/src/game/actor-animator.ts +++ b/src/game/actor-animator.ts @@ -77,6 +77,17 @@ export class ActorAnimator { this.isFinished = clip.frames <= 1 && !clip.loop } + /** + * Synchronize the active clip metadata in place (e.g. after a dynamic paperdoll + * or weapon-class re-composite) while preserving the current playback frame. + */ + syncClip(clip: AnimClipMeta): void { + this.currentClip = clip + if (this.index >= clip.frames) { + this.index = Math.max(0, clip.frames - 1) + } + } + /** * Advance one 25Hz simulation tick. * diff --git a/src/game/armor.ts b/src/game/armor.ts index 0f5470e..a0a7f2f 100644 --- a/src/game/armor.ts +++ b/src/game/armor.ts @@ -96,6 +96,26 @@ export interface ArmorBase extends ItemBase { readonly quest?: number | undefined readonly questDiffCheck?: boolean | undefined readonly vendorColumns?: Readonly> | undefined + /** 3-letter character paperdoll graphic token from alternategfx column (fallback to code). */ + readonly alternategfx?: string | undefined + /** Composite layer index from component column (0=hd, 7=sh, 16=body armor/gloves/boots/belt). */ + readonly component?: number | undefined + /** Armor component weight (0=lit, 1=med, 2=hvy) for Right Arm. */ + readonly rArm?: number | undefined + /** Armor component weight (0=lit, 1=med, 2=hvy) for Left Arm. */ + readonly lArm?: number | undefined + /** Armor component weight (0=lit, 1=med, 2=hvy) for Torso. */ + readonly torso?: number | undefined + /** Armor component weight (0=lit, 1=med, 2=hvy) for Legs. */ + readonly legs?: number | undefined + /** Armor component weight (0=lit, 1=med, 2=hvy) for Right Shoulder Pad (s1). */ + readonly rSPad?: number | undefined + /** Armor component weight (0=lit, 1=med, 2=hvy) for Left Shoulder Pad (s2). */ + readonly lSPad?: number | undefined + /** Character paperdoll palette shift table index (0..8) from Transform column. */ + readonly transform?: number | undefined + /** Inventory 2D icon palette shift table index (0..8) from InvTrans column. */ + readonly invTrans?: number | undefined } /** @@ -240,6 +260,17 @@ export function parseArmorTable(table: D2Table): ArmorTable { const quest = numAt(row, 'quest', 0) const questDiffCheck = strAt(row, 'questdiffcheck') === '1' + const alternategfx = (strAt(row, 'alternategfx') || code).toLowerCase() + const component = numAt(row, 'component', 16) + const rArm = numAt(row, 'rArm', 0) + const lArm = numAt(row, 'lArm', 0) + const torso = numAt(row, 'Torso', 0) + const legs = numAt(row, 'Legs', 0) + const rSPad = numAt(row, 'rSPad', 0) + const lSPad = numAt(row, 'lSPad', 0) + const transform = numAt(row, 'Transform', 0) + const invTrans = numAt(row, 'InvTrans', 0) + const vendorColumns: Record = {} for (let vId = 0; vId < VENDOR_COUNT; vId++) { const prefix = VENDOR_COLUMN_PREFIXES[vId]! @@ -301,6 +332,16 @@ export function parseArmorTable(table: D2Table): ArmorTable { quest, questDiffCheck, vendorColumns, + alternategfx, + component, + rArm, + lArm, + torso, + legs, + rSPad, + lSPad, + transform, + invTrans, } all.push(armor) diff --git a/src/game/char-appearance.ts b/src/game/char-appearance.ts new file mode 100644 index 0000000..54cb711 --- /dev/null +++ b/src/game/char-appearance.ts @@ -0,0 +1,746 @@ +/** + * Dynamic Sorceress (`so`) paperdoll appearance resolver and runtime layer compositor. + * + * Implements the 1.13c composite layer, weapon class, and palette shift pipeline: + * - Resolves equipped `helm`, `armor`, `weapon1`, and `weapon2` into the 10 active + * character composite layers (`hd`, `tr`, `lg`, `ra`, `la`, `rh`, `lh`, `sh`, `s1`, `s2`) + * and active weapon animation class (`hth`, `1hs`, `1ht`, `stf`, `bow`, `xbw`, `2hs`, `2ht`). + * - Resolves per-item color shifts (`Transform` 1..8 x `Colors.txt` 0..20) across Unique, Set, + * Magic/Rare/Crafted (suffixes before prefixes), Socketed Gem, and AutoMagic items. + * - Composites multi-mode DCC layers in COF priority order with 256-byte palette remap tables + * and packs all 11 animation modes into an R8 indexed atlas page. + */ + +import { decodeCof, cofLayerOrder, type CofFile } from '../formats/cof.ts' +import { decodeDcc, type DccFile } from '../formats/dcc.ts' +import type { SpriteFrame, SpriteSheet } from '../formats/sprite.ts' +import type { AnimClipMeta } from './actor-animator.ts' +import { COMPONENTS, dir64ToDcc } from './character.ts' +import type { DropTables } from './drop-pipeline.ts' +import { COLOR_CODES } from './gems-data.ts' +import type { Item } from './items.ts' +import { resolveItemColorTransform } from '../ui/item-bridge.ts' +import type { EquipSlotId, UiItemQuality } from '../ui/inventory.ts' + +/** All 10 active paperdoll component layers used by Sorceress animations. */ +export const SORCERESS_COMPONENTS = [ + 'hd', + 'tr', + 'lg', + 'ra', + 'la', + 'rh', + 'lh', + 'sh', + 's1', + 's2', +] as const + +export type SorceressComponent = (typeof SORCERESS_COMPONENTS)[number] + +/** Standard 11 player animation modes in pack order. */ +export const SORCERESS_PLAYER_MODES = [ + 'wl', + 'nu', + 'rn', + 'a1', + 'a2', + 'sc', + 'gh', + 'dt', + 'dd', + 'tn', + 'tw', +] as const + +const LOOPING_PLAYER_MODES = new Set(['nu', 'wl', 'rn', 'tn', 'tw']) + +const ARMOR_TIERS: readonly ('lit' | 'med' | 'hvy')[] = ['lit', 'med', 'hvy'] + +const VALID_HEAD_VARIANTS = new Set([ + 'lit', + 'bhm', + 'cap', + 'crn', + 'fhl', + 'ghm', + 'hlm', + 'msk', + 'skp', +]) + +/** Visual specification for a single paperdoll layer. */ +export interface LayerAppearance { + /** 3-letter DCC variant code (e.g. `'lit'`, `'cap'`, `'hvy'`, `'ob4'`, `'kit'`), or `null` if unequipped. */ + readonly variant: string | null + /** Palette shift table index from `Armor.txt` / `Weapons.txt` `Transform` column (`0..8`). */ + readonly transform: number + /** `Colors.txt` 4-letter code (e.g. `'cgrn'`, `'dpur'`, `'whit'`), or `null` if unshifted. */ + readonly color: string | null +} + +/** Complete resolved visual state of the Sorceress paperdoll across all layers and weapon class. */ +export interface SorceressAppearanceSpec { + /** Active animation weapon class (`'hth'`, `'1hs'`, `'1ht'`, `'stf'`, `'bow'`, `'xbw'`, `'2hs'`, `'2ht'`). */ + readonly wclass: string + /** Per-component visual variant and dye configuration. */ + readonly layers: Readonly> +} + +/** Minimal item interface required to resolve equipped paperdoll appearance. */ +export interface ResolvableEquippedItem { + readonly code?: string | undefined + readonly name?: string | undefined + readonly quality?: UiItemQuality | undefined + readonly chrtransform?: string | undefined + readonly invtransform?: string | undefined + readonly identified?: boolean | undefined + readonly isGambleMystery?: boolean | undefined + readonly rawItem?: Item | undefined +} + +/** Manifest structure for `samples/d2-packs/chars/so/manifest.json`. */ +export interface SorceressLayerPackManifest { + readonly schema: 1 + readonly metaFile: string + readonly cofs: Readonly> + readonly palettes: Readonly> + readonly animClips: Readonly< + Record + > + readonly layers: Readonly< + Record< + string, + { + readonly file: string + readonly dccs: Readonly> + } + > + > +} + +/** Result of compositing and shelf-packing all 11 Sorceress animation modes into an R8 atlas. */ +export interface CompositedSorceressR8 { + readonly key: string + readonly spec: SorceressAppearanceSpec + readonly r8: Uint8Array + readonly width: number + readonly height: number + readonly directions: number + readonly walk: number + readonly stand: number + readonly members: number + readonly clips: Readonly> + readonly groups: readonly (readonly [number, number, number, number, number, number])[][] +} + +/** Pluggable resource loader for browser `fetch` or Node test runners. */ +export interface CharLayerAssetLoader { + loadJson(relPath: string): Promise + loadBin(relPath: string): Promise +} + +function tierCode(value: number | undefined): 'lit' | 'med' | 'hvy' { + const idx = Math.max(0, Math.min(2, Math.trunc(value ?? 0))) + return ARMOR_TIERS[idx] ?? 'lit' +} + +function isItemUnidentified(item: ResolvableEquippedItem): boolean { + return Boolean( + item.identified === false || + item.isGambleMystery === true || + item.rawItem?.identified === false || + item.rawItem?.isGambleMystery === true || + (item.rawItem?.flags !== undefined && item.rawItem.flags.identified === false), + ) +} + +function resolveEquippedChrColor( + item: ResolvableEquippedItem, + code: string, + dropTables: DropTables, +): string | null { + if (isItemUnidentified(item)) { + return null + } + if (item.chrtransform && COLOR_CODES.includes(item.chrtransform as any)) { + return item.chrtransform + } + const resolved = resolveItemColorTransform( + item.rawItem ?? ({ + code, + name: item.name, + quality: item.quality, + identified: item.identified, + runewordRunes: (item as any).runewordRunes, + } as any), + item.quality ?? 'normal', + code, + dropTables, + ) + if (resolved.chrtransform && COLOR_CODES.includes(resolved.chrtransform as any)) { + return resolved.chrtransform + } + return null +} + +/** + * Produce a deterministic string key uniquely identifying a Sorceress appearance state. + */ +export function appearanceKey(spec: SorceressAppearanceSpec): string { + const parts: string[] = [`wc=${spec.wclass}`] + for (const comp of SORCERESS_COMPONENTS) { + const layer = spec.layers[comp] + const v = layer.variant ?? '-' + const t = layer.variant && layer.color && layer.transform > 0 ? String(layer.transform) : '0' + const c = layer.variant && layer.color && layer.transform > 0 ? layer.color : '-' + parts.push(`${comp}=${v}:${t}:${c}`) + } + return parts.join('|') +} + +/** + * Check whether the resolved appearance is the baseline unarmed `lit` state + * (matching the static pre-baked `char-so.r8`). + */ +export function isDefaultUnarmedAppearance(spec: SorceressAppearanceSpec): boolean { + if (spec.wclass !== 'hth') return false + for (const comp of ['hd', 'tr', 'lg', 'ra', 'la'] as const) { + const l = spec.layers[comp] + if (l.variant !== 'lit') return false + if (l.transform > 0 && l.color !== null) return false + } + for (const comp of ['rh', 'lh', 'sh', 's1', 's2'] as const) { + if (spec.layers[comp].variant !== null) return false + } + return true +} + +/** + * Resolve the Sorceress's 10 paperdoll layers, weapon class, and color shifts + * from the currently equipped items. + */ +export function resolveSorceressAppearance( + equipped: Partial>, + dropTables: DropTables, +): SorceressAppearanceSpec { + const layers: Record = { + hd: { variant: 'lit', transform: 0, color: null }, + tr: { variant: 'lit', transform: 0, color: null }, + lg: { variant: 'lit', transform: 0, color: null }, + ra: { variant: 'lit', transform: 0, color: null }, + la: { variant: 'lit', transform: 0, color: null }, + rh: { variant: null, transform: 0, color: null }, + lh: { variant: null, transform: 0, color: null }, + sh: { variant: null, transform: 0, color: null }, + s1: { variant: null, transform: 0, color: null }, + s2: { variant: null, transform: 0, color: null }, + } + + // 1. Helm -> HD (Circlets keep default 'lit' head and do not dye hair/skin) + const helmItem = equipped.helm + const helmCode = helmItem?.code?.trim() + if (helmItem && helmCode) { + const helmBase = dropTables.armor.get(helmCode) + if (helmBase) { + const isCirclet = + helmBase.type === 'circ' || + helmBase.alternategfx === 'cir' || + helmBase.alternategfx === 'lit' || + dropTables.isA(helmBase.type, 'circ') + if (!isCirclet) { + const rawVar = (helmBase.alternategfx || helmBase.code).toLowerCase() + const variant = VALID_HEAD_VARIANTS.has(rawVar) ? rawVar : 'lit' + const transform = helmBase.transform ?? 0 + const color = transform > 0 ? resolveEquippedChrColor(helmItem, helmCode, dropTables) : null + layers.hd = { variant, transform, color } + } + } + } + + // 2. Body Armor -> TR, LG, RA, LA, S1, S2 + const armorItem = equipped.armor + const armorCode = armorItem?.code?.trim() + if (armorItem && armorCode) { + const armorBase = dropTables.armor.get(armorCode) + if (armorBase) { + const transform = armorBase.transform ?? 0 + const color = transform > 0 ? resolveEquippedChrColor(armorItem, armorCode, dropTables) : null + layers.tr = { variant: tierCode(armorBase.torso), transform, color } + layers.lg = { variant: tierCode(armorBase.legs), transform, color } + layers.ra = { variant: tierCode(armorBase.rArm), transform, color } + layers.la = { variant: tierCode(armorBase.lArm), transform, color } + layers.s1 = { variant: tierCode(armorBase.rSPad), transform, color } + layers.s2 = { variant: tierCode(armorBase.lSPad), transform, color } + } + } + + // 3. Weapon1 & Weapon2 -> wclass, RH (weapons/orbs/staves/crossbows), LH (bows), SH (shields) + let wclass = 'hth' + let hasTwoHandedWeapon = false + const handSlots = [equipped.weapon1, equipped.weapon2] + + for (const handItem of handSlots) { + const rawCode = handItem?.code?.trim() + if (!handItem || !rawCode) continue + const code = rawCode === '7wa' && handItem.name === 'Colossus Blade' ? '7gd' : rawCode + const weapBase = dropTables.weapons.get(code) + if (weapBase) { + const activeWc = weapBase.twoHanded + ? (weapBase.twoHandedWClass || weapBase.wclass) + : weapBase.wclass + if (activeWc) { + wclass = activeWc.toLowerCase() + } + if (weapBase.twoHanded || wclass === 'bow' || wclass === 'xbw' || wclass === 'stf' || wclass === '2hs' || wclass === '2ht') { + hasTwoHandedWeapon = true + } + const rawVar = (weapBase.alternateGfx || weapBase.code).toLowerCase() + const transform = weapBase.transform ?? 0 + const color = transform > 0 ? resolveEquippedChrColor(handItem, code, dropTables) : null + if (weapBase.component === 6 || wclass === 'bow') { + layers.lh = { variant: rawVar, transform, color } + } else if (weapBase.component === 5) { + layers.rh = { variant: rawVar, transform, color } + } + break + } + } + + // Check shield in either hand slot when not wielding a two-handed weapon + if (!hasTwoHandedWeapon) { + for (const handItem of handSlots) { + const code = handItem?.code?.trim() + if (!handItem || !code) continue + const shieldBase = dropTables.armor.get(code) + if (shieldBase && (shieldBase.component === 7 || shieldBase.type === 'shie' || dropTables.isA(shieldBase.type, 'shie'))) { + const rawVar = (shieldBase.alternategfx || shieldBase.code).toLowerCase() + const transform = shieldBase.transform ?? 0 + const color = transform > 0 ? resolveEquippedChrColor(handItem, code, dropTables) : null + layers.sh = { variant: rawVar, transform, color } + break + } + } + } + + return { wclass, layers } +} + +/** + * Blit one DCC layer frame onto a destination canvas, optionally applying a 256-byte palette remap table. + */ +function blitWithRemap( + target: { indices: Uint8Array; mask: Uint8Array; width: number; height: number }, + source: SpriteFrame, + atX: number, + atY: number, + remap: Uint8Array | null, +): void { + for (let y = 0; y < source.height; y += 1) { + const ty = atY + y + if (ty < 0 || ty >= target.height) continue + for (let x = 0; x < source.width; x += 1) { + const at = y * source.width + x + if (source.mask[at] === 0) continue + const tx = atX + x + if (tx < 0 || tx >= target.width) continue + const to = ty * target.width + tx + const rawIdx = source.indices[at]! + target.indices[to] = remap !== null ? (remap[rawIdx] ?? rawIdx) : rawIdx + target.mask[to] = 1 + } + } +} + +const MAX_ATLAS_PAGE_SIZE = 4096 + +/** + * Shelf-pack all frames of a SpriteSheet into a single raw 8-bit index buffer (`gl.R8`). + * Index 0 represents transparency; opaque pixels with index 0 are remapped to `opaqueBlack` (172). + */ +export function packAppearanceSheetToR8( + sheet: SpriteSheet, + opaqueBlack = 172, +): { + readonly r8: Uint8Array + readonly width: number + readonly height: number + readonly groups: [number, number, number, number, number, number][][] +} { + const frames = sheet.groups.flatMap(g => g.frames) + const widest = Math.max(1, ...frames.map(f => f.width)) + const area = frames.reduce((sum, f) => sum + (f.width + 1) * (f.height + 1), 0) + let pageWidth = Math.min(MAX_ATLAS_PAGE_SIZE, Math.max(widest + 2, 512)) + while (pageWidth < MAX_ATLAS_PAGE_SIZE && pageWidth * pageWidth < area * 1.15) { + pageWidth *= 2 + } + pageWidth = Math.min(MAX_ATLAS_PAGE_SIZE, pageWidth) + + let cursorX = 0 + let shelfY = 0 + let shelfHeight = 0 + const placements: { x: number; y: number; width: number; height: number }[] = [] + + for (const frame of frames) { + const w = Math.max(1, Math.min(frame.width, pageWidth)) + const h = Math.max(1, Math.min(frame.height, MAX_ATLAS_PAGE_SIZE)) + if (cursorX + w > pageWidth) { + shelfY += shelfHeight + 1 + shelfHeight = 0 + cursorX = 0 + } + placements.push({ x: cursorX, y: shelfY, width: w, height: h }) + cursorX += w + 1 + shelfHeight = Math.max(shelfHeight, h) + } + + const pageHeight = Math.max(1, shelfY + shelfHeight) + const r8 = new Uint8Array(pageWidth * pageHeight) + + let idx = 0 + for (const frame of frames) { + const place = placements[idx]! + idx += 1 + for (let row = 0; row < place.height; row += 1) { + const fromRow = row * frame.width + const toRow = (place.y + row) * pageWidth + place.x + for (let col = 0; col < place.width; col += 1) { + const srcAt = fromRow + col + if (frame.mask[srcAt] === 0) { + r8[toRow + col] = 0 + } else { + const val = frame.indices[srcAt]! + r8[toRow + col] = val === 0 ? opaqueBlack : val + } + } + } + } + + const groups: [number, number, number, number, number, number][][] = [] + let consumed = 0 + for (const group of sheet.groups) { + const gList: [number, number, number, number, number, number][] = [] + for (let i = 0; i < group.frames.length; i += 1) { + const p = placements[consumed]! + const f = group.frames[i]! + consumed += 1 + gList.push([p.x, p.y, p.width, p.height, f.anchorX ?? 0, f.anchorY ?? 0]) + } + groups.push(gList) + } + + return { r8, width: pageWidth, height: pageHeight, groups } +} + +function createFetchLoader(packBaseUrl: string): CharLayerAssetLoader { + const base = packBaseUrl.replace(/\/+$/, '').replace(/\/chars\/so$/i, '') + return { + async loadJson(relPath: string): Promise { + const resp = await fetch(`${base}/chars/so/${relPath}`) + if (!resp.ok) { + throw new Error(`Failed to fetch Sorceress layer JSON ${relPath}: HTTP ${String(resp.status)}`) + } + return resp.json() + }, + async loadBin(relPath: string): Promise { + const resp = await fetch(`${base}/chars/so/${relPath}`) + if (!resp.ok) { + throw new Error(`Failed to fetch Sorceress layer binary ${relPath}: HTTP ${String(resp.status)}`) + } + return new Uint8Array(await resp.arrayBuffer()) + }, + } +} + +/** + * On-demand Sorceress paperdoll layer compositor with in-memory COF, Palette, and DCC caching. + */ +export class SorceressLayerCompositor { + private readonly loader: CharLayerAssetLoader + private initPromise: Promise | null = null + private manifest: SorceressLayerPackManifest | null = null + private metaBin: Uint8Array | null = null + private readonly cofCache = new Map() + private readonly layerBinCache = new Map>() + private readonly dccCache = new Map() + + constructor(packBaseOrLoader: string | CharLayerAssetLoader = 'samples/d2-packs') { + this.loader = + typeof packBaseOrLoader === 'string' + ? createFetchLoader(packBaseOrLoader) + : packBaseOrLoader + } + + private async ensureInitialized(): Promise { + if (this.manifest !== null && this.metaBin !== null) return + if (this.initPromise === null) { + this.initPromise = (async () => { + const [manifestRaw, metaBytes] = await Promise.all([ + this.loader.loadJson('manifest.json'), + this.loader.loadBin('meta.bin'), + ]) + this.manifest = manifestRaw as SorceressLayerPackManifest + this.metaBin = metaBytes + })() + } + await this.initPromise + } + + private getCof(cofToken: string): CofFile | null { + const upper = cofToken.toUpperCase() + const cached = this.cofCache.get(upper) + if (cached !== undefined) return cached + if (!this.manifest || !this.metaBin) return null + const range = this.manifest.cofs[upper] + if (!range) return null + const [offset, length] = range + const decoded = decodeCof(this.metaBin.subarray(offset, offset + length)) + this.cofCache.set(upper, decoded) + return decoded + } + + /** + * Look up the 256-byte palette remap table for `(transform, colorCode)`. + */ + getPaletteRemap(transform: number, colorCode: string | null): Uint8Array | null { + if (transform <= 0 || !colorCode || !this.manifest || !this.metaBin) return null + const colorIdx = COLOR_CODES.indexOf(colorCode as any) + if (colorIdx < 0 || colorIdx >= 21) return null + const range = this.manifest.palettes[transform] + if (!range) return null + const [palOffset, palLen] = range + if (palLen < (colorIdx + 1) * 256) return null + const start = palOffset + colorIdx * 256 + return this.metaBin.subarray(start, start + 256) + } + + private async getLayerBin(compVariantKey: string): Promise { + if (!this.manifest) return null + const entry = this.manifest.layers[compVariantKey] + if (!entry) return null + let p = this.layerBinCache.get(compVariantKey) + if (!p) { + p = this.loader.loadBin(entry.file) + this.layerBinCache.set(compVariantKey, p) + } + return p + } + + private async getDcc( + comp: SorceressComponent, + variant: string, + mode: string, + wclass: string, + ): Promise { + if (!this.manifest) return null + const compVariantKey = `${comp}-${variant.toLowerCase()}` + const layerEntry = this.manifest.layers[compVariantKey] + if (!layerEntry) return null + + const modeWclass = `${mode.toUpperCase()}${wclass.toUpperCase()}` + const range = layerEntry.dccs[modeWclass] + if (!range) return null + + const cacheKey = `${compVariantKey}:${modeWclass}` + const cached = this.dccCache.get(cacheKey) + if (cached !== undefined) return cached + + const bin = await this.getLayerBin(compVariantKey) + if (!bin) return null + const [offset, length] = range + const decoded = decodeDcc(bin.subarray(offset, offset + length)) + this.dccCache.set(cacheKey, decoded) + return decoded + } + + /** + * Composite all 11 Sorceress animation modes for the given `SorceressAppearanceSpec` + * and pack them into a single R8 indexed atlas buffer. + */ + async composite(spec: SorceressAppearanceSpec): Promise { + await this.ensureInitialized() + const manifest = this.manifest! + + // Pre-fetch all required -.bin files in parallel + const neededLayerKeys = new Set() + for (const comp of SORCERESS_COMPONENTS) { + const v = spec.layers[comp].variant + if (v) { + const key = `${comp}-${v.toLowerCase()}` + if (manifest.layers[key]) { + neededLayerKeys.add(key) + } else if (comp === 'hd' && manifest.layers['hd-lit']) { + neededLayerKeys.add('hd-lit') + } + } + } + await Promise.all([...neededLayerKeys].map(k => this.getLayerBin(k))) + + const allGroups: { frames: SpriteFrame[] }[] = [] + const clips: Record = {} + let groupOffset = 0 + let totalMembers = 0 + + for (const mode of SORCERESS_PLAYER_MODES) { + // Per D2Composit.cpp:68-75, death (dt) and corpse (dd) always use 'hth' + const requestedWc = mode === 'dt' || mode === 'dd' ? 'hth' : spec.wclass + let activeWc = requestedWc + let effectiveMode: string = mode + if (mode === 'a2' && (activeWc === 'hth' || !this.getCof(`SOA2${activeWc.toUpperCase()}`))) { + effectiveMode = 'a1' + } + let cofToken = `SO${effectiveMode.toUpperCase()}${activeWc.toUpperCase()}` + let cof = this.getCof(cofToken) + if (!cof && activeWc !== 'hth') { + activeWc = 'hth' + cofToken = `SO${effectiveMode.toUpperCase()}HTH` + cof = this.getCof(cofToken) + } + if (!cof) { + throw new Error(`Missing Sorceress COF descriptor for ${cofToken}`) + } + + // Resolve DCC + palette remap for each layer in the COF + const layerEntries = await Promise.all( + cof.layers.map(async (cofLayer) => { + const compName = COMPONENTS[cofLayer.type] as SorceressComponent | undefined + if (!compName || !SORCERESS_COMPONENTS.includes(compName)) { + return { dcc: null, remap: null } + } + // In dt/dd modes, weapons and shields are dropped (unarmed death animation) + if ((mode === 'dt' || mode === 'dd') && (compName === 'rh' || compName === 'lh' || compName === 'sh')) { + return { dcc: null, remap: null } + } + const layerSpec = spec.layers[compName] + if (!layerSpec || !layerSpec.variant) { + return { dcc: null, remap: null } + } + + const layerWc = + cofLayer.weaponClass && cofLayer.weaponClass.trim().length > 0 + ? cofLayer.weaponClass.trim().toLowerCase() + : activeWc + + let dcc = await this.getDcc(compName, layerSpec.variant, effectiveMode, layerWc) + if (!dcc && layerWc !== activeWc) { + dcc = await this.getDcc(compName, layerSpec.variant, effectiveMode, activeWc) + } + if (!dcc && activeWc !== 'hth') { + dcc = await this.getDcc(compName, layerSpec.variant, effectiveMode, 'hth') + } + if (!dcc && compName === 'hd' && layerSpec.variant !== 'lit') { + dcc = await this.getDcc('hd', 'lit', effectiveMode, layerWc) + } + if (!dcc) { + return { dcc: null, remap: null } + } + const remap = this.getPaletteRemap(layerSpec.transform, layerSpec.color) + return { dcc, remap } + }), + ) + + for (const entry of layerEntries) { + if (entry.dcc !== null) totalMembers += 1 + } + + for (let direction = 0; direction < cof.numberOfDirections; direction += 1) { + const dir64 = Math.round((direction * 64) / cof.numberOfDirections) + const frames: SpriteFrame[] = [] + + for (let frameIdx = 0; frameIdx < cof.framesPerDirection; frameIdx += 1) { + let left = Number.POSITIVE_INFINITY + let top = Number.POSITIVE_INFINITY + let right = Number.NEGATIVE_INFINITY + let bottom = Number.NEGATIVE_INFINITY + let placedCount = 0 + + for (let l = 0; l < cof.layers.length; l += 1) { + const sprite = layerEntries[l]?.dcc + if (!sprite) continue + const dccDir = dir64ToDcc(dir64, sprite.directions.length) + const layerDir = sprite.directions[dccDir] + const frame = layerDir?.frames[frameIdx] + if (!frame) continue + left = Math.min(left, layerDir!.box.left) + top = Math.min(top, layerDir!.box.top) + right = Math.max(right, layerDir!.box.left + layerDir!.box.width) + bottom = Math.max(bottom, layerDir!.box.top + layerDir!.box.height) + placedCount += 1 + } + + if (placedCount === 0) { + frames.push({ + width: 1, + height: 1, + indices: new Uint8Array(1), + mask: new Uint8Array(1), + anchorX: 0, + anchorY: 0, + }) + continue + } + + const boxLeft = Math.round(left) + const boxTop = Math.round(top) + const width = Math.max(1, Math.round(right) - boxLeft) + const height = Math.max(1, Math.round(bottom) - boxTop) + const composed: SpriteFrame = { + width, + height, + indices: new Uint8Array(width * height), + mask: new Uint8Array(width * height), + anchorX: boxLeft, + anchorY: boxTop, + } + + const order = cofLayerOrder(cof, direction, frameIdx) + for (const layerIndex of order) { + const entry = layerEntries[layerIndex] + if (!entry || !entry.dcc) continue + const dccDir = dir64ToDcc(dir64, entry.dcc.directions.length) + const layerDir = entry.dcc.directions[dccDir] + const frame = layerDir?.frames[frameIdx] + if (!frame) continue + blitWithRemap( + composed, + frame.frame, + Math.round(layerDir!.box.left) - boxLeft, + Math.round(layerDir!.box.top) - boxTop, + entry.remap, + ) + } + frames.push(composed) + } + allGroups.push({ frames }) + } + + const animRecord = manifest.animClips[cofToken] + const speed = animRecord?.speed ?? 256 + const events = (animRecord?.events ?? []).map(([f, c]) => ({ f, c })) + clips[mode] = { + group: groupOffset, + directions: cof.numberOfDirections, + frames: cof.framesPerDirection, + speed, + loop: LOOPING_PLAYER_MODES.has(mode), + events, + } + groupOffset += cof.numberOfDirections + } + + const packed = packAppearanceSheetToR8({ groups: allGroups, width: null }, 172) + return { + key: appearanceKey(spec), + spec, + r8: packed.r8, + width: packed.width, + height: packed.height, + directions: clips.wl?.directions ?? 16, + walk: clips.wl?.group ?? 0, + stand: clips.nu?.group ?? 16, + members: totalMembers, + clips, + groups: packed.groups, + } + } +} diff --git a/src/game/gems-data.ts b/src/game/gems-data.ts index f43d47a..7b6cb03 100644 --- a/src/game/gems-data.ts +++ b/src/game/gems-data.ts @@ -1234,3 +1234,56 @@ export function getSocketableInfo(code: string | undefined): (GemInfo | RuneInfo const c = code.trim().toLowerCase() return GEM_INFO[c] || RUNE_INFO[c] } + +/** + * Canonical 1.13c `Colors.txt` color codes (indices 0..20). + */ +export const COLOR_CODES: readonly string[] = [ + 'whit', // 0: White + 'lgry', // 1: Light Grey + 'dgry', // 2: Dark Grey + 'blac', // 3: Black + 'lblu', // 4: Light Blue + 'dblu', // 5: Dark Blue + 'cblu', // 6: Crystal Blue + 'lred', // 7: Light Red + 'dred', // 8: Dark Red + 'cred', // 9: Crystal Red + 'lgrn', // 10: Light Green + 'dgrn', // 11: Dark Green + 'cgrn', // 12: Crystal Green + 'lyel', // 13: Light Yellow + 'dyel', // 14: Dark Yellow + 'lgld', // 15: Light Gold + 'dgld', // 16: Dark Gold + 'lpur', // 17: Light Purple + 'dpur', // 18: Dark Purple + 'oran', // 19: Orange + 'bwht', // 20: Bright White +] as const + +/** + * Canonical 1.13c `Gems.txt` `transform` column (0..20 index into `Colors.txt`) for all 35 gems. + * Runes (`r01..r33`) have empty `transform` in `Gems.txt` and never dye socketed equipment. + */ +export const GEM_TRANSFORM_BY_CODE: Readonly> = { + gcv: 18, gfv: 18, gsv: 18, gzv: 18, gpv: 17, + gcb: 5, gfb: 5, gsb: 5, glb: 5, gpb: 6, + gcg: 11, gfg: 11, gsg: 11, glg: 11, gpg: 12, + gcr: 8, gfr: 8, gsr: 8, glr: 8, gpr: 9, + gcw: 1, gfw: 1, gsw: 1, glw: 1, gpw: 0, + gcy: 14, gfy: 14, gsy: 14, gly: 14, gpy: 13, + skc: 2, skf: 2, sku: 2, skl: 2, skz: 3, +} + +/** + * Returns the 4-letter `Colors.txt` code (e.g. `'cgrn'`, `'cred'`, `'lpur'`) for a gem code, + * or `undefined` if not a gem. + */ +export function getGemColorTransform(code: string | undefined): string | undefined { + if (!code) return undefined + const idx = GEM_TRANSFORM_BY_CODE[code.trim().toLowerCase()] + if (idx === undefined) return undefined + return COLOR_CODES[idx] +} + diff --git a/src/game/items.ts b/src/game/items.ts index 72121a3..c248ff7 100644 --- a/src/game/items.ts +++ b/src/game/items.ts @@ -97,6 +97,28 @@ export interface ItemBase { }>> | undefined /** Whether the item can spawn in normal gameplay or vendor stores. */ readonly spawnable?: boolean | undefined + /** 3-letter character paperdoll graphic token from alternategfx / alternateGfx column. */ + readonly alternategfx?: string | undefined + /** Alias for alternategfx on WeaponBase. */ + readonly alternateGfx?: string | undefined + /** Composite layer index (0=hd, 5=rh, 6=lh, 7=sh, 16=armor/misc). */ + readonly component?: number | undefined + /** Armor component weight (0=lit, 1=med, 2=hvy) for Right Arm. */ + readonly rArm?: number | undefined + /** Armor component weight (0=lit, 1=med, 2=hvy) for Left Arm. */ + readonly lArm?: number | undefined + /** Armor component weight (0=lit, 1=med, 2=hvy) for Torso. */ + readonly torso?: number | undefined + /** Armor component weight (0=lit, 1=med, 2=hvy) for Legs. */ + readonly legs?: number | undefined + /** Armor component weight (0=lit, 1=med, 2=hvy) for Right Shoulder Pad (s1). */ + readonly rSPad?: number | undefined + /** Armor component weight (0=lit, 1=med, 2=hvy) for Left Shoulder Pad (s2). */ + readonly lSPad?: number | undefined + /** Character 3D/composite palette shift file index (0..8) from Transform column. */ + readonly transform?: number | undefined + /** Inventory 2D icon palette shift file index (0..8) from InvTrans column. */ + readonly invTrans?: number | undefined /** Whether this weapon requires two hands (from Weapons.txt 2handed column). */ readonly twoHanded?: boolean | undefined /** Whether a Barbarian can wield this two-handed weapon in one hand (from Weapons.txt 1or2handed column). */ diff --git a/src/game/weapons.ts b/src/game/weapons.ts index f35a3a5..34edb4f 100644 --- a/src/game/weapons.ts +++ b/src/game/weapons.ts @@ -127,6 +127,16 @@ export interface WeaponBase extends ItemBase { readonly quest?: number | undefined readonly questDiffCheck?: boolean | undefined readonly vendorColumns?: Readonly> | undefined + /** 3-letter character paperdoll graphic token from alternateGfx column (fallback to code). */ + readonly alternateGfx?: string | undefined + /** Lowercase alias matching ArmorBase.alternategfx. */ + readonly alternategfx?: string | undefined + /** Composite layer index from component column (5=rh, 6=lh, 16=none). */ + readonly component?: number | undefined + /** Character paperdoll palette shift table index (0..8) from Transform column. */ + readonly transform?: number | undefined + /** Inventory 2D icon palette shift table index (0..8) from InvTrans column. */ + readonly invTrans?: number | undefined } /** @@ -308,6 +318,12 @@ export function parseWeaponsTable(input: D2Table | Uint8Array | string): WeaponT const quest = parseNumber(getCell(row, 'quest'), 0) const questDiffCheck = getCell(row, 'questdiffcheck') === '1' + const alternateGfx = (getCell(row, 'alternategfx') || code).toLowerCase() + const alternategfx = alternateGfx + const component = parseNumber(getCell(row, 'component'), 16) + const transform = parseNumber(getCell(row, 'transform'), 0) + const invTrans = parseNumber(getCell(row, 'invtrans'), 0) + const vendorColumns: Record = {} for (let vId = 0; vId < VENDOR_COUNT; vId++) { const prefix = VENDOR_COLUMN_PREFIXES[vId]! @@ -382,6 +398,11 @@ export function parseWeaponsTable(input: D2Table | Uint8Array | string): WeaponT quest, questDiffCheck, vendorColumns, + alternateGfx, + alternategfx, + component, + transform, + invTrans, } all.push(weapon) diff --git a/src/scene/act-scene.ts b/src/scene/act-scene.ts index 5044c83..6daab38 100644 --- a/src/scene/act-scene.ts +++ b/src/scene/act-scene.ts @@ -83,6 +83,12 @@ import type { AtlasHandle } from '../render/renderer.ts' import { buildBounds, intersects, viewportRect } from '../render/cull.ts' import { RoofFadeController } from '../render/roofs.ts' import { facingToDirection } from '../game/character.ts' +import { + SorceressLayerCompositor, + resolveSorceressAppearance, + appearanceKey, + type SorceressAppearanceSpec, +} from '../game/char-appearance.ts' import { ADDITIVE_MONSTER_TOKENS, type LoadedMonsterArt } from '../game/monster-art.ts' export { ADDITIVE_MONSTER_TOKENS } from '../game/monster-art.ts' import { velocityToDccDirection, MISSILE_METAS, type MissileMeta, type MissileFrameTuple } from '../render/missiles-meta.ts' @@ -704,10 +710,14 @@ export interface ActSceneState { heroName?: string /** Bounding box of the player sprite drawn this frame in scene coordinates. */ playerDrawRect?: { x: number; y: number; w: number; h: number } | undefined - /** Equipped or active weapon class code (e.g. 'hth', '1hs', '2hs'). */ + /** Equipped or active weapon class code (e.g. 'hth', '1hs', '2hs', 'stf', 'orb', 'bow', 'xbw'). */ weaponClass?: string | null | undefined /** Current animation clip mode name of the player (e.g. 'nu', 'wl', 'rn', 'a1', 'sc'). */ playerClip?: string | null | undefined + /** Canonical appearance cache key for dynamic modular paperdoll compositing. */ + characterAppearanceKey?: string | undefined + /** Active Sorceress 16-component appearance and palette shift spec. */ + characterAppearance?: SorceressAppearanceSpec | undefined /** Milliseconds from boot to the first rendered frame. */ loadMs: number missing: number @@ -854,6 +864,7 @@ declare global { enterTownPortal?: () => void getPortal?: () => OpenPortal | null getMinimap?: () => Minimap + syncAppearance?: () => Promise } } } @@ -7833,6 +7844,14 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st let packEntityAct = runtime.source === 'pack' ? runtime.act : undefined let character: LoadedCharacter | null = null + const dropTables = getEmbeddedDropTables() + let hudManager: HudManager | null = null + const soCompositor = new SorceressLayerCompositor( + packEntityBase ?? 'samples/d2-packs', + ) + let activeAppearanceKey = '' + let appearanceSeq = 0 + let monsterArtMap = new Map() let missileArtMap = new Map() void loadMissileArtMap(renderer, packEntityBase).then(map => { @@ -7858,6 +7877,81 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st ? LIGHTING_PRESETS[currentLightingPreset].labelEn : LIGHTING_PRESETS[currentLightingPreset].labelZh + const syncSorceressAppearance = async (): Promise => { + const hudInst = hudManager ?? ( + typeof window !== 'undefined' + ? (window as unknown as { __d2webHudInstance?: HudManager }).__d2webHudInstance ?? null + : null + ) + if (currentHeroToken !== 'so' || hudInst === null) { + activeAppearanceKey = '' + state.characterAppearanceKey = undefined + state.characterAppearance = undefined + if (hudInst !== null) { + const wCode = hudInst.inventory.equipped.weapon1?.code + state.weaponClass = wCode === '7wa' ? '2hs' : (wCode ? '1hs' : 'hth') + } + return false + } + + const spec = resolveSorceressAppearance( + hudInst.inventory.equipped, + dropTables, + ) + const key = appearanceKey(spec) + state.weaponClass = spec.wclass + state.characterAppearance = spec + + if (key === activeAppearanceKey) { + return false + } + activeAppearanceKey = key + state.characterAppearanceKey = key + const seq = ++appearanceSeq + + try { + const composited = await soCompositor.composite(spec) + if (seq !== appearanceSeq || currentHeroToken !== 'so') return false + const newHandle = renderer.addIndexedAtlas(composited.r8, composited.width, composited.height) + const groups: AtlasFrame[][] = composited.groups.map(group => + group.map(([x, y, width, height, anchorX, anchorY]) => ({ + x, + y, + width, + height, + anchorX, + anchorY, + })), + ) + if (character?.handle) { + renderer.deleteAtlas(character.handle) + } + character = { + handle: newHandle, + groups, + directions: composited.directions, + walk: composited.walk, + stand: composited.stand, + members: composited.members, + clips: composited.clips, + } + state.character = true + state.characterMembers = composited.members + state.characterAppearanceKey = key + state.characterAppearance = spec + if (playerAnimator.mode !== null) { + const nextClip = composited.clips[playerAnimator.mode] + if (nextClip !== undefined) { + playerAnimator.syncClip(nextClip) + } + } + return true + } catch (err) { + console.warn('Sorceress appearance composite failed:', err) + return false + } + } + const switchLighting = (preset: string): LightingPreset => { currentLightingPreset = normalizeLightingPreset(preset) const cfg = LIGHTING_PRESETS[currentLightingPreset] @@ -7926,6 +8020,13 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st if (hudManager !== null) { hudManager.switchClass(nextInfo.code as CharacterClassCode) } + activeAppearanceKey = '' + if (nextToken === 'so') { + await syncSorceressAppearance() + } else { + state.characterAppearanceKey = undefined + state.characterAppearance = undefined + } if (heroSelect !== null && heroSelect.value !== nextToken) { heroSelect.value = nextToken } @@ -7936,13 +8037,13 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st } catch { // ignore in non-browser context } - if (nextChar.clips?.nu) { - playerAnimator.play('nu', nextChar.clips.nu, { restart: true }) + if (character?.clips?.nu) { + playerAnimator.play('nu', character.clips.nu, { restart: true }) } const switchedPrefix = isEn ? 'Switched hero to ' : '已切换角色为 ' status.textContent = isEn - ? `${switchedPrefix}${heroDispName} (${String(nextChar.members)} layers).` - : `${switchedPrefix}${heroDispName}(${String(nextChar.members)} 层素材)。` + ? `${switchedPrefix}${heroDispName} (${String(character.members)} layers).` + : `${switchedPrefix}${heroDispName}(${String(character.members)} 层素材)。` setTimeout(() => { if (status.textContent.startsWith(switchedPrefix)) { status.textContent = '' @@ -7971,9 +8072,11 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st switchLighting, getLighting: () => currentLightingPreset, reloadMonsters: (opts) => reloadMonsters(opts), + syncAppearance: () => syncSorceressAppearance(), } ;(state as any).switchHero = switchHero ;(state as any).switchLighting = switchLighting + ;(state as any).syncAppearance = syncSorceressAppearance state.reloadMonsters = (opts) => reloadMonsters(opts) } const monsterAnimators = new Map() @@ -8048,9 +8151,6 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st return pack }) const streamingRooms = buildStreamingRooms(runtime, resolvedPacks, safeZones) - // Drop tables are pre-baked into memory via getEmbeddedDropTables() (zero network, zero MPQ). - const dropTables = getEmbeddedDropTables() - let hudManager: HudManager | null = null const engine = new GameEngine( createIsoTerrain(runtime.grid, runtime.widthPx, runtime.heightPx, { @@ -9164,11 +9264,7 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st } playerAnimator.tick() state.playerClip = playerAnimator.mode ?? (playerMoving ? 'wl' : 'nu') - const hud = (typeof window !== 'undefined' - ? (window as unknown as { __d2webHudInstance?: { inventory?: { equipped?: { weapon1?: { code?: string } } } } }).__d2webHudInstance - : undefined) - const w1Code = hud?.inventory?.equipped?.weapon1?.code - state.weaponClass = w1Code === '7wa' ? '2hs' : (w1Code ? '1hs' : 'hth') + void syncSorceressAppearance() } // Advance monster animations @@ -10029,6 +10125,9 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st hudManager.hotkeys.rightSkillId = heroInfo.defaultRightSkill hudManager.syncPublishedState() } + if (currentHeroToken === 'so') { + await syncSorceressAppearance() + } } const initialWalked = cellOf(runtime.grid, engine.world.player.x, engine.world.player.y) diff --git a/src/ui/item-bridge.ts b/src/ui/item-bridge.ts index 6bd8e6e..ef50fce 100644 --- a/src/ui/item-bridge.ts +++ b/src/ui/item-bridge.ts @@ -21,12 +21,15 @@ import { import { BAKED_UI_MANIFEST } from './baked-ui-meta.ts' import { formatItemTooltip } from '../game/item-tooltip.ts' import { translateItemName, translateRareName, translateMagicName } from '../game/tooltip-i18n.ts' -import { isRuneCode } from '../game/gems-data.ts' +import { isRuneCode, getGemColorTransform, COLOR_CODES } from '../game/gems-data.ts' +import { CANONICAL_AUTOMAGIC_TABLE } from '../game/automagic.ts' import type { D2ColorCode } from './font.ts' import { isA } from '../game/item-types.ts' import { inferItemClassFromType } from '../game/affixes.ts' import { CHARACTER_CLASS_CODES, type CharacterClassCode } from '../game/classes.ts' +export { COLOR_CODES, getGemColorTransform } + const VALID_CLASS_CODES = new Set(CHARACTER_CLASS_CODES) /** @@ -144,6 +147,8 @@ export function getEffectiveItemReqClass(item: { */ export function resolveItemRequiredClass( base: { + readonly id?: string | undefined + readonly code?: string | undefined readonly type?: string | undefined readonly type2?: string | undefined readonly tags?: readonly string[] | undefined @@ -152,6 +157,7 @@ export function resolveItemRequiredClass( readonly itemClass?: string | undefined }, rawItem?: { + readonly code?: string | undefined readonly reqClass?: string | undefined readonly itemClass?: string | undefined }, @@ -206,6 +212,11 @@ export function resolveItemRequiredClass( } } + const rawCode = (rawItem?.code ?? base.code ?? base.id ?? '').trim().toLowerCase() + if (rawCode && CANONICAL_CLASS_SPECIFIC_ITEM_CODES[rawCode]) { + return CANONICAL_CLASS_SPECIFIC_ITEM_CODES[rawCode] + } + return undefined } @@ -350,6 +361,178 @@ export function resolveAllowedSlots( return [] } +/** + * Ground Truth (`D2Common.dll` 1.13c `ITEMS_GetColor` @ `0x6fd99750`, `D2Common/src/Items/Items.cpp:3651-3836`): + * Resolves the 2D inventory palette shift (`invtransform`) and 3D character paperdoll palette shift (`chrtransform`) + * for an item across all quality tiers: + * - Unidentified / gamble mystery items have no color transform (`undefined`). + * - Unique (`ITEMQUAL_UNIQUE`): `uniqueItemDef.invtransform` / `uniqueItemDef.chrtransform` (or lookup in `dropTables.uniques`). + * - Set (`ITEMQUAL_SET`): `setItemDef.invtransform` / `setItemDef.chrtransform` (or lookup in `dropTables.sets.items`). + * - Magic / Rare / Crafted (`ITEMQUAL_MAGIC` | `ITEMQUAL_RARE` | `ITEMQUAL_CRAFT` | `ITEMQUAL_TEMPERED`): + * checks rolled **suffixes** first (`MagicSuffix.txt`), first suffix with `transform === true && transformColor` wins; + * if none, checks rolled **prefixes** (`MagicPrefix.txt`), first prefix with `transform === true && transformColor` wins. + * - Low / Normal / Superior (and non-runeword socketed items): + * checks `socketedItems` in socket order; the first socketed gem (`Gems.txt` `isGem`) maps `gem.transform` (`0..20`) + * to `COLOR_CODES[gem.transform]`. + * - AutoMagic fallback (`item.autoAffixId`): checks `CANONICAL_AUTOMAGIC_TABLE` for `transform && transformColor`. + */ +export function resolveItemColorTransform( + item: Item, + quality: UiItemQuality, + code: string, + dropTables?: DropTables, +): { invtransform?: string | undefined; chrtransform?: string | undefined } { + const isUnidentified = Boolean( + item.isGambleMystery || + item.identified === false || + (item.flags !== undefined && item.flags.identified === false), + ) + if (isUnidentified || (item as any).runewordRunes || item.flags?.runeword) { + return { invtransform: undefined, chrtransform: undefined } + } + + let invtransform: string | undefined = + (item as any).invtransform ?? item.uniqueItemDef?.invtransform ?? item.setItemDef?.invtransform + let chrtransform: string | undefined = + (item as any).chrtransform ?? item.uniqueItemDef?.chrtransform ?? item.setItemDef?.chrtransform + + if (quality === 'unique' && (!invtransform || !chrtransform) && dropTables?.uniques) { + const targetName = (item.name ?? '').trim() + let match = + (targetName ? dropTables.uniques.get?.(targetName) : undefined) ?? + (targetName + ? dropTables.uniques.all?.find( + u => u.enabled && u.index.trim().toLowerCase() === targetName.toLowerCase(), + ) + : undefined) + if (!match && code) { + const byCode = dropTables.uniques.getEnabledByCode?.(code.trim()) + if (byCode && byCode.length === 1) { + match = byCode[0] + } + } + if (match) { + if (!invtransform && match.invtransform) invtransform = match.invtransform.trim().toLowerCase() + if (!chrtransform && match.chrtransform) chrtransform = match.chrtransform.trim().toLowerCase() + } + } else if (quality === 'set' && (!invtransform || !chrtransform) && dropTables?.sets?.items) { + const targetName = (item.name ?? '').trim() + let match = + (targetName ? dropTables.sets.items.getByIndex?.(targetName) : undefined) ?? + (targetName + ? dropTables.sets.items.entries?.find( + s => s.index.trim().toLowerCase() === targetName.toLowerCase(), + ) + : undefined) + if (!match && code) { + const lowerCode = code.trim().toLowerCase() + const byCode = dropTables.sets.items.entries?.filter( + s => s.item.trim().toLowerCase() === lowerCode, + ) + if (byCode && byCode.length === 1) { + match = byCode[0] + } + } + if (match) { + if (!invtransform && match.invtransform) invtransform = match.invtransform.trim().toLowerCase() + if (!chrtransform && match.chrtransform) chrtransform = match.chrtransform.trim().toLowerCase() + } + } else if (quality === 'magic' || quality === 'rare' || quality === 'craft') { + if (!invtransform && !chrtransform) { + let affixColor: string | undefined + const suffixes = + item.rolledMagicAffixes?.suffixes ?? + item.rolledRareAffixes?.suffixes ?? + (item.rolledMagicAffixes?.suffix ? [item.rolledMagicAffixes.suffix] : []) + for (const suf of suffixes) { + if (suf && suf.transform && suf.transformColor) { + affixColor = suf.transformColor.trim().toLowerCase() + break + } + } + if (!affixColor && dropTables?.magicAffixes?.suffixes) { + const sufList = dropTables.magicAffixes.suffixes.all ?? (dropTables.magicAffixes.suffixes as any) + const sufIds = (item as any).magicSuffixIds ?? (item.magicSuffixId !== undefined ? [item.magicSuffixId] : []) + for (const id of sufIds) { + if (typeof id === 'number' && id > 0 && Array.isArray(sufList)) { + const suf = sufList[id - 1] ?? sufList.find((s: any) => s.id === id) + if (suf && suf.transform && suf.transformColor) { + affixColor = suf.transformColor.trim().toLowerCase() + break + } + } + } + } + if (!affixColor) { + const prefixes = + item.rolledMagicAffixes?.prefixes ?? + item.rolledRareAffixes?.prefixes ?? + (item.rolledMagicAffixes?.prefix ? [item.rolledMagicAffixes.prefix] : []) + for (const pre of prefixes) { + if (pre && pre.transform && pre.transformColor) { + affixColor = pre.transformColor.trim().toLowerCase() + break + } + } + } + if (!affixColor && dropTables?.magicAffixes?.prefixes) { + const preList = dropTables.magicAffixes.prefixes.all ?? (dropTables.magicAffixes.prefixes as any) + const preIds = (item as any).magicPrefixIds ?? (item.magicPrefixId !== undefined ? [item.magicPrefixId] : []) + for (const id of preIds) { + if (typeof id === 'number' && id > 0 && Array.isArray(preList)) { + const pre = preList[id - 1] ?? preList.find((p: any) => p.id === id) + if (pre && pre.transform && pre.transformColor) { + affixColor = pre.transformColor.trim().toLowerCase() + break + } + } + } + } + if (affixColor) { + invtransform = affixColor + chrtransform = affixColor + } + } + } else if (quality === 'normal' && !(item as any).runewordRunes && !item.flags?.runeword) { + if (!invtransform && !chrtransform && Array.isArray(item.socketedItems)) { + for (const sock of item.socketedItems) { + const sockCode = (sock?.code ?? sock?.base?.id ?? '').trim().toLowerCase() + const gemColor = getGemColorTransform(sockCode) + if (gemColor) { + invtransform = gemColor + chrtransform = gemColor + break + } + } + } + } + + // Fallback: AutoMagic (`Items.cpp:3816-3827`) + if (!invtransform && !chrtransform) { + const directAuto = (item as any).autoAffix + if (directAuto && directAuto.transform && directAuto.transformColor) { + const autoColor = String(directAuto.transformColor).trim().toLowerCase() + invtransform = autoColor + chrtransform = autoColor + } else if (typeof item.autoAffixId === 'number' && item.autoAffixId > 0) { + const autoTable = dropTables?.autoMagic ?? CANONICAL_AUTOMAGIC_TABLE + const autoEntry = + autoTable.entries[item.autoAffixId - 1] ?? + autoTable.entries.find(e => e.id === item.autoAffixId) + if (autoEntry && autoEntry.transform && autoEntry.transformColor) { + const autoColor = autoEntry.transformColor.trim().toLowerCase() + invtransform = autoColor + chrtransform = autoColor + } + } + } + + return { + invtransform: invtransform ? invtransform.trim().toLowerCase() : undefined, + chrtransform: chrtransform ? chrtransform.trim().toLowerCase() : undefined, + } +} + /** * Resolves the DC6 inventory graphic sprite key (`invFile`) following * the authentic Diablo II 1.13c resolution hierarchy and guaranteeing @@ -362,8 +545,10 @@ export function resolveItemInvFile( dropTables?: DropTables, ): { invFile: string; invtransform?: string | undefined; chrtransform?: string | undefined } { let invFile: string | undefined = undefined - let invtransform: string | undefined = item.uniqueItemDef?.invtransform ?? item.setItemDef?.invtransform - const chrtransform: string | undefined = item.uniqueItemDef?.chrtransform ?? item.setItemDef?.chrtransform + const resolvedColors = resolveItemColorTransform(item, quality, code, dropTables) + let invtransform: string | undefined = resolvedColors.invtransform + const chrtransform: string | undefined = resolvedColors.chrtransform + // 1. Explicit Unique / Set Table Graphics if (quality === 'unique') { diff --git a/tests/issue-506-equipment-restrictions.test.ts b/tests/issue-506-equipment-restrictions.test.ts index 5577e01..a60d1ed 100644 --- a/tests/issue-506-equipment-restrictions.test.ts +++ b/tests/issue-506-equipment-restrictions.test.ts @@ -106,10 +106,7 @@ describe('Issue #506: Diablo II v1.13c Character Equipment Restrictions & Red Re it('Requirement 1: blocks equipping items exceeding character level, strength, dexterity, or class requirements', () => { const inv = new InventoryPanel() - delete inv.equipped.helm - delete inv.equipped.armor - delete inv.equipped.weapon1 - delete inv.equipped.weapon2 + ;(inv as unknown as { equipped: Record }).equipped = {} inv.setPlayerContext({ level: 40, @@ -313,8 +310,7 @@ describe('Issue #506: Diablo II v1.13c Character Equipment Restrictions & Red Re it('Requirement 2 (Default Non-Barbarian): allows 1H weapon + shield OR 1 two-handed weapon, blocks dual-wielding and 2H + shield', () => { const inv = new InventoryPanel() - delete inv.equipped.weapon1 - delete inv.equipped.weapon2 + ;(inv as unknown as { equipped: Record }).equipped = {} inv.setPlayerContext({ level: 85, @@ -451,8 +447,7 @@ describe('Issue #506: Diablo II v1.13c Character Equipment Restrictions & Red Re it('Requirement 2 (Barbarian 1.13c): allows dual-wielding 1H weapons and 1or2handed two-handed swords, and 2H sword + shield, while blocking strict 2H dual-wielding', () => { const inv = new InventoryPanel() - delete inv.equipped.weapon1 - delete inv.equipped.weapon2 + ;(inv as unknown as { equipped: Record }).equipped = {} inv.setPlayerContext({ level: 85, @@ -606,21 +601,18 @@ describe('Issue #506: Diablo II v1.13c Character Equipment Restrictions & Red Re const cbBase = tables.weapons.get('7gd')! const uiCb = itemToUiInventoryItem( { - id: '9001', name: 'Colossus Blade', base: cbBase, quality: 2, level: 85, identified: true, - ethereal: false, - socketed: false, - sockets: 0, stats: {}, - prefix: 0, - suffix: 0, - location: 0, - x: 0, - y: 0, + prefix: null, + suffix: null, + invWidth: 2, + invHeight: 4, + stack: 1, + value: 1000, }, tables, ) diff --git a/tests/sorceress-appearance.test.ts b/tests/sorceress-appearance.test.ts new file mode 100644 index 0000000..30bba1a --- /dev/null +++ b/tests/sorceress-appearance.test.ts @@ -0,0 +1,198 @@ +import { readFile } from 'node:fs/promises' +import { join, resolve } from 'node:path' +import { describe, expect, it } from 'vitest' +import { COLOR_CODES, GEM_TRANSFORM_BY_CODE } from '../src/game/gems-data.ts' +import { getEmbeddedDropTables } from '../src/game/embedded-drop-tables.ts' +import { resolveItemColorTransform } from '../src/ui/item-bridge.ts' +import { + SorceressLayerCompositor, + appearanceKey, + isDefaultUnarmedAppearance, + resolveSorceressAppearance, + type ResolvableEquippedItem, +} from '../src/game/char-appearance.ts' + +function makeItem(overrides: Partial & { code: string; name: string }): ResolvableEquippedItem { + return { + quality: 'normal', + identified: true, + ...overrides, + } +} + +describe('Sorceress Equipment Appearance & Palette Shift (1.13c D2Composit / ITEMS_GetColor)', () => { + const dropTables = getEmbeddedDropTables() + + it('resolves item color transforms per 1.13c ITEMS_GetColor (Items.cpp:3699)', () => { + expect(COLOR_CODES).toHaveLength(21) + expect(COLOR_CODES[0]).toBe('whit') + expect(COLOR_CODES[20]).toBe('bwht') + expect(Object.keys(GEM_TRANSFORM_BY_CODE)).toHaveLength(35) + + // Unidentified items never dye + expect( + resolveItemColorTransform({ name: 'Harlequin Crest', identified: false } as any, 'unique', 'uap', dropTables) + .chrtransform, + ).toBeUndefined() + + // Unique items + expect( + resolveItemColorTransform({ name: 'Harlequin Crest', identified: true } as any, 'unique', 'uap', dropTables) + .chrtransform, + ).toBe('cgrn') + expect( + resolveItemColorTransform({ name: 'Skin of the Vipermagi', identified: true } as any, 'unique', 'xea', dropTables) + .chrtransform, + ).toBe('dblu') + + // Set items (including display name vs internal index fallback) + expect( + resolveItemColorTransform( + { name: "Tal Rasha's Horadric Crest", identified: true } as any, + 'set', + 'xsk', + dropTables, + ).chrtransform, + ).toBe('dpur') + expect( + resolveItemColorTransform( + { name: "Tal Rasha's Guardianship", identified: true } as any, + 'set', + 'uth', + dropTables, + ).chrtransform, + ).toBe('dpur') + + // Magic/Rare/Crafted: suffix overrides prefix + expect( + resolveItemColorTransform( + { + name: 'Cobalt Cap of the Mammoth', + identified: true, + rolledMagicAffixes: { + prefixes: [{ name: 'Cobalt', transform: true, transformColor: 'cblu' }], + suffixes: [{ name: 'of the Mammoth', transform: true, transformColor: 'cred' }], + }, + } as any, + 'magic', + 'cap', + dropTables, + ).chrtransform, + ).toBe('cred') + + // Socketed gems vs Runeword exclusion + expect( + resolveItemColorTransform( + { + name: 'Socketed Cap', + identified: true, + socketedItems: [{ code: 'gpw', name: 'Perfect Diamond' }], + } as any, + 'normal', + 'cap', + dropTables, + ).chrtransform, + ).toBe('whit') + expect( + resolveItemColorTransform( + { + name: 'Enigma', + identified: true, + runewordRunes: "'JahIthBer'", + socketedItems: [{ code: 'gpw', name: 'Perfect Diamond' }], + } as any, + 'rune', + 'utp', + dropTables, + ).chrtransform, + ).toBeUndefined() + }) + + it('maps equipped gear to 10 Sorceress paperdoll components and weapon classes', () => { + const unarmed = resolveSorceressAppearance({}, dropTables) + expect(unarmed.wclass).toBe('hth') + expect(isDefaultUnarmedAppearance(unarmed)).toBe(true) + + // Circlet exemption (D2Inventory.cpp:2624) + const circletSpec = resolveSorceressAppearance( + { helm: makeItem({ code: 'ci3', name: "Griffon's Eye", quality: 'unique', chrtransform: 'cblu' }) }, + dropTables, + ) + expect(circletSpec.layers.hd.variant).toBe('lit') + expect(circletSpec.layers.hd.color).toBeNull() + + // Asymmetric Studded Leather (stu) + const stuSpec = resolveSorceressAppearance( + { armor: makeItem({ code: 'stu', name: 'Studded Leather', chrtransform: 'cgrn' }) }, + dropTables, + ) + expect(stuSpec.layers.tr).toEqual({ variant: 'lit', transform: 7, color: 'cgrn' }) + expect(stuSpec.layers.lg).toEqual({ variant: 'med', transform: 7, color: 'cgrn' }) + expect(stuSpec.layers.ra).toEqual({ variant: 'med', transform: 7, color: 'cgrn' }) + expect(stuSpec.layers.la).toEqual({ variant: 'lit', transform: 7, color: 'cgrn' }) + expect(stuSpec.layers.s1).toEqual({ variant: 'med', transform: 7, color: 'cgrn' }) + expect(stuSpec.layers.s2).toEqual({ variant: 'med', transform: 7, color: 'cgrn' }) + + // Bow on LH vs Crossbow on RH + const bowSpec = resolveSorceressAppearance( + { weapon1: makeItem({ code: 'sbw', name: 'Short Bow' }) }, + dropTables, + ) + expect(bowSpec.wclass).toBe('bow') + expect(bowSpec.layers.lh.variant).toBe('sbw') + expect(bowSpec.layers.rh.variant).toBeNull() + + const xbwSpec = resolveSorceressAppearance( + { weapon1: makeItem({ code: 'lxb', name: 'Light Crossbow' }) }, + dropTables, + ) + expect(xbwSpec.wclass).toBe('xbw') + expect(xbwSpec.layers.rh.variant).toBe('lxb') + expect(xbwSpec.layers.lh.variant).toBeNull() + }) + + it('composites Sorceress layers from offline pack with palette shift tables', async () => { + const packDir = resolve(process.cwd(), 'samples/d2-packs/chars/so') + const compositor = new SorceressLayerCompositor({ + async loadJson(relPath: string): Promise { + const raw = await readFile(join(packDir, relPath), 'utf8') + return JSON.parse(raw) as T + }, + async loadBin(relPath: string): Promise { + const buf = await readFile(join(packDir, relPath)) + return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength) + }, + }) + + const redSpec = resolveSorceressAppearance( + { + helm: makeItem({ code: 'crn', name: 'Ruby Crown', quality: 'magic', chrtransform: 'cred' }), + armor: makeItem({ code: 'fld', name: 'Ruby Field Plate', quality: 'magic', chrtransform: 'cred' }), + weapon1: makeItem({ code: 'oba', name: 'The Oculus', quality: 'unique', chrtransform: 'whit' }), + weapon2: makeItem({ code: 'uit', name: 'Monarch', quality: 'magic', chrtransform: 'cred' }), + }, + dropTables, + ) + const blueSpec = resolveSorceressAppearance( + { + helm: makeItem({ code: 'crn', name: 'Cobalt Crown', quality: 'magic', chrtransform: 'cblu' }), + armor: makeItem({ code: 'fld', name: 'Cobalt Field Plate', quality: 'magic', chrtransform: 'cblu' }), + weapon1: makeItem({ code: 'oba', name: 'The Oculus', quality: 'unique', chrtransform: 'whit' }), + weapon2: makeItem({ code: 'uit', name: 'Monarch', quality: 'magic', chrtransform: 'cblu' }), + }, + dropTables, + ) + + expect(appearanceKey(redSpec)).not.toBe(appearanceKey(blueSpec)) + const redAtlas = await compositor.composite(redSpec) + const blueAtlas = await compositor.composite(blueSpec) + expect(redAtlas.width).toBe(blueAtlas.width) + expect(redAtlas.height).toBe(blueAtlas.height) + + let diffPixels = 0 + for (let i = 0; i < redAtlas.r8.length; i += 1) { + if (redAtlas.r8[i] !== blueAtlas.r8[i]) diffPixels += 1 + } + expect(diffPixels).toBeGreaterThan(100000) + }) +})