feat(frontend): implement 1.13c D2Launch/D2Multi/D2Win frontend renderer & flow state parity (#696-#701, #703-#711)
This commit is contained in:
parent
f209f4e6e2
commit
948a586432
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -12,6 +12,7 @@ import {
|
|||
cancelConnecting,
|
||||
charSummaryToRosterCharacter,
|
||||
clickBattleNetButton,
|
||||
completeBnetConnect,
|
||||
confirmCharacterSelect,
|
||||
createInitialFlowState,
|
||||
enterCharCreate,
|
||||
|
|
@ -38,6 +39,8 @@ describe('Diablo II v1.13c Front-End & Battle.net Flow State Machine', () => {
|
|||
expect(isValidCharacterName('A')).toBe(false)
|
||||
expect(isValidCharacterName('Isendra')).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('In--valid')).toBe(false)
|
||||
|
|
@ -59,15 +62,17 @@ describe('Diablo II v1.13c Front-End & Battle.net Flow State Machine', () => {
|
|||
cancelConnecting(state)
|
||||
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)
|
||||
for (let i = 0; i < 18; i++) {
|
||||
for (let i = 0; i < 25; i++) {
|
||||
stepFlowTick(state)
|
||||
}
|
||||
expect(state.stage).toBe('bnet_connecting')
|
||||
completeBnetConnect(state)
|
||||
expect(state.stage).toBe('bnet_login')
|
||||
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.accountPassword = 'any_password_456'
|
||||
expect(submitBattleNetLogin(state)).toBe(true)
|
||||
|
|
@ -79,26 +84,26 @@ describe('Diablo II v1.13c Front-End & Battle.net Flow State Machine', () => {
|
|||
hoverCampfireClass(state, 'am')
|
||||
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')
|
||||
expect(state.selectedClass).toBe('so')
|
||||
expect(state.campfireClasses.so.phase).toBe('fw')
|
||||
expect(state.activeInput).toBe('charName')
|
||||
|
||||
const fwCount = state.campfireClasses.so.frameCount.fw
|
||||
for (let i = 0; i < fwCount; i++) {
|
||||
const fwEnd = state.campfireClasses.so.frameCount.fw - 2
|
||||
for (let i = 0; i < fwEnd; i++) {
|
||||
stepFlowTick(state)
|
||||
}
|
||||
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')
|
||||
expect(state.selectedClass).toBe('ne')
|
||||
expect(state.campfireClasses.so.phase).toBe('bw')
|
||||
expect(state.campfireClasses.ne.phase).toBe('fw')
|
||||
|
||||
const bwCount = state.campfireClasses.so.frameCount.bw
|
||||
for (let i = 0; i < bwCount; i++) {
|
||||
const bwEnd = state.campfireClasses.so.frameCount.bw - 2
|
||||
for (let i = 0; i < bwEnd; i++) {
|
||||
stepFlowTick(state)
|
||||
}
|
||||
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)
|
||||
|
||||
// 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')
|
||||
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
|
||||
stepFlowTick(state)
|
||||
stepFlowTick(state)
|
||||
expect(state.campfireClasses.as.frameIndex).toBe(0)
|
||||
expect(state.campfireClasses.as.frameIndex).toBe(25)
|
||||
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')
|
||||
expect(state.campfireClasses.pa.phase).toBe('fw')
|
||||
for (let i = 0; i < 28; i++) {
|
||||
for (let i = 0; i < 78; i++) {
|
||||
stepFlowTick(state)
|
||||
}
|
||||
expect(state.campfireClasses.pa.phase).toBe('nu3')
|
||||
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)
|
||||
expect(state.campfireClasses.pa.frameIndex).toBe(0)
|
||||
stepFlowTick(state)
|
||||
expect(state.campfireClasses.pa.frameIndex).toBe(1)
|
||||
for (let i = 0; i < 20; i++) {
|
||||
for (let i = 0; i < 12; i++) {
|
||||
stepFlowTick(state)
|
||||
}
|
||||
expect(state.campfireClasses.pa.frameIndex).toBe(11)
|
||||
expect(state.campfireClasses.pa.frameIndex).toBe(7)
|
||||
stepFlowTick(state)
|
||||
expect(state.campfireClasses.pa.frameIndex).toBe(11)
|
||||
expect(state.campfireClasses.pa.frameIndex).toBe(7)
|
||||
stepFlowTick(state)
|
||||
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', () => {
|
||||
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 fontMetas = JSON.parse(readFileSync(metricsPath, 'utf8'))
|
||||
|
||||
// 1. Verify manifest animStrips rates
|
||||
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)
|
||||
renderer.manifest = manifest
|
||||
renderer.fontMetas = fontMetas
|
||||
renderer.ready = true
|
||||
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)
|
||||
|
|
@ -599,6 +609,11 @@ describe('Diablo II v1.13c Front-End & Battle.net Flow State Machine', () => {
|
|||
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)
|
||||
}
|
||||
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)
|
||||
state.stage = 'main_menu'
|
||||
|
|
|
|||
Loading…
Reference in New Issue