feat(char,items): dynamic Sorceress equipment paperdoll appearance, weapon classes, and 1.13c palette dye shifts

This commit is contained in:
troytt 2026-09-28 12:42:21 +00:00
parent 4e461e1fdb
commit 6601dcde82
14 changed files with 2321 additions and 36 deletions

View File

@ -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",

View File

@ -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\<comp>\so<comp><variant><mode><wclass>.dcc` (1,669 DCC files across 77 `<comp>-<variant>` 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<Record<number, string>> = {
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<string, [number, number]>
readonly palettes: Record<number, [number, number]>
readonly animClips: Record<string, { speed: number; events: [number, number][] }>
readonly layers: Record<
string,
{
readonly file: string
readonly dccs: Record<string, [number, number]>
}
>
}
async function writeFileAtomic(targetPath: string, content: string | Uint8Array): Promise<void> {
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<SorceressLayerPackManifest> {
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<string, { frames: number; speed: number; events?: [number, number][] }>
}
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<string, { modeWclass: string; path: string }[]>()
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\<comp>\so<comp><lit><mode><wclass>.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<string, [number, number]> = {}
const animClips: Record<string, { speed: number; events: [number, number][] }> = {}
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<number, [number, number]> = {}
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 <comp>-<variant>.bin
const sortedLayerKeys = [...dccByCompVariant.keys()].sort()
const layers: Record<string, { file: string; dccs: Record<string, [number, number]> }> = {}
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<string, [number, number]> = {}
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)
})
}

View File

@ -298,7 +298,7 @@ async function processCharJob(job: CharJob, ctx: EntityWorkerContext): Promise<C
let charGroupOffset = 0
let totalCharMembers = 0
for (const mode of PLAYER_MODES) {
const sheet = await loadCharacterSheet(archives, 'so', mode, 'hth')
const sheet = await loadCharacterSheet(archives, 'so', mode, 'hth', undefined, true)
charSheets.push(sheet)
totalCharMembers += sheet.members.length
@ -814,12 +814,20 @@ export async function bakeEntities(archiveDir = 'samples/d2', outDir = 'samples/
// 2. Build job list: Job 0 = Sorceress character, Jobs 1..N = Monsters
const jobs: EntityJob[] = []
jobs.push({
const charJob: CharJob = {
type: 'char',
jobId: 0,
archiveDir,
outDir,
})
}
jobs.push(charJob)
if (process.env.CHAR_ONLY === '1') {
const ctx = await EntityWorkerContext.create(archiveDir, outDir)
await processCharJob(charJob, ctx)
console.log('✓ Packed char-so (CHAR_ONLY=1).')
return
}
const entries = [...uniqueSpecs.entries()]
for (let i = 0; i < entries.length; i += 1) {

View File

@ -0,0 +1,691 @@
import { readFile } from 'node:fs/promises'
import { join, resolve } from 'node:path'
import { spawn, type ChildProcess } from 'node:child_process'
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,
resolveSorceressAppearance,
} from '../src/game/char-appearance.ts'
import type { ResolvableEquippedItem } from '../src/game/char-appearance.ts'
const sleep = (ms: number): Promise<void> => 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<ResolvableEquippedItem> & { code: string; name: string }): ResolvableEquippedItem {
return {
quality: 'normal',
identified: true,
...overrides,
}
}
interface CdpSession {
send: <T = unknown>(method: string, params?: Record<string, unknown>, timeoutMs?: number) => Promise<T>
evalJs: <T = unknown>(expression: string) => Promise<T>
close: () => void
}
async function connectCdp(debugPort: number, onEvent: (msg: Record<string, any>) => void): Promise<CdpSession> {
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<void>((res, rej) => {
ws.onopen = () => res()
ws.onerror = () => rej(new Error('CDP WebSocket failed to open'))
})
let idCounter = 1
const send = <T = unknown>(
method: string,
params: Record<string, unknown> = {},
timeoutMs = 20000,
): Promise<T> =>
new Promise<T>((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<string, any>
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<string, any>)
})
const evalJs = async <T = unknown>(expression: string): Promise<T> => {
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<void> {
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<ChildProcess> {
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<void> {
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>(`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<void> {
await runUnitAndCompositorChecks()
await runBrowserE2ECheck()
console.log('\n🎉 ALL SORCERESS APPEARANCE & COLOR VERIFICATION CHECKS PASSED!')
}
main().catch(err => {
console.error(err)
process.exit(1)
})

View File

@ -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.
*

View File

@ -96,6 +96,26 @@ export interface ArmorBase extends ItemBase {
readonly quest?: number | undefined
readonly questDiffCheck?: boolean | undefined
readonly vendorColumns?: Readonly<Record<number, VendorItemColumns>> | 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<number, VendorItemColumns> = {}
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)

746
src/game/char-appearance.ts Normal file
View File

@ -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<string>(['nu', 'wl', 'rn', 'tn', 'tw'])
const ARMOR_TIERS: readonly ('lit' | 'med' | 'hvy')[] = ['lit', 'med', 'hvy']
const VALID_HEAD_VARIANTS = new Set<string>([
'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<Record<SorceressComponent, LayerAppearance>>
}
/** 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<Record<string, readonly [number, number]>>
readonly palettes: Readonly<Record<number, readonly [number, number]>>
readonly animClips: Readonly<
Record<string, { readonly speed: number; readonly events: readonly (readonly [number, number])[] }>
>
readonly layers: Readonly<
Record<
string,
{
readonly file: string
readonly dccs: Readonly<Record<string, readonly [number, number]>>
}
>
>
}
/** 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<Record<string, AnimClipMeta>>
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<unknown>
loadBin(relPath: string): Promise<Uint8Array>
}
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<Record<EquipSlotId, ResolvableEquippedItem | null | undefined>>,
dropTables: DropTables,
): SorceressAppearanceSpec {
const layers: Record<SorceressComponent, LayerAppearance> = {
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<unknown> {
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<Uint8Array> {
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<void> | null = null
private manifest: SorceressLayerPackManifest | null = null
private metaBin: Uint8Array | null = null
private readonly cofCache = new Map<string, CofFile>()
private readonly layerBinCache = new Map<string, Promise<Uint8Array>>()
private readonly dccCache = new Map<string, DccFile>()
constructor(packBaseOrLoader: string | CharLayerAssetLoader = 'samples/d2-packs') {
this.loader =
typeof packBaseOrLoader === 'string'
? createFetchLoader(packBaseOrLoader)
: packBaseOrLoader
}
private async ensureInitialized(): Promise<void> {
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<Uint8Array | null> {
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<DccFile | null> {
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<CompositedSorceressR8> {
await this.ensureInitialized()
const manifest = this.manifest!
// Pre-fetch all required <comp>-<variant>.bin files in parallel
const neededLayerKeys = new Set<string>()
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<string, AnimClipMeta> = {}
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,
}
}
}

View File

@ -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<Record<string, number>> = {
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]
}

View File

@ -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). */

View File

@ -127,6 +127,16 @@ export interface WeaponBase extends ItemBase {
readonly quest?: number | undefined
readonly questDiffCheck?: boolean | undefined
readonly vendorColumns?: Readonly<Record<number, VendorItemColumns>> | 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<number, VendorItemColumns> = {}
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)

View File

@ -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<boolean>
}
}
}
@ -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<string, LoadedMonsterArt>()
let missileArtMap = new Map<string, LoadedMissileArt>()
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<boolean> => {
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<number, ActorAnimator>()
@ -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)

View File

@ -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<string>(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') {

View File

@ -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<string, unknown> }).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<string, unknown> }).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<string, unknown> }).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,
)

View File

@ -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<ResolvableEquippedItem> & { 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<T>(relPath: string): Promise<T> {
const raw = await readFile(join(packDir, relPath), 'utf8')
return JSON.parse(raw) as T
},
async loadBin(relPath: string): Promise<Uint8Array> {
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)
})
})