feat(frontend): implement 1.13c D2Launch/D2Multi/D2Win frontend renderer & flow state parity (#696-#701, #703-#711)

This commit is contained in:
troytt 2026-10-03 04:24:01 +00:00
parent f209f4e6e2
commit 948a586432
5 changed files with 2590 additions and 508 deletions

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,418 @@
/**
* Diablo II: Lord of Destruction v1.13c — MCP `0x19` (`MCP_CHARLIST2`) Statstring
* Composite Avatar Parser & `TN` Mode Layer Descriptor (`D2Launch.dll` `0x6fa4a440`,
* `D2Multi.dll` `0x6f9d2940`, `Composit.txt`, `Colors.txt`).
*
* The 33-byte MCP character statstring layout (`D2MCPClient.dll` `0x6fa25e80`):
* - `[0..1]`: `0x84, 0x80` header magic
* - `[2..12]`: 11 composite component codes (`HD, TR, LG, RA, LA, RH, LH, SH, S1, S2, S3`), `0xFF` = none/lit default
* - `[13]`: `classId + 1` (`1..7`)
* - `[14..24]`: 11 component dye color indices (`0xFF` = uncolored, `0..20` = `Colors.txt` transform index)
* - `[25]`: character level (`1..99`)
* - `[26]`: status flags (`0x04` Hardcore, `0x08` Dead, `0x20` Expansion, `0x40` Ladder)
* - `[27]`: `actAndDiff` (`bits 0..4` = 5-bit progression)
*/
import type { CampfireClassId } from './flow-state.ts'
/**
* The 11 composite avatar component slots in `Composit.txt` order (`statstring[2..12]`).
*/
export const AVATAR_COMPONENT_NAMES = [
'HD',
'TR',
'LG',
'RA',
'LA',
'RH',
'LH',
'SH',
'S1',
'S2',
'S3',
] as const
export type AvatarComponentName = (typeof AVATAR_COMPONENT_NAMES)[number]
/**
* `Colors.txt` 21 canonical dye/palette shift entries (`0..20`, `0xFF` = default/uncolored).
* Used to tint equipped component layers in `TN` composite avatar rendering.
*/
export const D2_COLORS_TXT_PALETTE: ReadonlyArray<{
readonly code: string
readonly name: string
readonly rgb: readonly [number, number, number]
}> = [
{ code: 'whit', name: 'White', rgb: [240, 240, 240] },
{ code: 'lgry', name: 'Light Grey', rgb: [192, 192, 192] },
{ code: 'dgry', name: 'Dark Grey', rgb: [104, 104, 104] },
{ code: 'blac', name: 'Black', rgb: [44, 44, 48] },
{ code: 'lblu', name: 'Light Blue', rgb: [116, 156, 232] },
{ code: 'dblu', name: 'Dark Blue', rgb: [48, 76, 164] },
{ code: 'cblu', name: 'Crystal Blue', rgb: [88, 196, 244] },
{ code: 'lred', name: 'Light Red', rgb: [228, 104, 104] },
{ code: 'dred', name: 'Dark Red', rgb: [148, 36, 36] },
{ code: 'cred', name: 'Crystal Red', rgb: [240, 64, 76] },
{ code: 'lgrn', name: 'Light Green', rgb: [116, 216, 116] },
{ code: 'dgrn', name: 'Dark Green', rgb: [40, 128, 48] },
{ code: 'cgrn', name: 'Crystal Green', rgb: [80, 240, 120] },
{ code: 'lyel', name: 'Light Yellow', rgb: [244, 228, 120] },
{ code: 'dyel', name: 'Dark Yellow', rgb: [180, 152, 48] },
{ code: 'lgld', name: 'Light Gold', rgb: [228, 196, 116] },
{ code: 'dgld', name: 'Dark Gold', rgb: [164, 128, 56] },
{ code: 'lpur', name: 'Light Purple', rgb: [196, 124, 236] },
{ code: 'dpur', name: 'Dark Purple', rgb: [116, 48, 164] },
{ code: 'oran', name: 'Orange', rgb: [232, 140, 52] },
{ code: 'bwht', name: 'Bright White', rgb: [255, 255, 255] },
]
/**
* Head component graphic index (`statstring[2]`) -> 3-letterDCC token (`Armor.txt`).
*/
const HEAD_COMPONENT_TOKENS: Readonly<Record<number, string>> = {
1: 'lit',
2: 'med',
3: 'hvy',
4: 'cap',
5: 'skp',
6: 'hlm',
7: 'fhl',
8: 'ghm',
9: 'crn',
10: 'msk',
11: 'bhm',
12: 'ci0',
13: 'ci1',
14: 'ci2',
15: 'ci3',
16: 'ba1',
17: 'ba2',
18: 'ba3',
19: 'ba4',
20: 'ba5',
21: 'dr1',
22: 'dr2',
23: 'dr3',
24: 'dr4',
25: 'dr5',
}
/**
* Armor tier component graphic index (`TR`, `LG`, `RA`, `LA`, `S1`, `S2`) -> 3-letter DCC token.
*/
const ARMOR_TIER_TOKENS: Readonly<Record<number, string>> = {
1: 'lit',
2: 'med',
3: 'hvy',
}
/**
* Shield component graphic index (`statstring[9]`) -> 3-letter DCC token (`Armor.txt`).
*/
const SHIELD_COMPONENT_TOKENS: Readonly<Record<number, string>> = {
1: 'buc',
2: 'sml',
3: 'lrg',
4: 'kit',
5: 'tow',
6: 'gts',
7: 'bsh',
8: 'spk',
9: 'pa1',
10: 'pa2',
11: 'pa3',
12: 'pa4',
13: 'pa5',
14: 'ne1',
15: 'ne2',
16: 'ne3',
17: 'ne4',
18: 'ne5',
}
/**
* Weapon component graphic index (`RH`/`LH`, `statstring[7..8]`) -> `{ token, wclass }` (`Weapons.txt`).
*/
const WEAPON_COMPONENT_TABLE: Readonly<Record<number, { readonly token: string; readonly wclass: string }>> = {
1: { token: 'hax', wclass: '1hs' },
2: { token: 'axe', wclass: '1hs' },
3: { token: '2ax', wclass: '1hs' },
4: { token: 'mpi', wclass: '1hs' },
5: { token: 'wax', wclass: '1hs' },
6: { token: 'lax', wclass: '2ht' },
7: { token: 'bax', wclass: '2ht' },
8: { token: 'btx', wclass: '2ht' },
9: { token: 'gax', wclass: '2ht' },
10: { token: 'gix', wclass: '2ht' },
11: { token: 'wnd', wclass: '1hs' },
12: { token: 'ywn', wclass: '1hs' },
13: { token: 'bwn', wclass: '1hs' },
14: { token: 'gwn', wclass: '1hs' },
15: { token: 'clb', wclass: '1hs' },
16: { token: 'scp', wclass: '1hs' },
17: { token: 'gsc', wclass: '1hs' },
18: { token: 'wsp', wclass: '1hs' },
19: { token: 'spc', wclass: '1hs' },
20: { token: 'mac', wclass: '1hs' },
21: { token: 'mst', wclass: '1hs' },
22: { token: 'fla', wclass: '1hs' },
23: { token: 'whm', wclass: '1hs' },
24: { token: 'mau', wclass: '2ht' },
25: { token: 'gma', wclass: '2ht' },
26: { token: 'ssd', wclass: '1hs' },
27: { token: 'scm', wclass: '1hs' },
28: { token: 'sbr', wclass: '1hs' },
29: { token: 'flc', wclass: '1hs' },
30: { token: 'crs', wclass: '1hs' },
31: { token: 'bsd', wclass: '1hs' },
32: { token: 'lsd', wclass: '1hs' },
33: { token: 'wsd', wclass: '1hs' },
34: { token: '2hs', wclass: '2hs' },
35: { token: 'clm', wclass: '2hs' },
36: { token: 'gis', wclass: '2hs' },
37: { token: 'bsw', wclass: '2hs' },
38: { token: 'flb', wclass: '2hs' },
39: { token: 'gsd', wclass: '2hs' },
40: { token: 'dgr', wclass: '1ht' },
41: { token: 'dir', wclass: '1ht' },
42: { token: 'kri', wclass: '1ht' },
43: { token: 'bld', wclass: '1ht' },
44: { token: 'tkf', wclass: '1ht' },
45: { token: 'tax', wclass: '1hs' },
46: { token: 'bkf', wclass: '1ht' },
47: { token: 'bal', wclass: '1hs' },
48: { token: 'jav', wclass: '1ht' },
49: { token: 'pil', wclass: '1ht' },
50: { token: 'ssp', wclass: '1ht' },
51: { token: 'glv', wclass: '1ht' },
52: { token: 'tsp', wclass: '1ht' },
53: { token: 'spr', wclass: '2ht' },
54: { token: 'tri', wclass: '2ht' },
55: { token: 'brn', wclass: '2ht' },
56: { token: 'spt', wclass: '2ht' },
57: { token: 'pik', wclass: '2ht' },
58: { token: 'bar', wclass: 'stf' },
59: { token: 'vou', wclass: 'stf' },
60: { token: 'scy', wclass: 'stf' },
61: { token: 'pax', wclass: 'stf' },
62: { token: 'hal', wclass: 'stf' },
63: { token: 'wsc', wclass: 'stf' },
64: { token: 'sst', wclass: 'stf' },
65: { token: 'lst', wclass: 'stf' },
66: { token: 'cst', wclass: 'stf' },
67: { token: 'bst', wclass: 'stf' },
68: { token: 'wst', wclass: 'stf' },
69: { token: 'sbw', wclass: 'bow' },
70: { token: 'hbw', wclass: 'bow' },
71: { token: 'lbw', wclass: 'bow' },
72: { token: 'cbw', wclass: 'bow' },
73: { token: 'sbb', wclass: 'bow' },
74: { token: 'lbb', wclass: 'bow' },
75: { token: 'swb', wclass: 'bow' },
76: { token: 'lwb', wclass: 'bow' },
77: { token: 'lxb', wclass: 'xbw' },
78: { token: 'mxb', wclass: 'xbw' },
79: { token: 'hxb', wclass: 'xbw' },
80: { token: 'rxb', wclass: 'xbw' },
85: { token: 'ktr', wclass: 'ht1' },
86: { token: 'wrb', wclass: 'ht1' },
87: { token: 'axf', wclass: 'ht1' },
88: { token: 'ces', wclass: 'ht1' },
89: { token: 'clw', wclass: 'ht1' },
90: { token: 'btl', wclass: 'ht1' },
91: { token: 'skr', wclass: 'ht1' },
92: { token: 'ob1', wclass: '1hs' },
93: { token: 'ob2', wclass: '1hs' },
94: { token: 'ob3', wclass: '1hs' },
95: { token: 'ob4', wclass: '1hs' },
96: { token: 'ob5', wclass: '1hs' },
97: { token: 'am1', wclass: 'bow' },
98: { token: 'am2', wclass: 'bow' },
99: { token: 'am3', wclass: '2ht' },
}
export interface AvatarComponentLayerSpec {
readonly component: AvatarComponentName
readonly rawCode: number
readonly token: string
readonly colorIndex: number
readonly colorCode: string | null
readonly rgb: readonly [number, number, number] | null
}
export interface ParsedMcpAvatarStatstring {
readonly classToken: string
readonly mode: 'TN'
readonly weaponClass: string
readonly hasEquippedComponents: boolean
readonly rawComponentCodes: Uint8Array
readonly rawComponentColors: Uint8Array
readonly components: Readonly<Record<AvatarComponentName, string>>
readonly componentTokens: Readonly<Record<AvatarComponentName, string>>
readonly componentColors: Readonly<Record<AvatarComponentName, number>>
readonly equippedLayers: readonly AvatarComponentLayerSpec[]
readonly cofSignature: string
}
const CLASS_ID_TO_TOKEN: Readonly<Record<CampfireClassId, string>> = {
am: 'am',
as: 'ai',
ne: 'ne',
ba: 'ba',
pa: 'pa',
so: 'so',
dz: 'dz',
}
function resolveComponentToken(component: AvatarComponentName, rawCode: number): string {
if (rawCode === 0xff || rawCode === 0) {
if (component === 'TR' || component === 'LG' || component === 'RA' || component === 'LA') {
return 'lit'
}
if (component === 'HD') {
return 'lit'
}
return 'nil'
}
switch (component) {
case 'HD':
return HEAD_COMPONENT_TOKENS[rawCode] ?? 'hlm'
case 'TR':
case 'LG':
case 'RA':
case 'LA':
case 'S1':
case 'S2':
case 'S3':
return ARMOR_TIER_TOKENS[rawCode] ?? 'hvy'
case 'RH':
case 'LH':
return WEAPON_COMPONENT_TABLE[rawCode]?.token ?? 'lsd'
case 'SH':
return SHIELD_COMPONENT_TOKENS[rawCode] ?? 'kit'
}
}
function resolveWeaponClass(rhCode: number, lhCode: number): string {
const rh = rhCode !== 0xff && rhCode !== 0 ? WEAPON_COMPONENT_TABLE[rhCode] : undefined
const lh = lhCode !== 0xff && lhCode !== 0 ? WEAPON_COMPONENT_TABLE[lhCode] : undefined
if (!rh && !lh) return 'hth'
if (rh && lh) {
if (rh.wclass === 'ht1' && lh.wclass === 'ht1') return 'ht2'
if (rh.wclass === '1ht' && lh.wclass === '1ht') return '1ss'
if (rh.wclass === '1ht' || lh.wclass === '1ht') return '1js'
return '1hs'
}
return (rh ?? lh)!.wclass
}
function toStatstringBytes(
statstring: Uint8Array | string | undefined,
equipmentGraphics?: Uint8Array,
): Uint8Array {
if (statstring instanceof Uint8Array && statstring.length > 0) {
return statstring
}
if (typeof statstring === 'string' && statstring.length > 0) {
const out = new Uint8Array(statstring.length)
for (let i = 0; i < statstring.length; i++) {
out[i] = statstring.charCodeAt(i) & 0xff
}
return out
}
if (equipmentGraphics instanceof Uint8Array && equipmentGraphics.length > 0) {
const synthetic = new Uint8Array(33)
synthetic.fill(0xff)
synthetic[0] = 0x84
synthetic[1] = 0x80
for (let i = 0; i < Math.min(11, equipmentGraphics.length); i++) {
synthetic[2 + i] = equipmentGraphics[i]!
}
return synthetic
}
return new Uint8Array(0)
}
/**
* Parses the MCP `0x19` (`MCP_CHARLIST2`) 33-byte statstring into a 16-layer/11-component
* `TN` avatar descriptor (`D2Launch.dll` `0x6fa4a440`, `D2Multi.dll` `0x6f9d2940`).
*/
export function parseMcpAvatarStatstring(
statstring: Uint8Array | string | undefined,
classId: CampfireClassId = 'ba',
equipmentGraphics?: Uint8Array,
): ParsedMcpAvatarStatstring {
const bytes = toStatstringBytes(statstring, equipmentGraphics)
const rawComponentCodes = new Uint8Array(11)
rawComponentCodes.fill(0xff)
const rawComponentColors = new Uint8Array(11)
rawComponentColors.fill(0xff)
const hasHeader = bytes.length >= 2 && bytes[0] === 0x84 && bytes[1] === 0x80
const compOffset = hasHeader ? 2 : 0
for (let i = 0; i < 11; i++) {
if (compOffset + i < bytes.length) {
rawComponentCodes[i] = bytes[compOffset + i]!
} else if (equipmentGraphics && i < equipmentGraphics.length) {
rawComponentCodes[i] = equipmentGraphics[i]!
}
}
const colorOffset = hasHeader ? 14 : 12
if (bytes.length >= colorOffset + 11) {
for (let i = 0; i < 11; i++) {
rawComponentColors[i] = bytes[colorOffset + i]!
}
}
let hasEquippedComponents = false
const componentTokens = {} as Record<AvatarComponentName, string>
const componentColors = {} as Record<AvatarComponentName, number>
const equippedLayers: AvatarComponentLayerSpec[] = []
for (let i = 0; i < AVATAR_COMPONENT_NAMES.length; i++) {
const comp = AVATAR_COMPONENT_NAMES[i]!
const code = rawComponentCodes[i]!
const colorIdx = rawComponentColors[i]!
const token = resolveComponentToken(comp, code)
componentTokens[comp] = token
componentColors[comp] = colorIdx
const isEquippedCode = code !== 0xff && code !== 0
if (isEquippedCode) {
hasEquippedComponents = true
const paletteEntry =
colorIdx !== 0xff && colorIdx >= 0 && colorIdx < D2_COLORS_TXT_PALETTE.length
? D2_COLORS_TXT_PALETTE[colorIdx]!
: null
equippedLayers.push({
component: comp,
rawCode: code,
token,
colorIndex: colorIdx,
colorCode: paletteEntry ? paletteEntry.code : null,
rgb: paletteEntry ? paletteEntry.rgb : null,
})
}
}
const weaponClass = resolveWeaponClass(rawComponentCodes[5]!, rawComponentCodes[6]!)
const classToken = CLASS_ID_TO_TOKEN[classId] ?? 'ba'
const cofSignature = `${classToken}:TN:${weaponClass}:${AVATAR_COMPONENT_NAMES.map(
(c) => `${c}=${componentTokens[c]}@${componentColors[c]!.toString(16)}`,
).join(',')}`
return {
classToken,
mode: 'TN',
weaponClass,
hasEquippedComponents,
rawComponentCodes,
rawComponentColors,
components: componentTokens,
componentTokens,
componentColors,
equippedLayers,
cofSignature,
}
}

View File

@ -0,0 +1,665 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
import {
AVATAR_COMPONENT_NAMES,
CAMPFIRE_CLASS_SPECS,
CAMPFIRE_FLAME_CONTROL_SPEC,
D2LAUNCH_CAMPFIRE_DESCRIPTORS,
D2LAUNCH_CONTROL_RECORDS,
D2MULTI_CONTROL_RECORDS,
D2_COLORS_TXT_PALETTE,
canAcceptCharNameChar,
charSummaryToRosterCharacter,
clickBattleNetButton,
completeBnetConnect,
computeCharacterExpirationDays,
countHyphenUnderscore,
createInitialFlowState,
enterCharCreate,
filterCharNameInput,
formatCharacterExpirationText,
formatCharacterLevelAndClass,
getBnetConnectStageTblId,
getCampfireSlotHitBox,
hoverCampfireClass,
isValidCharacterName,
parseMcpAvatarStatstring,
resolveClassNameByTbl,
resolveStringByTblId,
selectCampfireClass,
setBnetConnectStage,
stepFlowTick,
toggleChatGem,
} from '../../src/client/frontend/flow-state.ts'
import {
CAMPFIRE_OVERLAY_SPECS,
FrontendRenderer,
type FrontendManifest,
} from '../../src/client/frontend/frontend-renderer.ts'
const ROOT_DIR = resolve(import.meta.dirname, '../..')
const FRONTEND_DIR = resolve(ROOT_DIR, 'public/ui/frontend')
const MANIFEST_PATH = resolve(FRONTEND_DIR, 'manifest.json')
interface DrawImageCall {
readonly img: unknown
readonly args: readonly number[]
readonly compositeOp: string
readonly alpha: number
readonly filter: string
}
interface FillRectCall {
readonly x: number
readonly y: number
readonly w: number
readonly h: number
readonly fillStyle: string
}
function createRecordingRenderer(manifest: FrontendManifest) {
const drawCalls: DrawImageCall[] = []
const fillCalls: FillRectCall[] = []
const stateStack: Array<{ compositeOp: string; alpha: number; filter: string; fillStyle: string }> = []
const ctx = {
imageSmoothingEnabled: false,
globalCompositeOperation: 'source-over',
globalAlpha: 1,
filter: 'none',
fillStyle: '#000000',
strokeStyle: '#000000',
lineWidth: 1,
save() {
stateStack.push({
compositeOp: this.globalCompositeOperation,
alpha: this.globalAlpha,
filter: this.filter,
fillStyle: this.fillStyle,
})
},
restore() {
const prev = stateStack.pop()
if (prev) {
this.globalCompositeOperation = prev.compositeOp
this.globalAlpha = prev.alpha
this.filter = prev.filter
this.fillStyle = prev.fillStyle
}
},
clearRect() {},
fillRect(x: number, y: number, w: number, h: number) {
fillCalls.push({ x, y, w, h, fillStyle: String(this.fillStyle) })
},
strokeRect() {},
beginPath() {},
moveTo() {},
lineTo() {},
stroke() {},
rect() {},
clip() {},
drawImage(img: unknown, ...args: number[]) {
drawCalls.push({
img,
args,
compositeOp: this.globalCompositeOperation,
alpha: this.globalAlpha,
filter: this.filter,
})
},
}
const fakeCanvas = {
width: 800,
height: 600,
style: { cursor: 'none' },
getContext: () => ctx,
getBoundingClientRect: () => ({ left: 0, top: 0, width: 800, height: 600 }),
} as unknown as HTMLCanvasElement
const state = createInitialFlowState()
const renderer = new FrontendRenderer(fakeCanvas, state)
renderer.manifest = manifest
renderer.ready = true
const stubImg = (id: string) => ({ __id: id, width: 800, height: 600 }) as unknown as HTMLImageElement
for (const key of [
'bg-main-menu.png',
'bg-char-create.png',
'bg-char-select.png',
'bg-waiting-room.png',
'popup-340x224.png',
'popup-okcancel.png',
'terms-of-use-bg.png',
'scrollbar.png',
'btn-popup-small.png',
'create-game-bg.png',
'charselectbox.png',
'realmselect.png',
'btn-realm-thin.png',
'btn-wide.png',
'btn-3wide.png',
'btn-wide02.png',
'btn-medium.png',
'btn-short.png',
'btn-tall.png',
'textbox.png',
'clickbox.png',
'joingameclickbox.png',
'radiobutton.png',
'gembutton.png',
'btn-chatleft.png',
'btn-chatrighttop.png',
'btn-chatright.png',
'btn-game.png',
'btn-create-cancel.png',
'btn-create-game.png',
'grey-level-box.png',
'cursor-hand.png',
]) {
renderer.images.set(key, stubImg(key))
}
for (const strip of Object.values(manifest.animStrips)) {
renderer.images.set(strip.file, stubImg(strip.file))
}
for (const classStrips of Object.values(manifest.campfireClasses)) {
for (const strip of Object.values(classStrips)) {
if (strip) renderer.images.set(strip.file, stubImg(strip.file))
}
}
for (const strip of Object.values(manifest.charAvatars)) {
renderer.images.set(strip.file, stubImg(strip.file))
}
const glyphs = Array.from({ length: 256 }, (_, code) => ({
code,
width: 8,
height: 14,
x: (code % 16) * 16,
y: Math.floor(code / 16) * 16,
frameW: 8,
frameH: 14,
}))
for (const fontName of [
'font6',
'font8',
'font16',
'font24',
'font30',
'font42',
'fontformal10',
'fontformal11',
'fontformal12',
'fontexocet8',
'fontexocet10',
'fontridiculous',
]) {
renderer.fontMetas[fontName] = {
name: fontName,
lineHeight: 14,
capHeight: 12,
atlasWidth: 256,
atlasHeight: 256,
glyphs,
}
for (const colorIdx of [0, 1, 2, 3, 4, 5, 7, 8, 9]) {
renderer.images.set(`fonts/${fontName}-c${colorIdx}.png`, stubImg(`fonts/${fontName}-c${colorIdx}.png`))
}
}
return { renderer, state, ctx, drawCalls, fillCalls }
}
describe('Batch 7 & 8 Phase 3 — Frontend Renderer & Flow State Parity (#696–#701, #703–#711)', () => {
const manifest = JSON.parse(readFileSync(MANIFEST_PATH, 'utf8')) as FrontendManifest
const stringsRecord = manifest.strings as unknown as Record<string, unknown>
it('#696: parses MCP 0x19 statstring 11 equipment components + Colors.txt dye bytes and composites equipped TN avatar on CharSelect & Lobby', () => {
expect(AVATAR_COMPONENT_NAMES).toHaveLength(11)
expect(Object.keys(D2_COLORS_TXT_PALETTE)).toHaveLength(21)
// 1. All 0xFF statstring -> unequipped base TN avatar
const unequippedBytes = new Uint8Array(33).fill(0xff)
unequippedBytes[0] = 0x84
unequippedBytes[1] = 0x80
unequippedBytes[13] = 0x02 // Sorceress
unequippedBytes[25] = 1
unequippedBytes[26] = 0xa0
unequippedBytes[27] = 0x80
const unequippedDesc = parseMcpAvatarStatstring(unequippedBytes, 'so')
expect(unequippedDesc.hasEquippedComponents).toBe(false)
expect(unequippedDesc.equippedLayers).toHaveLength(0)
expect(unequippedDesc.weaponClass).toBe('hth')
// 2. Equipped statstring with heavy helm/torso/legs, 1hs right-hand weapon, shield, and cblu/cred dyes
const equippedBytes = new Uint8Array(33).fill(0xff)
equippedBytes[0] = 0x84
equippedBytes[1] = 0x80
equippedBytes[2] = 3 // HD -> hvy
equippedBytes[3] = 3 // TR -> hvy
equippedBytes[4] = 2 // LG -> med
equippedBytes[5] = 3 // RA -> hvy
equippedBytes[6] = 3 // LA -> hvy
equippedBytes[7] = 22 // RH -> fla (1hs weapon)
equippedBytes[9] = 2 // SH -> shield
equippedBytes[13] = 0x04 // Paladin
equippedBytes[14] = 6 // HD dye -> cblu (Crystal Blue)
equippedBytes[15] = 9 // TR dye -> cred (Crystal Red)
equippedBytes[25] = 85
equippedBytes[26] = 0xe0
equippedBytes[27] = 0x80 | 10
const equippedDesc = parseMcpAvatarStatstring(equippedBytes, 'pa')
expect(equippedDesc.hasEquippedComponents).toBe(true)
expect(equippedDesc.components.HD).toBe('hvy')
expect(equippedDesc.components.TR).toBe('hvy')
expect(equippedDesc.components.LG).toBe('med')
expect(equippedDesc.componentColors.HD).toBe(6)
expect(equippedDesc.componentColors.TR).toBe(9)
expect(equippedDesc.weaponClass).toBe('1hs')
const hdLayer = equippedDesc.equippedLayers.find((l) => l.component === 'HD')
const trLayer = equippedDesc.equippedLayers.find((l) => l.component === 'TR')
expect(hdLayer?.colorCode).toBe('cblu')
expect(hdLayer?.rgb).toEqual(D2_COLORS_TXT_PALETTE[6]!.rgb)
expect(trLayer?.colorCode).toBe('cred')
expect(trLayer?.rgb).toEqual(D2_COLORS_TXT_PALETTE[9]!.rgb)
const rosterChar = charSummaryToRosterCharacter({
name: 'Hammerdin',
classId: 3, // Paladin
level: 85,
progression: 10,
expansion: true,
hardcore: false,
statstring: equippedBytes,
})
expect(rosterChar.avatarDesc?.hasEquippedComponents).toBe(true)
expect(rosterChar.components?.HD).toBe('hvy')
expect(rosterChar.componentColors?.HD).toBe(6)
expect(rosterChar.weaponClass).toBe('1hs')
// Verify rendering on char_select draws equipment dye tint layers for equipped character
const { renderer, state, fillCalls } = createRecordingRenderer(manifest)
state.stage = 'char_select'
state.roster = [rosterChar]
state.selectedSlotIndex = 0
renderer.render(100)
const cbluFill = `rgba(${D2_COLORS_TXT_PALETTE[6]!.rgb.join(', ')}, 0.38)`
const credFill = `rgba(${D2_COLORS_TXT_PALETTE[9]!.rgb.join(', ')}, 0.38)`
expect(fillCalls.some((f) => f.fillStyle === cbluFill)).toBe(true)
expect(fillCalls.some((f) => f.fillStyle === credFill)).toBe(true)
// Verify rendering on bnet_lobby bottom bar also composites equipped layers
fillCalls.length = 0
state.stage = 'bnet_lobby'
renderer.render(140)
expect(fillCalls.some((f) => f.fillStyle === cbluFill)).toBe(true)
expect(fillCalls.some((f) => f.fillStyle === credFill)).toBe(true)
})
it('#697: enforces D2Launch 0x6fa52360 charName input filter and 0x6fa54710 OK button enablement without breaking gameName/gamePassword', () => {
// 1. Input filter @ 0x6fa52360
expect(canAcceptCharNameChar('', 'A')).toBe(true)
expect(canAcceptCharNameChar('', 'z')).toBe(true)
expect(canAcceptCharNameChar('', '-')).toBe(false) // '-' rejected at pos 0
expect(canAcceptCharNameChar('', '_')).toBe(false) // '_' rejected at pos 0
expect(canAcceptCharNameChar('', '1')).toBe(false) // digits rejected
expect(canAcceptCharNameChar('Hero', '-')).toBe(true) // first '-' allowed after pos 0
expect(canAcceptCharNameChar('Hero-', '-')).toBe(false) // second '-' rejected
expect(canAcceptCharNameChar('Hero-', '_')).toBe(false) // '_' rejected when '-' already present
expect(canAcceptCharNameChar('Hero_One', '-')).toBe(false)
expect(countHyphenUnderscore('Hero_One')).toBe(1)
expect(filterCharNameInput('-123Hero_One_Two456-')).toBe('Hero_OneTwo')
// 2. OK enablement @ 0x6fa54710
expect(isValidCharacterName('')).toBe(false)
expect(isValidCharacterName('A')).toBe(false)
expect(isValidCharacterName('Ab')).toBe(true)
expect(isValidCharacterName('A-b')).toBe(true)
expect(isValidCharacterName('A_b')).toBe(true)
expect(isValidCharacterName('-Ab')).toBe(false)
expect(isValidCharacterName('Ab-')).toBe(false)
expect(isValidCharacterName('Ab_')).toBe(false)
expect(isValidCharacterName('A-b-c')).toBe(false)
expect(isValidCharacterName('A_b-c')).toBe(false)
expect(isValidCharacterName('Abc1')).toBe(false)
expect(isValidCharacterName('FifteenLettersX')).toBe(true)
expect(isValidCharacterName('SixteenLettersXX')).toBe(false)
// 3. Campfire OK button disabled state in FrontendRenderer
const { renderer, state } = createRecordingRenderer(manifest)
enterCharCreate(state)
selectCampfireClass(state, 'am')
state.charName = 'A' // too short -> OK disabled
const buttonsShort = renderer.getActiveButtons()
expect(buttonsShort.find((b) => b.id === 'char_create_ok')?.disabled).toBe(true)
state.charName = 'Am-' // trailing hyphen -> OK disabled
const buttonsTrailing = renderer.getActiveButtons()
expect(buttonsTrailing.find((b) => b.id === 'char_create_ok')?.disabled).toBe(true)
state.charName = 'Amazon_Queen' // valid -> OK enabled
const buttonsValid = renderer.getActiveButtons()
expect(buttonsValid.find((b) => b.id === 'char_create_ok')?.disabled).toBe(false)
// 4. Typing in charName filters digits, while gameName/gamePassword in bnet_lobby accept digits
state.charName = ''
state.activeInput = 'charName'
for (const k of ['A', '1', '-', 'b', '_', 'c']) {
renderer.handleKeyDown({ key: k, preventDefault() {} } as unknown as KeyboardEvent)
}
expect(state.charName).toBe('A-bc')
state.stage = 'bnet_lobby'
state.lobbySubPanel = 'create'
state.activeInput = 'gameName'
state.gameName = ''
for (const k of ['B', 'a', 'a', 'l', '-', '0', '1']) {
renderer.handleKeyDown({ key: k, preventDefault() {} } as unknown as KeyboardEvent)
}
expect(state.gameName).toBe('Baal-01')
})
it('#698: computes character expiration days per D2Launch 0x6fa4b273 and formats .tbl 11132 / 11133 / 11134 only when days <= 60', () => {
const nowSec = 1_700_000_000
expect(computeCharacterExpirationDays({ expiresAtSec: nowSec + 90 * 86400 }, nowSec)).toBe(90)
expect(computeCharacterExpirationDays({ expiresAtSec: nowSec + 60 * 86400 }, nowSec)).toBe(60)
expect(computeCharacterExpirationDays({ expiresAtSec: nowSec + 86400 }, nowSec)).toBe(1)
expect(computeCharacterExpirationDays({ expiresAtSec: nowSec - 10 }, nowSec)).toBe(0)
// > 60 days or undefined -> null (nothing rendered, never "Expires in 90 days")
expect(formatCharacterExpirationText(undefined, stringsRecord)).toBeNull()
expect(formatCharacterExpirationText(90, stringsRecord)).toBeNull()
expect(formatCharacterExpirationText(61, stringsRecord)).toBeNull()
// 2..60 days -> .tbl 11133 "Expires in %d days"
expect(formatCharacterExpirationText(60, stringsRecord)).toEqual({
text: 'Expires in 60 days',
tblId: 11133,
isUrgent: false,
})
expect(formatCharacterExpirationText(10, stringsRecord)?.text).toBe('Expires in 10 days')
expect(formatCharacterExpirationText(2, stringsRecord)?.text).toBe('Expires in 2 days')
// 1 day -> .tbl 11132 "Expires Today"
expect(formatCharacterExpirationText(1, stringsRecord)).toEqual({
text: 'Expires Today',
tblId: 11132,
isUrgent: true,
})
// <= 0 days -> .tbl 11134 "Character Expired"
expect(formatCharacterExpirationText(0, stringsRecord)).toEqual({
text: 'Character Expired',
tblId: 11134,
isUrgent: true,
})
expect(formatCharacterExpirationText(-5, stringsRecord)?.text).toBe('Character Expired')
})
it('#699: removes fake 18-tick bnet_connecting timer, drives single status text via .tbl 5171..5177, and disables unimplemented Main Menu buttons', () => {
const { renderer, state } = createRecordingRenderer(manifest)
// 1. Main menu disables Single Player, Other Multiplayer, Credits, Cinematics, Exit; only Battle.net & Gateway are enabled
state.stage = 'main_menu'
renderer.render(100)
const menuBtns = renderer.getActiveButtons()
expect(menuBtns.find((b) => b.id === 'single_player')?.disabled).toBe(true)
expect(menuBtns.find((b) => b.id === 'other_multiplayer')?.disabled).toBe(true)
expect(menuBtns.find((b) => b.id === 'credits')?.disabled).toBe(true)
expect(menuBtns.find((b) => b.id === 'cinematics')?.disabled).toBe(true)
expect(menuBtns.find((b) => b.id === 'exit_d2')?.disabled).toBe(true)
expect(menuBtns.find((b) => b.id === 'battle_net')?.disabled).toBeFalsy()
// 2. Clicking Battle.net enters bnet_connecting and stays there across >18 ticks until completeBnetConnect
clickBattleNetButton(state, stringsRecord)
expect(state.stage).toBe('bnet_connecting')
expect(state.bnetConnectStage).toBe(0)
expect(getBnetConnectStageTblId(0)).toBe(5171)
for (let i = 0; i < 60; i++) {
stepFlowTick(state)
}
expect(state.stage).toBe('bnet_connecting')
// Verify all 7 BNCS connection stages 0..6 map to .tbl 5171..5177
const expectedIds = [5171, 5172, 5173, 5174, 5175, 5176, 5177] as const
for (let stageIdx = 0; stageIdx <= 6; stageIdx++) {
setBnetConnectStage(state, stageIdx as 0 | 1 | 2 | 3 | 4 | 5 | 6, stringsRecord)
expect(getBnetConnectStageTblId(state.bnetConnectStage)).toBe(expectedIds[stageIdx])
}
completeBnetConnect(state)
expect(state.stage).toBe('bnet_login')
})
it('#700: renders Battle.net Login title (.tbl 5204) and instruction (.tbl 5205) at D2Launch control record 0x6fa61520', () => {
expect(D2LAUNCH_CONTROL_RECORDS.bnetLoginHeader.va).toBe(0x6fa61520)
expect(D2LAUNCH_CONTROL_RECORDS.bnetLoginHeader.title.tblId).toBe(5204)
expect(D2LAUNCH_CONTROL_RECORDS.bnetLoginHeader.instruction.tblId).toBe(5205)
expect(resolveStringByTblId(stringsRecord, 5204, 'loginTitle')).toBe('Battle.net Log In')
expect(resolveStringByTblId(stringsRecord, 5205, 'loginInstruction')).toBe(
"To use an existing Battle.net account, enter the information below and click 'Log In'. Otherwise, click 'Create New Account'.",
)
})
it('#701: uses Expansion campfire flame control anchor (345, 470) -> draw (379, 338) with drawMode 3', () => {
expect(CAMPFIRE_FLAME_CONTROL_SPEC).toEqual({
va: 0x6fa63080,
x: 345,
y: 470,
w: 110,
h: 127,
speed: 1,
drawMode: 3,
})
const { renderer, state, drawCalls } = createRecordingRenderer(manifest)
enterCharCreate(state)
renderer.render(100)
const flameImg = renderer.images.get(manifest.animStrips.campfireFlame!.file)
const flameDraw = drawCalls.find((c) => c.img === flameImg)
expect(flameDraw).toBeDefined()
const flameFrame0 = manifest.animStrips.campfireFlame!.frames[0]!
expect(flameDraw!.args[4]).toBe(379 + flameFrame0.anchorX)
expect(flameDraw!.args[5]).toBe(338 + flameFrame0.anchorY)
expect(flameDraw!.compositeOp).toBe('screen')
})
it('#702: renders campfire overlays using manifest drawMode (5 = source-over for ba/pa fw_ov, 3 = screen for ne/so) and fails fast on missing strip', () => {
expect(CAMPFIRE_OVERLAY_SPECS.ba?.fw?.drawMode).toBe(5)
expect(CAMPFIRE_OVERLAY_SPECS.pa?.fw?.drawMode).toBe(5)
expect(CAMPFIRE_OVERLAY_SPECS.ne?.fw?.drawMode).toBe(3)
expect(CAMPFIRE_OVERLAY_SPECS.so?.fw?.drawMode).toBe(3)
const { renderer, state, drawCalls } = createRecordingRenderer(manifest)
enterCharCreate(state)
// 1. Select Barbarian ('ba') -> phase 'fw', overlay 'ba-fw-ov.png' drawn with drawMode 5 ('source-over')
selectCampfireClass(state, 'ba')
drawCalls.length = 0
renderer.render(100)
const baFwOvImg = renderer.images.get(manifest.campfireClasses.ba!.fw_ov!.file)
const baOvDraw = drawCalls.find((c) => c.img === baFwOvImg)
expect(baOvDraw).toBeDefined()
expect(baOvDraw!.compositeOp).toBe('source-over')
// 2. Select Sorceress ('so') -> phase 'fw', overlay 'so-fw-ov.png' drawn with drawMode 3 ('screen')
selectCampfireClass(state, 'so')
drawCalls.length = 0
renderer.render(140)
const soFwOvImg = renderer.images.get(manifest.campfireClasses.so!.fw_ov!.file)
const soOvDraw = drawCalls.find((c) => c.img === soFwOvImg)
expect(soOvDraw).toBeDefined()
expect(soOvDraw!.compositeOp).toBe('screen')
// 3. Missing required strip image throws explicit Error instead of silently skipping (#702, #709)
renderer.images.delete(manifest.campfireClasses.so!.fw_ov!.file)
expect(() => renderer.render(180)).toThrow(/Missing loaded image for animation strip/)
})
it('#703: implements D2WinAnimImage frame-0 cel bounds hit-test (0x6f8f07c0), continuous hover toggle (0x6f8f0966), and fw/bw end at count - 2 (0x6f8f09d7)', () => {
const state = createInitialFlowState()
enterCharCreate(state)
// 1. Hover toggle between nu1 and nu2 preserves frameIndex and phaseTicks
for (let i = 0; i < 15; i++) {
stepFlowTick(state)
}
const beforeHoverFrame = state.campfireClasses.am.frameIndex
const beforeHoverTicks = state.campfireClasses.am.phaseTicks
expect(beforeHoverFrame).toBeGreaterThan(0)
hoverCampfireClass(state, 'am')
expect(state.campfireClasses.am.phase).toBe('nu2')
expect(state.campfireClasses.am.frameIndex).toBe(beforeHoverFrame)
expect(state.campfireClasses.am.phaseTicks).toBe(beforeHoverTicks)
hoverCampfireClass(state, null)
expect(state.campfireClasses.am.phase).toBe('nu1')
expect(state.campfireClasses.am.frameIndex).toBe(beforeHoverFrame)
// 2. Hit-box updates dynamically from active phase's frame-0 cel bounds (nu1 vs nu3)
const amSlot = state.campfireClasses.am
const nu1Box = getCampfireSlotHitBox(amSlot)
expect(nu1Box).toEqual({
x: amSlot.anchorX + D2LAUNCH_CAMPFIRE_DESCRIPTORS.am.frame0Bounds.nu1.anchorX,
y: amSlot.anchorY + D2LAUNCH_CAMPFIRE_DESCRIPTORS.am.frame0Bounds.nu1.anchorY,
w: D2LAUNCH_CAMPFIRE_DESCRIPTORS.am.frame0Bounds.nu1.w,
h: D2LAUNCH_CAMPFIRE_DESCRIPTORS.am.frame0Bounds.nu1.h,
})
selectCampfireClass(state, 'am')
const fwFrames = amSlot.frameCount.fw
// Step to fwCount - 3 -> still in 'fw'
for (let i = 0; i < (fwFrames - 2) * amSlot.frameRate.fw - 1; i++) {
stepFlowTick(state)
}
expect(amSlot.phase).toBe('fw')
// One more tick reaches frameIndex === fwCount - 2 -> transitions to 'nu3' (last 2 frames not displayed)
stepFlowTick(state)
expect(amSlot.phase).toBe('nu3')
const nu3Box = getCampfireSlotHitBox(amSlot)
expect(nu3Box.x).not.toBe(nu1Box.x)
expect(nu3Box.y).not.toBe(nu1Box.y)
})
it('#704: removes hardcoded mock lobby chat/channel/gateway strings and uses structured .tbl 5334/5335/5336 gem messages', () => {
const state = createInitialFlowState()
expect(state.chatMessages).toEqual([])
expect(state.chatEntries).toEqual([])
expect(state.channelName).toBe('')
expect(state.gateway).toBe('')
state.stage = 'bnet_lobby'
const actEntry = toggleChatGem(state, stringsRecord)
expect(actEntry).toEqual({ text: 'Gem Activated', color: 3, kind: 'gem', tblId: 5334 })
expect(state.chatMessages).toEqual(['Gem Activated'])
const deactEntry = toggleChatGem(state, stringsRecord)
expect(deactEntry).toEqual({ text: 'Gem Deactivated', color: 3, kind: 'gem', tblId: 5335 })
const perfEntry = toggleChatGem(state, stringsRecord, true)
expect(perfEntry).toEqual({ text: 'Perfect Gem Activated', color: 3, kind: 'gem', tblId: 5336 })
})
it('#705: renders D2WinEditBox underscore "_" cursor at 250ms blink period in edit box text color', () => {
const { renderer, state, drawCalls } = createRecordingRenderer(manifest)
state.stage = 'bnet_login'
state.activeInput = 'accountName'
state.accountName = 'Tyrael'
// At nowMs = 0 -> Math.floor(0 / 250) & 1 === 0 (cursor off)
drawCalls.length = 0
renderer.render(0)
const callsOff = drawCalls.length
// At nowMs = 250 -> Math.floor(250 / 250) & 1 === 1 (cursor on -> draws "_" glyph via drawBitmapText, 1 extra drawImage call)
drawCalls.length = 0
renderer.render(250)
const callsOn = drawCalls.length
expect(callsOn).toBe(callsOff + 1)
// Verify "_" glyph (charCode 95) was drawn from the editbox font atlas
const underscoreGlyph = renderer.fontMetas.fontformal12!.glyphs[95]!
expect(
drawCalls.some((c) => c.args[0] === underscoreGlyph.x && c.args[1] === underscoreGlyph.y),
).toBe(true)
})
it('#706: renders disabled buttons and radio buttons with DRAWMODE_TRANS50 (globalAlpha = 0.5) instead of CSS brightness filter', () => {
const { renderer, state, drawCalls } = createRecordingRenderer(manifest)
state.stage = 'main_menu'
renderer.render(100)
// Main menu has 5 disabled buttons -> drawn with alpha = 0.5 and filter = 'none'
const trans50Draws = drawCalls.filter((c) => c.alpha === 0.5)
expect(trans50Draws.length).toBeGreaterThanOrEqual(5)
expect(drawCalls.every((c) => !c.filter.includes('brightness'))).toBe(true)
})
it('#707 & #708 & #711: resolves canonical .tbl strings for Create Account, Please Read, Level/Class, and fails fast when manifest strings are missing', () => {
expect(D2MULTI_CONTROL_RECORDS.lobbyAvatar).toEqual({
va: 0x6f9d2940,
anchorX: 52,
anchorY: 584,
})
expect(resolveClassNameByTbl('ba', stringsRecord)).toBe('Barbarian')
expect(resolveClassNameByTbl('so', stringsRecord)).toBe('Sorceress')
expect(formatCharacterLevelAndClass(42, 'pa', stringsRecord)).toBe('Level 42 Paladin')
// Missing manifest string key must throw explicit Error instead of returning hardcoded English fallback
expect(() => resolveStringByTblId({}, 5223, 'createAccountInstruction')).toThrow(
/Missing required \.tbl string #5223/,
)
expect(() => resolveStringByTblId({}, 5017)).toThrow(/Missing required \.tbl string #5017/)
})
it('#709 & #710: removes CAMPFIRE_CLASS_SPECS defaultFrames guessing, fails fast on missing font/strip, and rejects missing progression/level in charSummaryToRosterCharacter', () => {
// #709: CAMPFIRE_CLASS_SPECS matches manifest/D2Launch descriptors (no stale am.fw=28 / am.nu2=56)
expect(CAMPFIRE_CLASS_SPECS.am.frameCount.fw).toBe(54)
expect(CAMPFIRE_CLASS_SPECS.am.frameCount.nu2).toBe(26)
expect(CAMPFIRE_CLASS_SPECS.so.frameCount.fw).toBe(52)
const { renderer } = createRecordingRenderer(manifest)
delete renderer.fontMetas.font16
expect(() => renderer.measureBitmapText('Hello', 'font16')).toThrow(
/Missing bitmap font metrics for "font16"/,
)
// #710: charSummaryToRosterCharacter throws when progression or valid level is missing
expect(() =>
charSummaryToRosterCharacter({
name: 'NoProgHero',
classId: 0,
level: 10,
}),
).toThrow(/missing required numeric progression/)
expect(() =>
charSummaryToRosterCharacter({
name: 'ZeroLevelHero',
classId: 0,
level: 0,
progression: 0,
}),
).toThrow(/invalid or missing level/)
// Valid character summary succeeds without guessing
const validRoster = charSummaryToRosterCharacter({
name: 'ValidAmz',
classId: 0,
level: 24,
progression: 5,
expansion: true,
})
expect(validRoster.level).toBe(24)
expect(validRoster.progression).toBe(5)
})
})

View File

@ -12,6 +12,7 @@ import {
cancelConnecting, cancelConnecting,
charSummaryToRosterCharacter, charSummaryToRosterCharacter,
clickBattleNetButton, clickBattleNetButton,
completeBnetConnect,
confirmCharacterSelect, confirmCharacterSelect,
createInitialFlowState, createInitialFlowState,
enterCharCreate, enterCharCreate,
@ -38,6 +39,8 @@ describe('Diablo II v1.13c Front-End & Battle.net Flow State Machine', () => {
expect(isValidCharacterName('A')).toBe(false) expect(isValidCharacterName('A')).toBe(false)
expect(isValidCharacterName('Isendra')).toBe(true) expect(isValidCharacterName('Isendra')).toBe(true)
expect(isValidCharacterName('Dark_Wanderer')).toBe(true) expect(isValidCharacterName('Dark_Wanderer')).toBe(true)
expect(isValidCharacterName('Two_Under_Score')).toBe(false)
expect(isValidCharacterName('NoDigits1')).toBe(false)
expect(isValidCharacterName('-Invalid')).toBe(false) expect(isValidCharacterName('-Invalid')).toBe(false)
expect(isValidCharacterName('Invalid-')).toBe(false) expect(isValidCharacterName('Invalid-')).toBe(false)
expect(isValidCharacterName('In--valid')).toBe(false) expect(isValidCharacterName('In--valid')).toBe(false)
@ -59,15 +62,17 @@ describe('Diablo II v1.13c Front-End & Battle.net Flow State Machine', () => {
cancelConnecting(state) cancelConnecting(state)
expect(state.stage).toBe('main_menu') expect(state.stage).toBe('main_menu')
// Re-click Battle.net and advance 18 ticks to Login Screen // Re-click Battle.net: stays on bnet_connecting across ticks until BNCS handshake completes (#699)
clickBattleNetButton(state) clickBattleNetButton(state)
for (let i = 0; i < 18; i++) { for (let i = 0; i < 25; i++) {
stepFlowTick(state) stepFlowTick(state)
} }
expect(state.stage).toBe('bnet_connecting')
completeBnetConnect(state)
expect(state.stage).toBe('bnet_login') expect(state.stage).toBe('bnet_login')
expect(state.activeInput).toBe('accountName') expect(state.activeInput).toBe('accountName')
// Stage 2: Mock authentication accepts ANY username & password // Stage 2: Authentication transitions to char_create when roster is empty
state.accountName = 'any_user_123' state.accountName = 'any_user_123'
state.accountPassword = 'any_password_456' state.accountPassword = 'any_password_456'
expect(submitBattleNetLogin(state)).toBe(true) expect(submitBattleNetLogin(state)).toBe(true)
@ -79,26 +84,26 @@ describe('Diablo II v1.13c Front-End & Battle.net Flow State Machine', () => {
hoverCampfireClass(state, 'am') hoverCampfireClass(state, 'am')
expect(state.campfireClasses.am.phase).toBe('nu2') expect(state.campfireClasses.am.phase).toBe('nu2')
// Click Sorceress ('so') -> walks forward ('fw' -> 'nu3') // Click Sorceress ('so') -> walks forward ('fw' -> 'nu3', ending at fwCount - 2 per 0x6f8f09d7)
selectCampfireClass(state, 'so') selectCampfireClass(state, 'so')
expect(state.selectedClass).toBe('so') expect(state.selectedClass).toBe('so')
expect(state.campfireClasses.so.phase).toBe('fw') expect(state.campfireClasses.so.phase).toBe('fw')
expect(state.activeInput).toBe('charName') expect(state.activeInput).toBe('charName')
const fwCount = state.campfireClasses.so.frameCount.fw const fwEnd = state.campfireClasses.so.frameCount.fw - 2
for (let i = 0; i < fwCount; i++) { for (let i = 0; i < fwEnd; i++) {
stepFlowTick(state) stepFlowTick(state)
} }
expect(state.campfireClasses.so.phase).toBe('nu3') expect(state.campfireClasses.so.phase).toBe('nu3')
// Switching to Necromancer ('ne') causes Sorceress to walk backward ('bw' -> 'nu1') // Switching to Necromancer ('ne') causes Sorceress to walk backward ('bw' -> 'nu1', ending at bwCount - 2)
selectCampfireClass(state, 'ne') selectCampfireClass(state, 'ne')
expect(state.selectedClass).toBe('ne') expect(state.selectedClass).toBe('ne')
expect(state.campfireClasses.so.phase).toBe('bw') expect(state.campfireClasses.so.phase).toBe('bw')
expect(state.campfireClasses.ne.phase).toBe('fw') expect(state.campfireClasses.ne.phase).toBe('fw')
const bwCount = state.campfireClasses.so.frameCount.bw const bwEnd = state.campfireClasses.so.frameCount.bw - 2
for (let i = 0; i < bwCount; i++) { for (let i = 0; i < bwEnd; i++) {
stepFlowTick(state) stepFlowTick(state)
} }
expect(state.campfireClasses.so.phase).toBe('nu1') expect(state.campfireClasses.so.phase).toBe('nu1')
@ -414,36 +419,38 @@ describe('Diablo II v1.13c Front-End & Battle.net Flow State Machine', () => {
expect(state.campfireClasses.am.frameIndex).toBe(0) expect(state.campfireClasses.am.frameIndex).toBe(0)
// 2. Hover ('nu2') and Select ('fw' -> 'nu3'): // 2. Hover ('nu2') and Select ('fw' -> 'nu3'):
// At tick 75, Assassin ('as', nu1 rate=3, frameCount=31 -> loop 30) is at frame (75/3)%30 = 25;
// hoverCampfireClass preserves continuous frameIndex = 25 per D2Win.dll 0x6f8f0966 (#703)
hoverCampfireClass(state, 'as') hoverCampfireClass(state, 'as')
expect(state.campfireClasses.as.phase).toBe('nu2') expect(state.campfireClasses.as.phase).toBe('nu2')
expect(state.campfireClasses.as.frameIndex).toBe(0) expect(state.campfireClasses.as.frameIndex).toBe(25)
// Assassin nu2 has rate = 3 // Assassin nu2 has rate = 3
stepFlowTick(state) stepFlowTick(state)
stepFlowTick(state) stepFlowTick(state)
expect(state.campfireClasses.as.frameIndex).toBe(0) expect(state.campfireClasses.as.frameIndex).toBe(25)
stepFlowTick(state) stepFlowTick(state)
expect(state.campfireClasses.as.frameIndex).toBe(1) expect(state.campfireClasses.as.frameIndex).toBe(26)
// Select Paladin ('pa'): fw has rate = 1 (28 frames), then nu3 has rate = 2 (13 frames -> wraps at 12) // Select Paladin ('pa'): fw has rate = 1 (80 frames -> ends at 80 - 2 = 78 ticks), then nu3 has rate = 2 (9 frames -> wraps at 8)
selectCampfireClass(state, 'pa') selectCampfireClass(state, 'pa')
expect(state.campfireClasses.pa.phase).toBe('fw') expect(state.campfireClasses.pa.phase).toBe('fw')
for (let i = 0; i < 28; i++) { for (let i = 0; i < 78; i++) {
stepFlowTick(state) stepFlowTick(state)
} }
expect(state.campfireClasses.pa.phase).toBe('nu3') expect(state.campfireClasses.pa.phase).toBe('nu3')
expect(state.campfireClasses.pa.frameIndex).toBe(0) expect(state.campfireClasses.pa.frameIndex).toBe(0)
// Paladin nu3 advances 1 frame every 2 ticks and wraps at 13 - 1 = 12 // Paladin nu3 advances 1 frame every 2 ticks and wraps at 9 - 1 = 8
stepFlowTick(state) stepFlowTick(state)
expect(state.campfireClasses.pa.frameIndex).toBe(0) expect(state.campfireClasses.pa.frameIndex).toBe(0)
stepFlowTick(state) stepFlowTick(state)
expect(state.campfireClasses.pa.frameIndex).toBe(1) expect(state.campfireClasses.pa.frameIndex).toBe(1)
for (let i = 0; i < 20; i++) { for (let i = 0; i < 12; i++) {
stepFlowTick(state) stepFlowTick(state)
} }
expect(state.campfireClasses.pa.frameIndex).toBe(11) expect(state.campfireClasses.pa.frameIndex).toBe(7)
stepFlowTick(state) stepFlowTick(state)
expect(state.campfireClasses.pa.frameIndex).toBe(11) expect(state.campfireClasses.pa.frameIndex).toBe(7)
stepFlowTick(state) stepFlowTick(state)
expect(state.campfireClasses.pa.frameIndex).toBe(0) expect(state.campfireClasses.pa.frameIndex).toBe(0)
}) })
@ -539,7 +546,9 @@ describe('Diablo II v1.13c Front-End & Battle.net Flow State Machine', () => {
it('keeps public/ui/frontend/manifest.json and FrontendRenderer aligned on 1.13c rates and wrapped frame indices', () => { it('keeps public/ui/frontend/manifest.json and FrontendRenderer aligned on 1.13c rates and wrapped frame indices', () => {
const manifestPath = join(process.cwd(), 'public', 'ui', 'frontend', 'manifest.json') const manifestPath = join(process.cwd(), 'public', 'ui', 'frontend', 'manifest.json')
const metricsPath = join(process.cwd(), 'public', 'ui', 'frontend', 'fonts', 'metrics.json')
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as FrontendManifest const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as FrontendManifest
const fontMetas = JSON.parse(readFileSync(metricsPath, 'utf8'))
// 1. Verify manifest animStrips rates // 1. Verify manifest animStrips rates
expect(manifest.animStrips.logoMainBlack?.rate).toBe(1) expect(manifest.animStrips.logoMainBlack?.rate).toBe(1)
@ -592,6 +601,7 @@ describe('Diablo II v1.13c Front-End & Battle.net Flow State Machine', () => {
const renderer = new FrontendRenderer(fakeCanvas, state) const renderer = new FrontendRenderer(fakeCanvas, state)
renderer.manifest = manifest renderer.manifest = manifest
renderer.fontMetas = fontMetas
renderer.ready = true renderer.ready = true
for (const strip of Object.values(manifest.animStrips)) { for (const strip of Object.values(manifest.animStrips)) {
renderer.images.set(strip.file, { id: strip.file, width: strip.width, height: strip.height } as unknown as HTMLImageElement) renderer.images.set(strip.file, { id: strip.file, width: strip.width, height: strip.height } as unknown as HTMLImageElement)
@ -599,6 +609,11 @@ describe('Diablo II v1.13c Front-End & Battle.net Flow State Machine', () => {
for (const strip of Object.values(manifest.charAvatars)) { for (const strip of Object.values(manifest.charAvatars)) {
renderer.images.set(strip.file, { id: strip.file, width: strip.width, height: strip.height } as unknown as HTMLImageElement) renderer.images.set(strip.file, { id: strip.file, width: strip.width, height: strip.height } as unknown as HTMLImageElement)
} }
for (const fn of Object.keys(fontMetas)) {
for (const c of [0, 1, 2, 3, 4, 5, 7, 8, 9]) {
renderer.images.set(`fonts/${fn}-c${c}.png`, { id: `fonts/${fn}-c${c}.png`, width: 256, height: 256 } as unknown as HTMLImageElement)
}
}
// At globalTick = 29, 30-frame main menu logo wraps to frame 0 (not frame 29) // At globalTick = 29, 30-frame main menu logo wraps to frame 0 (not frame 29)
state.stage = 'main_menu' state.stage = 'main_menu'