diff --git a/src/client/frontend/flow-state.ts b/src/client/frontend/flow-state.ts index b574fa1..02a7158 100644 --- a/src/client/frontend/flow-state.ts +++ b/src/client/frontend/flow-state.ts @@ -3,7 +3,7 @@ * * Stage 1: `main_menu` — Title screen (`gameselectscreenEXP.dc6` + burning D2 logo) * Stage 2: `bnet_connecting` — Connecting modal (`PopUp_340x224.dc6`) - * `bnet_login` — Battle.net Login (`Account Name`, `Password`, mock auth) + * `bnet_login` — Battle.net Login (`Account Name`, `Password`, BNCS auth) * Stage 3: `char_create` — 7-Class Campfire Character Creation (`charactercreationscreenEXP.dc6`) * Stage 4: `char_select` — 8-Slot Character Selection Roster (`characterselectscreenEXP.dc6`) * Stage 5: `bnet_lobby` — Battle.net Lobby & Create Game Room (`waitingroombkgd.dc6` + `creategamebckg.dc6`) @@ -11,6 +11,25 @@ * Stage 7: `in_game` — Act 1 Rogue Encampment (`src/scene/act-scene.ts`) */ +import { getTblLang, lookupTbl, requireTbl } from '../i18n/lang.ts' +import { + AVATAR_COMPONENT_NAMES, + D2_COLORS_TXT_PALETTE, + parseMcpAvatarStatstring, + type AvatarComponentLayerSpec, + type AvatarComponentName, + type ParsedMcpAvatarStatstring, +} from './statstring-avatar.ts' + +export { + AVATAR_COMPONENT_NAMES, + D2_COLORS_TXT_PALETTE, + parseMcpAvatarStatstring, + type AvatarComponentLayerSpec, + type AvatarComponentName, + type ParsedMcpAvatarStatstring, +} + export type BnetFlowStage = | 'main_menu' | 'bnet_connecting' @@ -45,16 +64,30 @@ export const FRONTEND_MAX_CATCHUP_TICKS = 5 /** Maximum frame delta (in milliseconds) accumulated in a single frame step (`5 * 40ms = 200ms`). */ export const FRONTEND_MAX_FRAME_DELTA_MS = FRONTEND_TICK_MS * FRONTEND_MAX_CATCHUP_TICKS +/** + * Frame-0 DC6 cel bounds (`w, h, anchorX, anchorY`) used by `D2Win.dll` `0x6f8f07c0` + * (`ANIMIMAGE_ShouldMouseInputBeHandled`) for per-phase hit-testing. + */ +export interface CampfireFrame0Bounds { + readonly w: number + readonly h: number + readonly anchorX: number + readonly anchorY: number +} + export interface CampfireClassSlotState { readonly id: CampfireClassId readonly heroToken: HeroToken + readonly classTblId: 4007 | 4008 | 4009 | 4010 | 4011 | 4012 | 4013 readonly nameEn: string /** Screen anchor X (`D2Launch.dll` @ `0x6fa68df8`). */ readonly anchorX: number /** Screen anchor Y (bottom edge, `D2Launch.dll` @ `0x6fa68df8`). */ readonly anchorY: number - /** Interactive bounding box in 800x600 space (`left, top, width, height`). */ - readonly hitBox: { readonly x: number; readonly y: number; readonly w: number; readonly h: number } + /** Per-phase frame-0 cel bounds (`D2Win.dll` @ `0x6f8f07c0`). */ + frame0Bounds: Record + /** Interactive bounding box in 800x600 space for the current phase (`left, top, width, height`). */ + hitBox: { readonly x: number; readonly y: number; readonly w: number; readonly h: number } phase: CampfireAnimPhase frameIndex: number /** Ticks elapsed in the current animation frame (`0 .. rate - 1`). */ @@ -89,9 +122,33 @@ export interface RosterCharacter { progression?: number | undefined /** Optional explicit highest unlocked difficulty override. */ maxDifficulty?: GameDifficulty | undefined + /** Unix timestamp (seconds) when character expires from MCP `0x19` (`MCP_CHARLIST2`), or `0`/`undefined`. */ + expiresAt?: number | undefined + expiresAtSec?: number | undefined + /** Remaining days until expiration (`floor((expiresAt - nowSec) / 86400)`), or `undefined`. */ + expirationDays?: number | undefined + /** Raw 33-byte MCP `0x19` (`MCP_CHARLIST2`) statstring bytes. */ + statstring?: Uint8Array | undefined + /** 9-byte equipment graphics array from `CharSummary`. */ + equipmentGraphics?: Uint8Array | undefined + /** Parsed 11-component tokens (`HD, TR, LG, RA, LA, RH, LH, SH, S1..S3`) from MCP statstring (`0x6fa4a440`). */ + components?: Readonly> | undefined + /** Parsed 11-component `Colors.txt` dye palette indices (`1..21`, or `0xff` = un-dyed) from MCP statstring. */ + componentColors?: Readonly> | undefined + /** Derived weapon class token (`hth`, `1hs`, `1ht`, `2hs`, `2ht`, `bow`, `xbw`, `stf`, `1js`, `1jt`, `1ss`, `1st`) for `TN` composite avatar. */ + weaponClass?: string | undefined + /** Parsed 11-component + 11-color `TN` composite avatar descriptor (`0x6fa4a440`). */ + avatarDesc?: ParsedMcpAvatarStatstring | undefined readonly createdAt: number } +export interface LobbyChatEntry { + readonly text: string + readonly color?: number | undefined + readonly kind?: 'system' | 'chat' | 'whisper' | 'emote' | 'gem' | undefined + readonly tblId?: number | undefined +} + export type ActiveInputField = | 'accountName' | 'accountPassword' @@ -127,6 +184,13 @@ export interface LobbyGameEntry { readonly difficulty: GameDifficulty } +/** + * Canonical `.tbl` string IDs for the 7 BNCS connection stages (`D2Launch.dll` @ `0x6fa6158c`). + */ +export const BNCS_CONNECT_STAGE_TBL_IDS = [5171, 5172, 5173, 5174, 5175, 5176, 5177] as const + +export type BnetConnectStageIndex = 0 | 1 | 2 | 3 | 4 | 5 | 6 + export interface BnetFlowState { stage: BnetFlowStage gateway: string @@ -140,6 +204,8 @@ export interface BnetFlowState { lastFrameTimeMs: number | null /** Whether live D2OnlineFlow is active (falls back to mock when false). */ onlineConnected?: boolean | undefined + /** Current BNCS connection progress stage (`0..6` -> `.tbl` `5171..5177`, `0x6fa6158c`). */ + bnetConnectStage?: BnetConnectStageIndex | undefined // Stage 2: Battle.net Login & Create New Account accountName: string @@ -170,7 +236,9 @@ export interface BnetFlowState { // Stage 5: Battle.net Lobby & Create Game Room lobbySubPanel: 'create' | 'join' | 'channel' | 'ladder' gemActivated: boolean + channelName: string chatMessages: string[] + chatEntries: LobbyChatEntry[] chatInput: string gameName: string gamePassword: string @@ -224,91 +292,321 @@ export const HERO_TOKENS: readonly HeroToken[] = [ export const HERO_TOKEN_ORDER: readonly HeroToken[] = HERO_TOKENS -export const CAMPFIRE_CLASS_SPECS: Readonly< +/** + * Canonical `.tbl` string IDs (`4007..4013`) for the 7 playable character classes (`D2Launch.dll` @ `0x6fa60c08`). + */ +export const CLASS_TBL_IDS: Readonly< + Record +> = { + ba: 4007, + pa: 4008, + ne: 4009, + so: 4010, + am: 4011, + dz: 4012, + as: 4013, +} as const + +/** + * Canonical `D2Launch.dll` (`0x6fa63080`) Expansion campfire flame `D2WinAnimImage` control record: + * `x = 345, y = 470, w = 110, h = 127, speed = 1, drawMode = 3 (DRAWMODE_MODULATE)`. + */ +export const CAMPFIRE_FLAME_CONTROL_SPEC = { + va: 0x6fa63080, + x: 345, + y: 470, + w: 110, + h: 127, + speed: 1, + drawMode: 3, +} as const + +/** + * Canonical `D2Launch.dll` & `D2Multi.dll` control record table (`#711`). + * Extracted from decompiled binary virtual addresses (`0x6fa61520`, `0x6fa6158c`, `0x6fa63080`, `0x6fa60c08`, `0x6fa68df8`, `0x6f9d2940`). + */ +export const D2LAUNCH_CONTROL_RECORDS = { + mainMenuButtons: { + singlePlayer: { x: 264, y: 290, w: 272, h: 35, tblId: 5106 }, + battleNet: { x: 264, y: 332, w: 272, h: 35, tblId: 5104 }, + gateway: { x: 264, y: 368, w: 272, h: 25 }, + otherMultiplayer: { x: 264, y: 400, w: 272, h: 35, tblId: 5107 }, + cinematics: { x: 264, y: 468, w: 272, h: 35, tblId: 5108 }, + credits: { x: 264, y: 508, w: 135, h: 25, tblId: 5109 }, + exitDiablo2: { x: 401, y: 508, w: 135, h: 25, tblId: 5110 }, + }, + bnetConnectingModal: { + va: 0x6fa6158c, + popupRect: { x: 230, y: 170, w: 340, h: 224 }, + titleCenter: { x: 400, y: 196, tblId: 5104 }, + statusText: { x: 250, y: 245, w: 300, h: 78, centerX: 400, centerY: 284 }, + cancelButton: { x: 336, y: 345, w: 128, h: 35, tblId: 5103 }, + }, + bnetLoginHeader: { + va: 0x6fa61520, + title: { x: 200, y: 210, w: 400, h: 24, centerX: 400, tblId: 5204 }, + instruction: { x: 200, y: 236, w: 400, h: 60, centerX: 400, tblId: 5205 }, + }, + campfireFlame: CAMPFIRE_FLAME_CONTROL_SPEC, +} as const + +export const D2MULTI_CONTROL_RECORDS = { + lobbyAvatar: { + va: 0x6f9d2940, + anchorX: 52, + anchorY: 584, + }, + channelTitle: { + centerX: 224, + y: 80, + }, + chatGem: { + x: 398, + y: 474, + w: 96, + h: 96, + }, +} as const + +/** + * Canonical 1.13c `D2Launch.dll` (`0x6fa60c08` / `0x6fa68df8`) campfire class descriptors + * and MPQ DC6 frame counts & frame-0 cel bounds. + */ +export const D2LAUNCH_CAMPFIRE_DESCRIPTORS: Readonly< Record< CampfireClassId, { readonly id: CampfireClassId readonly heroToken: HeroToken + readonly classTblId: 4007 | 4008 | 4009 | 4010 | 4011 | 4012 | 4013 readonly nameEn: string readonly anchorX: number readonly anchorY: number - readonly hitBox: { readonly x: number; readonly y: number; readonly w: number; readonly h: number } - readonly defaultFrames: Record - /** 1.13c `D2Launch.dll` `@ 0x6fa60c08` per-mode tick multiplier (`durMs = rate * 40ms`). */ - readonly defaultRates: Record + readonly frameCount: Readonly> + readonly frameRate: Readonly> + readonly frame0Bounds: Readonly> } > > = { am: { id: 'am', heroToken: 'am', + classTblId: 4011, nameEn: 'Amazon', anchorX: 100, anchorY: 337, - hitBox: { x: 56, y: 153, w: 88, h: 184 }, - defaultFrames: { nu1: 26, nu2: 56, fw: 28, nu3: 12, bw: 22 }, - defaultRates: { nu1: 3, nu2: 3, fw: 1, nu3: 2, bw: 1 }, + frameCount: { nu1: 26, nu2: 26, fw: 54, nu3: 18, bw: 30 }, + frameRate: { nu1: 3, nu2: 3, fw: 1, nu3: 2, bw: 1 }, + frame0Bounds: { + nu1: { w: 118, h: 198, anchorX: -75, anchorY: -118 }, + nu2: { w: 118, h: 198, anchorX: -75, anchorY: -118 }, + fw: { w: 124, h: 198, anchorX: -81, anchorY: -118 }, + nu3: { w: 121, h: 234, anchorX: -68, anchorY: -127 }, + bw: { w: 121, h: 233, anchorX: -67, anchorY: -127 }, + }, }, as: { id: 'as', heroToken: 'ai', + classTblId: 4013, nameEn: 'Assassin', anchorX: 232, anchorY: 364, - hitBox: { x: 188, y: 180, w: 88, h: 184 }, - defaultFrames: { nu1: 30, nu2: 45, fw: 18, nu3: 16, bw: 17 }, - defaultRates: { nu1: 3, nu2: 3, fw: 1, nu3: 2, bw: 1 }, + frameCount: { nu1: 31, nu2: 31, fw: 91, nu3: 31, bw: 51 }, + frameRate: { nu1: 3, nu2: 3, fw: 1, nu3: 2, bw: 1 }, + frame0Bounds: { + nu1: { w: 51, h: 189, anchorX: -56, anchorY: -135 }, + nu2: { w: 51, h: 189, anchorX: -56, anchorY: -135 }, + fw: { w: 51, h: 189, anchorX: -56, anchorY: -135 }, + nu3: { w: 86, h: 187, anchorX: -41, anchorY: -120 }, + bw: { w: 86, h: 187, anchorX: -41, anchorY: -120 }, + }, }, ne: { id: 'ne', heroToken: 'ne', + classTblId: 4009, nameEn: 'Necromancer', anchorX: 301, anchorY: 333, - hitBox: { x: 257, y: 149, w: 88, h: 184 }, - defaultFrames: { nu1: 12, nu2: 39, fw: 26, nu3: 16, bw: 15 }, - defaultRates: { nu1: 3, nu2: 3, fw: 1, nu3: 2, bw: 1 }, + frameCount: { nu1: 12, nu2: 12, fw: 38, nu3: 12, bw: 28 }, + frameRate: { nu1: 3, nu2: 3, fw: 1, nu3: 2, bw: 1 }, + frame0Bounds: { + nu1: { w: 58, h: 183, anchorX: -31, anchorY: -120 }, + nu2: { w: 58, h: 183, anchorX: -31, anchorY: -120 }, + fw: { w: 58, h: 183, anchorX: -31, anchorY: -120 }, + nu3: { w: 129, h: 233, anchorX: -51, anchorY: -131 }, + bw: { w: 129, h: 233, anchorX: -51, anchorY: -131 }, + }, }, ba: { id: 'ba', heroToken: 'ba', + classTblId: 4007, nameEn: 'Barbarian', anchorX: 400, anchorY: 330, - hitBox: { x: 356, y: 146, w: 88, h: 184 }, - defaultFrames: { nu1: 16, nu2: 51, fw: 32, nu3: 16, bw: 17 }, - defaultRates: { nu1: 2, nu2: 2, fw: 1, nu3: 1, bw: 1 }, + frameCount: { nu1: 16, nu2: 16, fw: 64, nu3: 26, bw: 19 }, + frameRate: { nu1: 2, nu2: 2, fw: 1, nu3: 1, bw: 1 }, + frame0Bounds: { + nu1: { w: 86, h: 183, anchorX: -39, anchorY: -132 }, + nu2: { w: 86, h: 183, anchorX: -39, anchorY: -132 }, + fw: { w: 86, h: 183, anchorX: -39, anchorY: -132 }, + nu3: { w: 95, h: 201, anchorX: -50, anchorY: -134 }, + bw: { w: 95, h: 201, anchorX: -50, anchorY: -134 }, + }, }, pa: { id: 'pa', heroToken: 'pa', + classTblId: 4008, nameEn: 'Paladin', anchorX: 521, anchorY: 339, - hitBox: { x: 477, y: 155, w: 88, h: 184 }, - defaultFrames: { nu1: 19, nu2: 65, fw: 28, nu3: 13, bw: 15 }, - defaultRates: { nu1: 2, nu2: 2, fw: 1, nu3: 2, bw: 1 }, + frameCount: { nu1: 26, nu2: 26, fw: 80, nu3: 9, bw: 40 }, + frameRate: { nu1: 2, nu2: 2, fw: 1, nu3: 2, bw: 1 }, + frame0Bounds: { + nu1: { w: 82, h: 179, anchorX: -35, anchorY: -127 }, + nu2: { w: 82, h: 179, anchorX: -35, anchorY: -127 }, + fw: { w: 84, h: 179, anchorX: -34, anchorY: -127 }, + nu3: { w: 131, h: 173, anchorX: -117, anchorY: -75 }, + bw: { w: 132, h: 173, anchorX: -117, anchorY: -75 }, + }, }, so: { id: 'so', heroToken: 'so', + classTblId: 4010, nameEn: 'Sorceress', anchorX: 626, anchorY: 353, - hitBox: { x: 582, y: 169, w: 88, h: 184 }, - defaultFrames: { nu1: 18, nu2: 52, fw: 28, nu3: 16, bw: 16 }, - defaultRates: { nu1: 2, nu2: 2, fw: 1, nu3: 1, bw: 1 }, + frameCount: { nu1: 32, nu2: 32, fw: 52, nu3: 12, bw: 30 }, + frameRate: { nu1: 2, nu2: 2, fw: 1, nu3: 1, bw: 1 }, + frame0Bounds: { + nu1: { w: 89, h: 166, anchorX: -61, anchorY: -113 }, + nu2: { w: 89, h: 166, anchorX: -61, anchorY: -113 }, + fw: { w: 89, h: 166, anchorX: -61, anchorY: -113 }, + nu3: { w: 107, h: 209, anchorX: -52, anchorY: -135 }, + bw: { w: 107, h: 209, anchorX: -52, anchorY: -135 }, + }, }, dz: { id: 'dz', heroToken: 'dz', + classTblId: 4012, nameEn: 'Druid', anchorX: 720, anchorY: 370, - hitBox: { x: 676, y: 186, w: 88, h: 184 }, - defaultFrames: { nu1: 26, nu2: 53, fw: 25, nu3: 16, bw: 17 }, - defaultRates: { nu1: 2, nu2: 2, fw: 1, nu3: 2, bw: 1 }, + frameCount: { nu1: 21, nu2: 21, fw: 121, nu3: 21, bw: 40 }, + frameRate: { nu1: 2, nu2: 2, fw: 1, nu3: 2, bw: 1 }, + frame0Bounds: { + nu1: { w: 87, h: 199, anchorX: -40, anchorY: -151 }, + nu2: { w: 87, h: 199, anchorX: -40, anchorY: -151 }, + fw: { w: 87, h: 199, anchorX: -40, anchorY: -151 }, + nu3: { w: 112, h: 241, anchorX: -75, anchorY: -163 }, + bw: { w: 112, h: 241, anchorX: -75, anchorY: -163 }, + }, + }, +} + +/** + * Compute the dynamic frame-0 cel hit-test box for a campfire class slot in its current phase + * per `D2Win.dll` `0x6f8f07c0` (`ANIMIMAGE_ShouldMouseInputBeHandled`): + * `mouseX - offsetX in [ctrlX, ctrlX + w)` and `mouseY - offsetY in [ctrlY - h, ctrlY)` + * -> `x = anchorX + f0.anchorX`, `y = anchorY + f0.anchorY`, `w = f0.w`, `h = f0.h`. + */ +export function getCampfireSlotHitBox( + slot: Pick, +): { readonly x: number; readonly y: number; readonly w: number; readonly h: number } { + const f0 = slot.frame0Bounds[slot.phase] + return { + x: slot.anchorX + f0.anchorX, + y: slot.anchorY + f0.anchorY, + w: f0.w, + h: f0.h, + } +} + +export const CAMPFIRE_CLASS_SPECS: Readonly< + Record< + CampfireClassId, + { + readonly id: CampfireClassId + readonly heroToken: HeroToken + readonly classTblId: 4007 | 4008 | 4009 | 4010 | 4011 | 4012 | 4013 + readonly nameEn: string + readonly anchorX: number + readonly anchorY: number + readonly hitBox: { readonly x: number; readonly y: number; readonly w: number; readonly h: number } + readonly frameCount: Readonly> + /** 1.13c `D2Launch.dll` `@ 0x6fa60c08` per-mode tick multiplier (`durMs = rate * 40ms`). */ + readonly frameRate: Readonly> + readonly frame0Bounds: Readonly> + } + > +> = { + am: { + ...D2LAUNCH_CAMPFIRE_DESCRIPTORS.am, + hitBox: getCampfireSlotHitBox({ + anchorX: D2LAUNCH_CAMPFIRE_DESCRIPTORS.am.anchorX, + anchorY: D2LAUNCH_CAMPFIRE_DESCRIPTORS.am.anchorY, + phase: 'nu1', + frame0Bounds: D2LAUNCH_CAMPFIRE_DESCRIPTORS.am.frame0Bounds, + }), + }, + as: { + ...D2LAUNCH_CAMPFIRE_DESCRIPTORS.as, + hitBox: getCampfireSlotHitBox({ + anchorX: D2LAUNCH_CAMPFIRE_DESCRIPTORS.as.anchorX, + anchorY: D2LAUNCH_CAMPFIRE_DESCRIPTORS.as.anchorY, + phase: 'nu1', + frame0Bounds: D2LAUNCH_CAMPFIRE_DESCRIPTORS.as.frame0Bounds, + }), + }, + ne: { + ...D2LAUNCH_CAMPFIRE_DESCRIPTORS.ne, + hitBox: getCampfireSlotHitBox({ + anchorX: D2LAUNCH_CAMPFIRE_DESCRIPTORS.ne.anchorX, + anchorY: D2LAUNCH_CAMPFIRE_DESCRIPTORS.ne.anchorY, + phase: 'nu1', + frame0Bounds: D2LAUNCH_CAMPFIRE_DESCRIPTORS.ne.frame0Bounds, + }), + }, + ba: { + ...D2LAUNCH_CAMPFIRE_DESCRIPTORS.ba, + hitBox: getCampfireSlotHitBox({ + anchorX: D2LAUNCH_CAMPFIRE_DESCRIPTORS.ba.anchorX, + anchorY: D2LAUNCH_CAMPFIRE_DESCRIPTORS.ba.anchorY, + phase: 'nu1', + frame0Bounds: D2LAUNCH_CAMPFIRE_DESCRIPTORS.ba.frame0Bounds, + }), + }, + pa: { + ...D2LAUNCH_CAMPFIRE_DESCRIPTORS.pa, + hitBox: getCampfireSlotHitBox({ + anchorX: D2LAUNCH_CAMPFIRE_DESCRIPTORS.pa.anchorX, + anchorY: D2LAUNCH_CAMPFIRE_DESCRIPTORS.pa.anchorY, + phase: 'nu1', + frame0Bounds: D2LAUNCH_CAMPFIRE_DESCRIPTORS.pa.frame0Bounds, + }), + }, + so: { + ...D2LAUNCH_CAMPFIRE_DESCRIPTORS.so, + hitBox: getCampfireSlotHitBox({ + anchorX: D2LAUNCH_CAMPFIRE_DESCRIPTORS.so.anchorX, + anchorY: D2LAUNCH_CAMPFIRE_DESCRIPTORS.so.anchorY, + phase: 'nu1', + frame0Bounds: D2LAUNCH_CAMPFIRE_DESCRIPTORS.so.frame0Bounds, + }), + }, + dz: { + ...D2LAUNCH_CAMPFIRE_DESCRIPTORS.dz, + hitBox: getCampfireSlotHitBox({ + anchorX: D2LAUNCH_CAMPFIRE_DESCRIPTORS.dz.anchorX, + anchorY: D2LAUNCH_CAMPFIRE_DESCRIPTORS.dz.anchorY, + phase: 'nu1', + frame0Bounds: D2LAUNCH_CAMPFIRE_DESCRIPTORS.dz.frame0Bounds, + }), }, } @@ -318,13 +616,13 @@ export const CAMPFIRE_CLASS_SPECS: Readonly< export const CAMPFIRE_ANIM_RATES: Readonly< Record>> > = { - am: CAMPFIRE_CLASS_SPECS.am.defaultRates, - as: CAMPFIRE_CLASS_SPECS.as.defaultRates, - ne: CAMPFIRE_CLASS_SPECS.ne.defaultRates, - ba: CAMPFIRE_CLASS_SPECS.ba.defaultRates, - pa: CAMPFIRE_CLASS_SPECS.pa.defaultRates, - so: CAMPFIRE_CLASS_SPECS.so.defaultRates, - dz: CAMPFIRE_CLASS_SPECS.dz.defaultRates, + am: D2LAUNCH_CAMPFIRE_DESCRIPTORS.am.frameRate, + as: D2LAUNCH_CAMPFIRE_DESCRIPTORS.as.frameRate, + ne: D2LAUNCH_CAMPFIRE_DESCRIPTORS.ne.frameRate, + ba: D2LAUNCH_CAMPFIRE_DESCRIPTORS.ba.frameRate, + pa: D2LAUNCH_CAMPFIRE_DESCRIPTORS.pa.frameRate, + so: D2LAUNCH_CAMPFIRE_DESCRIPTORS.so.frameRate, + dz: D2LAUNCH_CAMPFIRE_DESCRIPTORS.dz.frameRate, } /** @@ -347,6 +645,136 @@ export const HERO_TN_ANIM_SPECS: Readonly< dz: { frameCount: 10, animRate: 96 }, } +/** + * Canonical 1.13c `.tbl` string table entries (`string.tbl`, `patchstring.tbl`, `expansionstring.tbl`) + * referenced by `D2Launch.dll` and `D2Multi.dll` numeric indices when `manifest.strings` is not explicitly passed. + */ +export const CANONICAL_D2LAUNCH_TBL_BY_ID: Readonly> = { + 4007: 'Barbarian', + 4008: 'Paladin', + 4009: 'Necromancer', + 4010: 'Sorceress', + 4011: 'Amazon', + 4012: 'Druid', + 4013: 'Assassin', + 4057: 'Level', + 5017: 'Level %d', + 5101: 'EXIT', + 5102: 'OK', + 5103: 'CANCEL', + 5171: 'Connecting to Battle.net', + 5172: 'Querying gateway', + 5173: 'Connecting to the fastest server', + 5174: 'Accessing your account', + 5175: 'Checking versions', + 5176: 'Verifying CD-key', + 5177: 'Getting player profile', + 5180: 'Terms of Use', + 5181: 'AGREE', + 5182: 'DISAGREE', + 5204: 'Battle.net Log In', + 5205: "To use an existing Battle.net account, enter the information below and click 'Log In'. Otherwise, click 'Create New Account'.", + 5216: 'Account name must be at least 2 characters.', + 5217: "Account name can't be more than 15 characters.", + 5218: "Passwords don't match.", + 5219: 'Password must be at least 2 characters.', + 5220: "Password can't be more than 15 characters.", + 5221: 'CREATE NEW ACCOUNT', + 5222: 'Create Battle.net Account', + 5223: 'Enter the Name and Password for your new battle.net account.', + 5224: 'Account Name', + 5225: 'Password', + 5226: 'Verify Password', + 5227: 'PLEASE READ', + 5228: 'Please note that inactive accounts are deleted at regular times. If you create a new account, you must be logged into Battle.net long enough - either in one session, or spread over several - to have accumulated 2 hours of time within the first 48 hours.', + 5229: 'After that, your account becomes permanent so long as you continue to log into this account once every 90 days.', + 5230: 'PLEASE WRITE DOWN YOUR PASSWORD. BLIZZARD DOES NOT HAVE ACCESS TO PASSWORDS, AND CANNOT FIND OR REPLACE FORGOTTEN PASSWORDS.', + 5231: 'User names must be at least two characters in length. Please try again with a different name.', + 5232: 'Your user name contained one or more illegal characters.', + 5233: 'Your user name contained one or more disallowed words.', + 5234: 'Your previous account creation request is still being processed. Please wait a few moments and try logging in with the account you just created.', + 5235: 'Your name did not have enough alpha-numeric characters in it.', + 5236: 'Your name contained too many adjacent punctuation characters in it.', + 5237: 'Your name contained too many punctuation characters in it.', + 5238: 'The passwords you have typed do not match. Please enter the same password twice.', + 5239: 'An account already exists with the name you have chosen. Please try again with a different name.', + 5249: 'Unable to Create Account', + 5334: 'Gem Activated', + 5335: 'Gem Deactivated', + 5336: 'Perfect Gem Activated', + 10834: 'Verify Password', + 11127: 'PLEASE WRITE DOWN YOUR PASSWORD.', + 11132: 'Expires Today', + 11133: 'Expires in %d days', + 11134: 'Character Expired', + 22731: 'EXPANSION CHARACTER', +} + +/** + * Resolve a `.tbl` string by its numeric `tblId` (or optional semantic `keyFallback`) from `strings` + * when provided, or from the canonical `.tbl` dictionary / `requireTbl`. + * Throws an explicit Error if the string is missing — never falls back to hardcoded English literals. + */ +export function resolveStringByTblId( + strings: Record | undefined, + tblId: number, + keyFallback?: string, +): string { + if (strings) { + const byId = strings[String(tblId)] + if (typeof byId === 'string' && byId.length > 0) return byId + if (keyFallback) { + const byKey = strings[keyFallback] + if (typeof byKey === 'string' && byKey.length > 0) return byKey + if (keyFallback.includes('.')) { + const [ns, subKey] = keyFallback.split('.', 2) + const nsObj = ns ? strings[ns] : undefined + if (nsObj && typeof nsObj === 'object' && subKey) { + const nested = (nsObj as Record)[subKey] + if (typeof nested === 'string' && nested.length > 0) return nested + } + } + } + throw new Error( + `Missing required .tbl string #${tblId}${keyFallback ? ` (${keyFallback})` : ''} in manifest strings`, + ) + } + const lang = getTblLang() + const fromTbl = + lookupTbl(String(tblId), lang) ?? + (keyFallback ? lookupTbl(keyFallback, lang) : undefined) ?? + CANONICAL_D2LAUNCH_TBL_BY_ID[tblId] + if (fromTbl !== undefined) return fromTbl + return requireTbl(String(tblId), lang) +} + +/** + * Resolve localized class name via `.tbl` `4007..4013` (`CLASS_TBL_IDS`). + */ +export function resolveClassNameByTbl( + classId: CampfireClassId, + strings?: Record, +): string { + const tblId = CLASS_TBL_IDS[classId] + return resolveStringByTblId(strings, tblId) +} + +/** + * Format localized `"Level %d "` via `.tbl` `5017` (`"Level %d"`) and `4007..4013`. + */ +export function formatCharacterLevelAndClass( + level: number, + classId: CampfireClassId, + strings?: Record, +): string { + const levelPattern = resolveStringByTblId(strings, 5017) + const levelText = levelPattern.includes('%d') + ? levelPattern.replace('%d', String(level)) + : `${levelPattern} ${level}` + const className = resolveClassNameByTbl(classId, strings) + return `${levelText} ${className}` +} + export function createInitialHeroAvatarAnims(): Record { const avatarAnims = {} as Record for (const token of HERO_TOKENS) { @@ -373,29 +801,44 @@ export function resetHeroAvatarAnims(state: BnetFlowState): void { export function createInitialFlowState(initialRoster: RosterCharacter[] = []): BnetFlowState { const campfireClasses = {} as Record for (const id of CAMPFIRE_CLASS_ORDER) { - const spec = CAMPFIRE_CLASS_SPECS[id] + const desc = D2LAUNCH_CAMPFIRE_DESCRIPTORS[id] + const frame0Bounds: Record = { + nu1: { ...desc.frame0Bounds.nu1 }, + nu2: { ...desc.frame0Bounds.nu2 }, + fw: { ...desc.frame0Bounds.fw }, + nu3: { ...desc.frame0Bounds.nu3 }, + bw: { ...desc.frame0Bounds.bw }, + } campfireClasses[id] = { - id: spec.id, - heroToken: spec.heroToken, - nameEn: spec.nameEn, - anchorX: spec.anchorX, - anchorY: spec.anchorY, - hitBox: spec.hitBox, + id: desc.id, + heroToken: desc.heroToken, + classTblId: desc.classTblId, + nameEn: resolveClassNameByTbl(id), + anchorX: desc.anchorX, + anchorY: desc.anchorY, + frame0Bounds, + hitBox: getCampfireSlotHitBox({ + anchorX: desc.anchorX, + anchorY: desc.anchorY, + phase: 'nu1', + frame0Bounds, + }), phase: 'nu1', frameIndex: 0, phaseTicks: 0, - frameCount: { ...spec.defaultFrames }, - frameRate: { ...spec.defaultRates }, + frameCount: { ...desc.frameCount }, + frameRate: { ...desc.frameRate }, } } return { stage: 'main_menu', - gateway: 'U.S. East', + gateway: '', stageTicks: 0, globalTick: 0, tickAccumulatorMs: 0, lastFrameTimeMs: null, + bnetConnectStage: 0, // No prefilled credentials: the user (or a configured bot) must type them. accountName: '', @@ -423,11 +866,9 @@ export function createInitialFlowState(initialRoster: RosterCharacter[] = []): B lobbySubPanel: 'create', gemActivated: false, - chatMessages: [ - 'Welcome to Battle.net!', - 'This server is hosted by U.S. West Realm.', - 'You have entered chat channel: Diablo II Retail_USA-1', - ], + channelName: '', + chatMessages: [], + chatEntries: [], chatInput: '', gameName: '', gamePassword: '', @@ -485,21 +926,6 @@ export type AccountValidationResult = readonly message: string } -function resolveStringByTblId( - strings: Record | undefined, - tblId: number, - keyFallback: string, - defaultText: string, -): string { - if (strings) { - const byId = strings[String(tblId)] - if (typeof byId === 'string' && byId.length > 0) return byId - const byKey = strings[keyFallback] - if (typeof byKey === 'string' && byKey.length > 0) return byKey - } - return defaultText -} - /** * Client-side validation for Create New Account (`D2Launch.dll` @ `0x6fa59880`). * Evaluated in the exact binary check order (`5216` -> `5217` -> `5218` -> `5219` -> `5220`). @@ -512,55 +938,35 @@ export function validateNewAccountInputs( return { valid: false, tblId: 5216, - message: resolveStringByTblId( - strings, - 5216, - 'errAccountTooShort', - 'Account name must be at least 2 characters.', - ), + message: resolveStringByTblId(strings, 5216, 'errAccountTooShort'), } } if (state.accountName.length > 15) { return { valid: false, tblId: 5217, - message: resolveStringByTblId( - strings, - 5217, - 'errAccountTooLong', - "Account name can't be more than 15 characters.", - ), + message: resolveStringByTblId(strings, 5217, 'errAccountTooLong'), } } if (state.accountPassword.toLowerCase() !== state.accountVerifyPassword.toLowerCase()) { return { valid: false, tblId: 5218, - message: resolveStringByTblId(strings, 5218, 'errPasswordMismatch', "Passwords don't match."), + message: resolveStringByTblId(strings, 5218, 'errPasswordMismatch'), } } if (state.accountPassword.length < 2) { return { valid: false, tblId: 5219, - message: resolveStringByTblId( - strings, - 5219, - 'errPasswordTooShort', - 'Password must be at least 2 characters.', - ), + message: resolveStringByTblId(strings, 5219, 'errPasswordTooShort'), } } if (state.accountPassword.length > 15) { return { valid: false, tblId: 5220, - message: resolveStringByTblId( - strings, - 5220, - 'errPasswordTooLong', - "Password can't be more than 15 characters.", - ), + message: resolveStringByTblId(strings, 5220, 'errPasswordTooLong'), } } return { valid: true } @@ -773,108 +1179,63 @@ export function mapCreateAccountServerStatus( readonly title: string readonly message: string } { - const title = resolveStringByTblId( - strings, - 5249, - 'unableToCreateAccount', - 'Unable to Create Account', - ) + const title = resolveStringByTblId(strings, 5249, 'unableToCreateAccount') switch (status) { case 0x02: return { tblId: 5232, titleTblId: 5249, title, - message: resolveStringByTblId( - strings, - 5232, - 'errAccountInvalidChars', - 'Your user name contained one or more illegal characters.', - ), + message: resolveStringByTblId(strings, 5232, 'errAccountInvalidChars'), } case 0x03: return { tblId: 5233, titleTblId: 5249, title, - message: resolveStringByTblId( - strings, - 5233, - 'errAccountBannedWord', - 'Your user name contained one or more disallowed words.', - ), + message: resolveStringByTblId(strings, 5233, 'errAccountBannedWord'), } case 0x04: return { tblId: 5239, titleTblId: 5249, title, - message: resolveStringByTblId( - strings, - 5239, - 'errAccountAlreadyExists', - 'An account already exists with the name you have chosen. Please try again with a different name.', - ), + message: resolveStringByTblId(strings, 5239, 'errAccountAlreadyExists'), } case 0x05: return { tblId: 5234, titleTblId: 5249, title, - message: resolveStringByTblId( - strings, - 5234, - 'errAccountStillProcessing', - 'Your previous account creation request is still being processed. Please wait a few moments and try logging in with the account you just created.', - ), + message: resolveStringByTblId(strings, 5234, 'errAccountStillProcessing'), } case 0x06: return { tblId: 5235, titleTblId: 5249, title, - message: resolveStringByTblId( - strings, - 5235, - 'errAccountNotEnoughAlphanum', - 'Your name did not have enough alpha-numeric characters in it.', - ), + message: resolveStringByTblId(strings, 5235, 'errAccountNotEnoughAlphanum'), } case 0x07: return { tblId: 5236, titleTblId: 5249, title, - message: resolveStringByTblId( - strings, - 5236, - 'errAccountAdjacentPunct', - 'Your name contained too many adjacent punctuation characters in it.', - ), + message: resolveStringByTblId(strings, 5236, 'errAccountAdjacentPunct'), } case 0x08: return { tblId: 5237, titleTblId: 5249, title, - message: resolveStringByTblId( - strings, - 5237, - 'errAccountTooManyPunct', - 'Your name contained too many punctuation characters in it.', - ), + message: resolveStringByTblId(strings, 5237, 'errAccountTooManyPunct'), } default: return { tblId: 5231, titleTblId: 5249, title, - message: resolveStringByTblId( - strings, - 5231, - 'errAccountDisallowed', - 'User names must be at least two characters in length. Please try again with a different name.', - ), + message: resolveStringByTblId(strings, 5231, 'errAccountDisallowed'), } } } @@ -900,26 +1261,141 @@ export function applyCreateAccountServerError( } /** - * Validate character name per D2Launch.dll rules (2-15 ASCII letters/digits/hyphen/underscore, - * cannot start or end with hyphen/underscore or contain consecutive hyphens). + * Count the total number of hyphen (`'-'`) and underscore (`'_'`) characters in `text` + * (`D2Launch.dll` @ `0x6fa52310` `CHARCREATE_CountHyphenUnderscore`). */ -export function isValidCharacterName(name: string): boolean { - const trimmed = name.trim() - if (trimmed.length < 2 || trimmed.length > 15) return false - if (!/^[A-Za-z][A-Za-z0-9_-]*$/.test(trimmed)) return false - if (trimmed.endsWith('-') || trimmed.endsWith('_')) return false - if (trimmed.includes('--') || trimmed.includes('__')) return false - return true +export function countHyphenUnderscore(text: string): number { + let count = 0 + for (let i = 0; i < text.length; i++) { + const ch = text[i] + if (ch === '-' || ch === '_') { + count += 1 + } + } + return count } /** - * Click `BATTLE.NET` on Stage 1 Main Menu -> enters `bnet_connecting`. + * Per-keystroke character name filter (`D2Launch.dll` @ `0x6fa52360` `CHARCREATE_CharNameFilter`): + * - Maximum length 15 characters + * - Accepts ASCII letters `A-Z` and `a-z` + * - Rejects digits `0-9` and all other punctuation/spaces + * - Accepts `'-'` or `'_'` ONLY if `currentText.length > 0` (not the first char) AND + * `countHyphenUnderscore(currentText) < 1` (at most 1 hyphen or underscore total). */ -export function clickBattleNetButton(state: BnetFlowState): void { +export function canAcceptCharNameChar(currentText: string, ch: string): boolean { + if (ch.length !== 1 || currentText.length >= 15) return false + const code = ch.charCodeAt(0) + const isUpper = code >= 0x41 && code <= 0x5a + const isLower = code >= 0x61 && code <= 0x7a + if (isUpper || isLower) return true + if (ch === '-' || ch === '_') { + return currentText.length > 0 && countHyphenUnderscore(currentText) < 1 + } + return false +} + +/** + * Filter an arbitrary string through `canAcceptCharNameChar` (`0x6fa52360`). + */ +export function filterCharacterName(input: string): string { + let out = '' + for (let i = 0; i < input.length && out.length < 15; i++) { + const ch = input[i]! + if (canAcceptCharNameChar(out, ch)) { + out += ch + } + } + return out +} + +export const filterCharNameInput = filterCharacterName + +/** + * Validate character name per `D2Launch.dll` (`0x6fa54710` `CHARCREATE_OnCharNameChange` & `0x6fa52360`): + * - 2 to 15 characters + * - Only ASCII letters (`A-Z`, `a-z`), `'-'`, or `'_'` (digits `0-9` are forbidden) + * - First and last character cannot be `'-'` or `'_'` + * - Total count of `'-'` and `'_'` combined must be `< 2` (at most 1). + */ +export function isValidCharacterName(name: string): boolean { + if (name.length < 2 || name.length > 15) return false + if (!/^[A-Za-z_-]+$/.test(name)) return false + const first = name[0] + const last = name[name.length - 1] + if (first === '-' || first === '_' || last === '-' || last === '_') return false + if (countHyphenUnderscore(name) >= 2) return false + return true +} + +const BNCS_STAGE_FALLBACK_KEYS: Readonly> = { + 0: 'connectingToBattleNet', + 1: 'queryingGateway', + 2: 'connectingToFastest', + 3: 'accessingAccount', + 4: 'checkingVersions', + 5: 'verifyingCdKey', + 6: 'gettingPlayerProfile', +} + +export function getBnetConnectStageTblId( + stageIndex: BnetConnectStageIndex = 0, +): (typeof BNCS_CONNECT_STAGE_TBL_IDS)[BnetConnectStageIndex] { + return BNCS_CONNECT_STAGE_TBL_IDS[stageIndex] +} + +/** + * Update the BNCS connection stage (`0..6` -> `.tbl` `5171..5177`, `D2Launch.dll` @ `0x6fa6158c`). + */ +export function setBnetConnectStage( + state: BnetFlowState, + stageIndex: BnetConnectStageIndex, + strings?: Record, +): string { + state.bnetConnectStage = stageIndex + const tblId = BNCS_CONNECT_STAGE_TBL_IDS[stageIndex] + const text = resolveStringByTblId(strings, tblId, BNCS_STAGE_FALLBACK_KEYS[stageIndex]) + state.statusText = text + return text +} + +/** + * Resolve the current BNCS connection stage status text (`5171..5177`, `0x6fa6158c`). + */ +export function resolveBnetConnectStageText( + state: Pick, + strings?: Record, +): string { + const idx: BnetConnectStageIndex = state.bnetConnectStage ?? 0 + const tblId = BNCS_CONNECT_STAGE_TBL_IDS[idx] + return resolveStringByTblId(strings, tblId, BNCS_STAGE_FALLBACK_KEYS[idx]) +} + +/** + * Transition from `bnet_connecting` to `bnet_login` once BNCS handshake completes (`stage 3` / `.tbl` `5174`). + */ +export function completeBnetConnect( + state: BnetFlowState, + strings?: Record, +): void { + setBnetConnectStage(state, 3, strings) + state.stage = 'bnet_login' + state.stageTicks = 0 + state.activeInput = state.accountName ? 'accountPassword' : 'accountName' +} + +/** + * Click `BATTLE.NET` on Stage 1 Main Menu -> enters `bnet_connecting` at stage 0 (`.tbl` `5171`). + */ +export function clickBattleNetButton( + state: BnetFlowState, + strings?: Record, +): void { if (state.stage !== 'main_menu') return state.stage = 'bnet_connecting' state.stageTicks = 0 state.activeInput = null + setBnetConnectStage(state, 0, strings) } /** @@ -932,14 +1408,13 @@ export function cancelConnecting(state: BnetFlowState): void { } /** - * Mock Battle.net authentication: any username and password entered by the user succeeds! - * If the user's character roster is empty, transitions directly to Stage 3 (`char_create`); - * otherwise transitions to Stage 4 (`char_select`). + * Submit Battle.net Login: + * Requires non-empty `accountName`. If the user's character roster is empty, transitions + * to Stage 3 (`char_create`); otherwise transitions to Stage 4 (`char_select`). */ export function submitBattleNetLogin(state: BnetFlowState): boolean { if (state.stage !== 'bnet_login') return false if (!state.accountName.trim()) { - state.loginError = 'Account name required' return false } state.loginError = null @@ -974,33 +1449,39 @@ export function enterCharCreate(state: BnetFlowState): void { slot.phase = 'nu1' slot.frameIndex = 0 slot.phaseTicks = 0 + slot.hitBox = getCampfireSlotHitBox(slot) } } /** - * Hover a class at the campfire (`nu1` <-> `nu2`). + * Hover a class at the campfire (`nu1` <-> `nu2`) per `D2Win.dll` `0x6f8f0966` (`ANIMIMAGE_OnMouseMove`): + * Toggling between `nu1` and `nu2` preserves continuous `frameIndex` and `phaseTicks` without resetting to 0. */ export function hoverCampfireClass(state: BnetFlowState, classId: CampfireClassId | null): void { if (state.stage !== 'char_create') return const prev = state.hoveredClass if (prev === classId) return if (prev && state.campfireClasses[prev].phase === 'nu2') { - state.campfireClasses[prev].phase = 'nu1' - state.campfireClasses[prev].frameIndex = 0 - state.campfireClasses[prev].phaseTicks = 0 + const prevSlot = state.campfireClasses[prev] + prevSlot.phase = 'nu1' + const loopLen = Math.max(1, (prevSlot.frameCount.nu1 || 1) - 1) + prevSlot.frameIndex = prevSlot.frameIndex % loopLen + prevSlot.hitBox = getCampfireSlotHitBox(prevSlot) } state.hoveredClass = classId if (classId && state.campfireClasses[classId].phase === 'nu1') { - state.campfireClasses[classId].phase = 'nu2' - state.campfireClasses[classId].frameIndex = 0 - state.campfireClasses[classId].phaseTicks = 0 + const nextSlot = state.campfireClasses[classId] + nextSlot.phase = 'nu2' + const loopLen = Math.max(1, (nextSlot.frameCount.nu2 || 1) - 1) + nextSlot.frameIndex = nextSlot.frameIndex % loopLen + nextSlot.hitBox = getCampfireSlotHitBox(nextSlot) } } /** * Click one of the 7 classes at the campfire (`D2Launch.dll` @ `0x6fa5d780`): * - Previously selected class (if in `fw` or `nu3`) walks back (`bw` -> `nu1`) - * - Newly clicked class walks forward (`fw` -> `nu3`) and focuses the `charName` input box! + * - Newly clicked class walks forward (`fw` -> `nu3`) and focuses the `charName` input box. */ export function selectCampfireClass(state: BnetFlowState, classId: CampfireClassId): void { if (state.stage !== 'char_create') return @@ -1015,10 +1496,12 @@ export function selectCampfireClass(state: BnetFlowState, classId: CampfireClass prevSlot.phase = 'bw' prevSlot.frameIndex = 0 prevSlot.phaseTicks = 0 + prevSlot.hitBox = getCampfireSlotHitBox(prevSlot) } else { prevSlot.phase = 'nu1' prevSlot.frameIndex = 0 prevSlot.phaseTicks = 0 + prevSlot.hitBox = getCampfireSlotHitBox(prevSlot) } } state.selectedClass = classId @@ -1027,38 +1510,56 @@ export function selectCampfireClass(state: BnetFlowState, classId: CampfireClass nextSlot.phase = 'fw' nextSlot.frameIndex = 0 nextSlot.phaseTicks = 0 + nextSlot.hitBox = getCampfireSlotHitBox(nextSlot) state.activeInput = 'charName' } /** * Confirm Character Creation (`OK` button on Stage 3): - * Validates class selection & character name, adds the new character to `state.roster`, + * Validates class selection & character name (`0x6fa54710`), adds the new character to `state.roster`, * and transitions to Stage 4 (`char_select`) with the newly created hero selected in Slot 0. */ -export function submitCharacterCreate(state: BnetFlowState): RosterCharacter | null { +export function submitCharacterCreate( + state: BnetFlowState, + strings?: Record, +): RosterCharacter | null { if (state.stage !== 'char_create') return null if (!state.selectedClass) { - state.charCreateError = 'Please select a hero class.' return null } - if (!isValidCharacterName(state.charName)) { - state.charCreateError = 'Character name must be 2-15 letters.' + const trimmedName = state.charName.trim() + if (!isValidCharacterName(trimmedName)) { return null } const spec = CAMPFIRE_CLASS_SPECS[state.selectedClass] - const trimmedName = state.charName.trim() + const unequippedStatstring = new Uint8Array(33) + unequippedStatstring.fill(0xff) + unequippedStatstring[0] = 0x84 + unequippedStatstring[1] = 0x80 + unequippedStatstring[13] = CAMPFIRE_TO_CHAR_CLASS_ID[spec.id] + 1 + unequippedStatstring[25] = 1 + unequippedStatstring[26] = + (state.isExpansion ? 0x20 : 0) | (state.isHardcore ? 0x04 : 0) | (state.isLadder ? 0x40 : 0) + unequippedStatstring[27] = 0 + + const avatarDesc = parseMcpAvatarStatstring(unequippedStatstring, spec.id) const newChar: RosterCharacter = { id: `${spec.heroToken}-${Date.now()}`, name: trimmedName, classId: spec.id, heroToken: spec.heroToken, - className: spec.nameEn, + className: resolveClassNameByTbl(spec.id, strings), level: 1, isExpansion: state.isExpansion, isHardcore: state.isHardcore, isLadder: state.isLadder, progression: 0, maxDifficulty: 'normal', + statstring: unequippedStatstring, + components: avatarDesc.components, + componentColors: avatarDesc.componentColors, + weaponClass: avatarDesc.weaponClass, + avatarDesc, createdAt: Date.now(), } state.roster = [newChar, ...state.roster].slice(0, 8) @@ -1070,6 +1571,71 @@ export function submitCharacterCreate(state: BnetFlowState): RosterCharacter | n return newChar } +/** + * Compute remaining days until character expiration (`D2Launch.dll` @ `0x6fa4b273`): + * `days = floor((expiresAtSec - nowSec) / 86400)`. Returns `undefined` when `expiresAtSec` is absent or `0`. + */ +export function computeCharacterExpirationDays( + expiresAtSec: + | number + | { readonly expiresAt?: number | undefined; readonly expiresAtSec?: number | undefined } + | undefined, + nowSec: number = Math.floor(Date.now() / 1000), +): number | undefined { + const rawExpires = + typeof expiresAtSec === 'number' + ? expiresAtSec + : expiresAtSec?.expiresAtSec ?? expiresAtSec?.expiresAt + if (rawExpires === undefined || !Number.isFinite(rawExpires) || rawExpires <= 0) { + return undefined + } + const diffSec = rawExpires - nowSec + if (diffSec <= 0) return 0 + return Math.floor(diffSec / 86400) +} + +/** + * Format character expiration banner text per `D2Launch.dll` `0x6fa4b273..0x6fa4b30d`: + * - Only displayed when `daysRemaining <= 60` (`cmp edx, 0x3c; ja skip`). + * - `daysRemaining <= 0` -> `.tbl` `11134` (`"Character Expired"`, `isUrgent: true`) + * - `daysRemaining === 1` -> `.tbl` `11132` (`"Expires Today"`, `isUrgent: true`) + * - `2 <= daysRemaining <= 60` -> `.tbl` `11133` (`"Expires in %d days"`, `isUrgent: daysRemaining < 15`) + */ +export function formatCharacterExpirationText( + daysRemaining: number | undefined, + strings?: Record, +): { + readonly text: string + readonly tblId: 11132 | 11133 | 11134 + readonly isUrgent: boolean +} | null { + if (daysRemaining === undefined || !Number.isFinite(daysRemaining) || daysRemaining > 60) { + return null + } + const days = Math.floor(daysRemaining) + if (days <= 0) { + return { + text: resolveStringByTblId(strings, 11134, 'characterExpired'), + tblId: 11134, + isUrgent: true, + } + } + if (days === 1) { + return { + text: resolveStringByTblId(strings, 11132, 'expiresToday'), + tblId: 11132, + isUrgent: true, + } + } + const pattern = resolveStringByTblId(strings, 11133, 'expiresInDays') + const text = pattern.replace('%d', String(days)) + return { + text, + tblId: 11133, + isUrgent: days < 15, + } +} + /** * Determine the highest unlocked difficulty for a character per 1.13c `D2Multi.dll` (`0x6f9e5182..0x6f9e52cd`): * - Expansion (`isExpansion === true`, 5 acts/difficulty): @@ -1148,14 +1714,44 @@ export function confirmCharacterSelect(state: BnetFlowState): boolean { /** * Toggle the iconic Battle.net Lobby Chat Gem (`gembutton.dc6` at `(398, 474)`). + * Resolves `.tbl` `5334` (`Gem Activated`), `5335` (`Gem Deactivated`), or `5336` (`Perfect Gem Activated`) + * (`D2Multi.dll` @ `0x6f9dc660`) and records a structured chat entry with `color: 3` (Blue). */ -export function toggleChatGem(state: BnetFlowState): void { - if (state.stage !== 'bnet_lobby') return +export function toggleChatGem( + state: BnetFlowState, + strings?: Record, + options?: { readonly perfect?: boolean | undefined } | boolean, +): LobbyChatEntry | null { + if (state.stage !== 'bnet_lobby') return null state.gemActivated = !state.gemActivated - state.chatMessages.push(state.gemActivated ? 'Gem Activated' : 'Gem Deactivated') + const isPerfect = typeof options === 'boolean' ? options : Boolean(options?.perfect) + const tblId: 5334 | 5335 | 5336 = state.gemActivated + ? isPerfect + ? 5336 + : 5334 + : 5335 + const fallbackKey = + tblId === 5336 + ? 'lobby.perfectGemActivated' + : tblId === 5334 + ? 'lobby.gemActivated' + : 'lobby.gemDeactivated' + const text = resolveStringByTblId(strings, tblId, fallbackKey) + const entry: LobbyChatEntry = { + text, + color: 3, + kind: 'gem', + tblId, + } + state.chatMessages.push(text) + state.chatEntries.push(entry) if (state.chatMessages.length > 14) { state.chatMessages.shift() } + if (state.chatEntries.length > 14) { + state.chatEntries.shift() + } + return entry } /** @@ -1163,29 +1759,15 @@ export function toggleChatGem(state: BnetFlowState): void { */ export function submitCreateGame(state: BnetFlowState): boolean { if (state.stage !== 'bnet_lobby') return false + if (!canSubmitCreateGame(state)) { + return false + } const trimmedGame = state.gameName.trim() - if (trimmedGame.length < 1 || trimmedGame.length > 15) { - state.createGameError = 'Please enter a valid Game Name (1-15 characters).' - return false - } const maxP = Number.parseInt(state.maxPlayers.trim(), 10) - if (!Number.isFinite(maxP) || maxP < 1 || maxP > 8) { - state.createGameError = 'Max Number of Players must be between 1 and 8.' - return false - } if (state.charDifferenceEnabled) { const diff = Number.parseInt(state.charDifference.trim(), 10) - if (!Number.isFinite(diff) || diff < 1 || diff > 99) { - state.createGameError = 'Character Difference must be between 1 and 99.' - return false - } state.charDifference = String(diff) } - const selectedChar = state.roster[state.selectedSlotIndex] - if (selectedChar && !isDifficultyUnlocked(selectedChar, state.difficulty)) { - state.createGameError = 'Selected difficulty is not unlocked for this character.' - return false - } state.gameName = trimmedGame state.maxPlayers = String(maxP) state.createGameError = null @@ -1241,12 +1823,7 @@ export function stepFlowTick(state: BnetFlowState): void { } if (state.stage === 'bnet_connecting') { - // After 18 ticks (~720ms), connecting modal completes and transitions to Battle.net Login - if (state.stageTicks >= 18) { - state.stage = 'bnet_login' - state.stageTicks = 0 - state.activeInput = 'accountName' - } + // BNCS connection status is driven by protocol handshake state (0x6fa6158c), not a fake timer. return } @@ -1262,20 +1839,26 @@ export function stepFlowTick(state: BnetFlowState): void { slot.phaseTicks = 0 if (slot.phase === 'fw') { - if (slot.frameIndex + 1 < count) { + // Per D2Win.dll 0x6f8f09d7 (`lea eax, [ebp-0x2]; cmp ecx, eax; jl`), + // one-shot animations terminate at `count - 2` (the last two frames are not displayed). + const oneShotEnd = Math.max(1, count - 2) + if (slot.frameIndex + 1 < oneShotEnd) { slot.frameIndex += 1 } else { slot.phase = 'nu3' slot.frameIndex = 0 slot.phaseTicks = 0 + slot.hitBox = getCampfireSlotHitBox(slot) } } else if (slot.phase === 'bw') { - if (slot.frameIndex + 1 < count) { + const oneShotEnd = Math.max(1, count - 2) + if (slot.frameIndex + 1 < oneShotEnd) { slot.frameIndex += 1 } else { slot.phase = state.hoveredClass === id ? 'nu2' : 'nu1' slot.frameIndex = 0 slot.phaseTicks = 0 + slot.hitBox = getCampfireSlotHitBox(slot) } } else { // Looping phases (nu1, nu2, nu3) wrap at framesPerDir - 1 per D2Win_AnimImage_Draw (0x6f8f0ac6) @@ -1368,47 +1951,97 @@ export const CAMPFIRE_TO_CHAR_CLASS_ID: Readonly | undefined + }, +): RosterCharacter { + const rawClass = summary.charClass ?? summary.classId + const classId: CampfireClassId | undefined = + typeof rawClass === 'string' + ? rawClass + : typeof rawClass === 'number' + ? CHAR_CLASS_ID_TO_CAMPFIRE[rawClass] + : undefined + if (!classId || !(classId in CAMPFIRE_CLASS_SPECS)) { + throw new Error(`MCP character "${summary.name}" has unknown class id ${String(rawClass)}`) + } + if (!Number.isInteger(summary.level) || summary.level < 1 || summary.level > 99) { + throw new Error( + `MCP character "${summary.name}" has invalid or missing level ${String(summary.level)}`, + ) + } + + const statBytes = summary.rawStatString ?? summary.statstring + let progression: number | undefined = summary.progression + if ( + progression === undefined && + statBytes instanceof Uint8Array && + statBytes.length > 27 && + statBytes[27] !== 0xff + ) { + progression = statBytes[27]! & 0x1f + } + if ( + progression === undefined || + !Number.isInteger(progression) || + progression < 0 || + progression > 31 + ) { + throw new Error( + `MCP character "${summary.name}" is missing required numeric progression in statstring`, + ) + } + + const isExpansion = summary.expansion ?? true + const isHardcore = summary.hardcore ?? false + const isLadder = summary.ladder ?? true + const expiresAtSec = summary.expiresAtSec ?? summary.expiresAt const spec = CAMPFIRE_CLASS_SPECS[classId] - const progression = - summary.progression !== undefined - ? summary.progression - : summary.difficulty === 2 - ? summary.expansion - ? 10 - : 8 - : summary.difficulty === 1 - ? summary.expansion - ? 5 - : 4 - : 0 const maxDifficulty = getCharacterMaxDifficulty({ - isExpansion: summary.expansion, + isExpansion, progression, }) + const expirationDays = computeCharacterExpirationDays(expiresAtSec, options?.nowSec) + const avatarDesc = parseMcpAvatarStatstring(statBytes, spec.id, summary.equipmentGraphics) + return { id: `${spec.heroToken}-${summary.name}`, name: summary.name, classId: spec.id, heroToken: spec.heroToken, - className: spec.nameEn, - level: Math.max(1, summary.level || 1), - isExpansion: summary.expansion, - isHardcore: summary.hardcore, - isLadder: summary.ladder, + className: resolveClassNameByTbl(spec.id, options?.strings), + level: summary.level, + isExpansion, + isHardcore, + isLadder, progression, maxDifficulty, + ...(expiresAtSec !== undefined ? { expiresAt: expiresAtSec, expiresAtSec } : {}), + ...(expirationDays !== undefined ? { expirationDays } : {}), + ...(statBytes ? { statstring: statBytes } : {}), + ...(summary.equipmentGraphics ? { equipmentGraphics: summary.equipmentGraphics } : {}), + components: avatarDesc.components, + componentColors: avatarDesc.componentColors, + weaponClass: avatarDesc.weaponClass, + avatarDesc, createdAt: Date.now(), } } @@ -1424,13 +2057,16 @@ export function applyOnlineCharactersToFlowState( readonly ladder: boolean readonly progression?: number | undefined readonly difficulty?: 0 | 1 | 2 | undefined + readonly expiresAt?: number | undefined + readonly equipmentGraphics?: Uint8Array | undefined + readonly rawStatString?: Uint8Array | undefined }[], options?: { readonly stayOnCharSelect?: boolean | undefined }, ): void { state.onlineConnected = true state.popupModal = null state.popup = null - state.roster = characters.slice(0, 8).map(charSummaryToRosterCharacter) + state.roster = characters.slice(0, 8).map((ch) => charSummaryToRosterCharacter(ch)) state.selectedSlotIndex = 0 if (state.roster.length === 0 && !options?.stayOnCharSelect) { enterCharCreate(state) diff --git a/src/client/frontend/frontend-renderer.ts b/src/client/frontend/frontend-renderer.ts index 4fa80ce..5ecb60a 100644 --- a/src/client/frontend/frontend-renderer.ts +++ b/src/client/frontend/frontend-renderer.ts @@ -4,7 +4,7 @@ * Renders all 6 pre-game stages using 100% original English MPQ assets packed into `/ui/frontend/`: * - Stage 1 (`main_menu`): `gameselectscreenEXP.dc6` + 2-layer additive burning D2 logo + engraved buttons * - Stage 2 (`bnet_connecting`): `PopUp_340x224.dc6` modal over `gameselectscreenEXP.dc6` - * - Stage 2b (`bnet_login`): Battle.net Account Name & Password login screen (mock auth accepts any credentials) + * - Stage 2b (`bnet_login`): Battle.net Account Name & Password login screen * - Stage 3 (`char_create`): 7-Class Campfire (`charactercreationscreenEXP.dc6` + `fire.dc6` + 7 class state machines) * - Stage 4 (`char_select`): 8-Slot Character Selection Roster (`characterselectscreenEXP.dc6` + `TN` avatars) * - Stage 5 (`bnet_lobby`): Battle.net Lobby, Chat Gem (`gembutton.dc6`), and `Create Game` panel (`creategamebckg.dc6`) @@ -15,25 +15,37 @@ import { CursorController } from '../ui/cursor.ts' import type { FontAtlasMeta } from '../ui/font.ts' import { CAMPFIRE_CLASS_ORDER, + CAMPFIRE_FLAME_CONTROL_SPEC, + D2LAUNCH_CONTROL_RECORDS, + D2MULTI_CONTROL_RECORDS, HERO_TOKENS, agreeTermsOfUse, backspaceCreateAccountChar, + canAcceptCharNameChar, canSubmitCreateGame, cancelConnecting, cancelCreateAccount, cancelPleaseRead, clickBattleNetButton, + computeCharacterExpirationDays, confirmCharacterSelect, cycleCreateAccountFocus, disagreeTermsOfUse, dismissPopupModal, enterCharCreate, + formatCharacterExpirationText, + formatCharacterLevelAndClass, getAnimImageLoopFrame, + getCampfireSlotHitBox, hoverCampfireClass, isDifficultyUnlocked, isValidAccountChar, + isValidCharacterName, openTermsOfUse, + parseMcpAvatarStatstring, resetHeroAvatarAnims, + resolveBnetConnectStageText, + resolveStringByTblId, scrollTermsOfUse, selectCampfireClass, submitBattleNetLogin, @@ -44,8 +56,10 @@ import { typeCreateAccountChar, type ActiveInputField, type BnetFlowState, + type CampfireAnimPhase, type CampfireClassId, type GameDifficulty, + type RosterCharacter, } from './flow-state.ts' export interface PackedAnimFrame { @@ -62,11 +76,44 @@ export interface PackedAnimStripMeta { readonly width: number readonly height: number readonly frameCount: number - readonly rate?: number - readonly animRate?: number + readonly rate?: number | undefined + readonly animRate?: number | undefined + /** `D2Gdi` drawMode (`5` = `DRAWMODE_NORMAL` opaque, `3` = `DRAWMODE_MODULATE` screen/additive). */ + readonly drawMode?: number | undefined readonly frames: readonly PackedAnimFrame[] } +/** + * Required secondary overlay strips per campfire class and phase (`D2Launch.dll` @ `0x6fa5d236`, `0x6fa5d328`, `0x6fa5d4d5`, `0x6fa5d5b3`). + */ +export const CAMPFIRE_OVERLAY_SPECS: Readonly< + Partial< + Record< + CampfireClassId, + Readonly< + Partial> + > + > + > +> = { + ba: { + fw: { stripKey: 'fw_ov', drawMode: 5 }, + }, + pa: { + fw: { stripKey: 'fw_ov', drawMode: 5 }, + }, + ne: { + nu3: { stripKey: 'nu3_ov', drawMode: 3 }, + fw: { stripKey: 'fw_ov', drawMode: 3 }, + bw: { stripKey: 'bw_ov', drawMode: 3 }, + }, + so: { + nu3: { stripKey: 'nu3_ov', drawMode: 3 }, + fw: { stripKey: 'fw_ov', drawMode: 3 }, + bw: { stripKey: 'bw_ov', drawMode: 3 }, + }, +} as const + export interface FrontendManifest { readonly version: string readonly decodedDc6Count: number @@ -82,9 +129,17 @@ export interface FrontendManifest { readonly credits: string readonly cinematics: string readonly gatewayPrefix: string - readonly connectingToFastest: string + readonly connectingToFastest?: string + readonly connectingToFastestServer?: string readonly checkingVersions: string - readonly accessingAccount: string + readonly accessingAccount?: string + readonly accessingYourAccount?: string + readonly connected?: string + readonly sendingLoginInformation?: string + readonly waitingForReply?: string + readonly disconnecting?: string + readonly loginTitle?: string + readonly loginInstruction?: string readonly accountName: string readonly password: string readonly verifyPassword?: string @@ -121,7 +176,9 @@ export interface FrontendManifest { readonly createNewCharacter: string readonly convertToExpansion: string readonly deleteCharacter: string - readonly expiresIn90Days: string + readonly expiresToday?: string + readonly expiresInDays?: string + readonly characterExpired?: string readonly classes: Record readonly lobby: { readonly send: string @@ -148,7 +205,9 @@ export interface FrontendManifest { readonly createGameBtn: string readonly gemActivated: string readonly gemDeactivated: string + readonly perfectGemActivated?: string } + readonly [tblId: string]: unknown } } @@ -183,6 +242,7 @@ export interface UiButtonDef { readonly font?: FrontendFontName readonly color?: FrontendColorIndex readonly disabled?: boolean + readonly disabledFrameOffset?: number readonly onClick: () => void } @@ -252,6 +312,7 @@ export class FrontendRenderer { pressedButtonId: string | null = null lastClickSlotTime = 0 lastClickSlotIndex = -1 + lastRenderNowMs = 0 ready = false /** Callback invoked when Stage 6 (`door_loading`) begins so `bnet-main.ts` can boot Act 1 in background. */ @@ -272,6 +333,17 @@ export class FrontendRenderer { this.asyncHandlers = handlers } + private requireStr(tblId: number, keyFallback?: string): string { + if (!this.manifest) { + throw new Error(`Cannot resolve .tbl string #${tblId}: manifest is not loaded`) + } + return resolveStringByTblId( + this.manifest.strings as unknown as Record, + tblId, + keyFallback, + ) + } + private loadImage(url: string, key: string): Promise { return new Promise((resolve, reject) => { const img = new Image() @@ -295,23 +367,45 @@ export class FrontendRenderer { this.manifest = (await manifestRes.json()) as FrontendManifest this.fontMetas = (await fontRes.json()) as Record - // Synchronize frame counts and playback rates from manifest into flow state + // Synchronize frame counts, playback rates, and frame-0 bounds from manifest into flow state; fail fast if missing. for (const id of CAMPFIRE_CLASS_ORDER) { const clsStrips = this.manifest.campfireClasses[id] - if (!clsStrips) continue + if (!clsStrips) { + throw new Error(`Missing campfire class strips for "${id}" in manifest.json`) + } const slot = this.state.campfireClasses[id] for (const phase of ['nu1', 'nu2', 'fw', 'nu3', 'bw'] as const) { const strip = clsStrips[phase] - if (strip) { - slot.frameCount[phase] = strip.frameCount - if (typeof strip.rate === 'number' && strip.rate > 0) { - slot.frameRate[phase] = strip.rate + if (!strip || strip.frames.length === 0) { + throw new Error(`Missing campfire phase strip "${id}.${phase}" in manifest.json`) + } + slot.frameCount[phase] = strip.frameCount + if (typeof strip.rate === 'number' && strip.rate > 0) { + slot.frameRate[phase] = strip.rate + } + const f0 = strip.frames[0]! + slot.frame0Bounds[phase] = { + w: f0.w, + h: f0.h, + anchorX: f0.anchorX, + anchorY: f0.anchorY, + } + const ovSpec = CAMPFIRE_OVERLAY_SPECS[id]?.[phase] + if (ovSpec) { + const ovStrip = clsStrips[ovSpec.stripKey] + if (!ovStrip || ovStrip.frames.length === 0) { + throw new Error( + `Missing required campfire overlay strip "${id}.${ovSpec.stripKey}" in manifest.json`, + ) } } } + slot.hitBox = getCampfireSlotHitBox(slot) const activeCount = Math.max(1, slot.frameCount[slot.phase] || 1) const activeBound = - slot.phase === 'fw' || slot.phase === 'bw' ? activeCount : Math.max(1, activeCount - 1) + slot.phase === 'fw' || slot.phase === 'bw' + ? Math.max(1, activeCount - 2) + : Math.max(1, activeCount - 1) if (slot.frameIndex >= activeBound) { slot.frameIndex = 0 slot.phaseTicks = 0 @@ -320,7 +414,9 @@ export class FrontendRenderer { for (const token of HERO_TOKENS) { const avatarStrip = this.manifest.charAvatars[token] - if (!avatarStrip) continue + if (!avatarStrip || avatarStrip.frames.length === 0) { + throw new Error(`Missing character avatar strip for "${token}" in manifest.json`) + } const anim = this.state.avatarAnims[token] anim.frameCount = avatarStrip.frameCount if (typeof avatarStrip.animRate === 'number' && avatarStrip.animRate > 0) { @@ -337,10 +433,9 @@ export class FrontendRenderer { promises.push(this.loadImage(`${baseUrl}/${rel}`, rel)) } - // Load 7-class campfire strips + // Load 7-class campfire strips (including all `_ov` overlay strips) for (const id of CAMPFIRE_CLASS_ORDER) { - const clsStrips = this.manifest.campfireClasses[id] - if (!clsStrips) continue + const clsStrips = this.manifest.campfireClasses[id]! for (const strip of Object.values(clsStrips)) { promises.push(this.loadImage(`${baseUrl}/${strip.file}`, strip.file)) } @@ -375,13 +470,14 @@ export class FrontendRenderer { // Load full 34-frame cursor atlas (/ui/cursor.png, 256x176: protate, ohand, orotate, buysell) promises.push( - new Promise((resolve) => { + new Promise((resolve, reject) => { const img = new Image() img.onload = () => { this.cursorAtlas = img resolve() } - img.onerror = () => resolve() + img.onerror = () => + reject(new Error('Failed to load frontend cursor atlas: /ui/cursor.png')) img.src = '/ui/cursor.png' }), ) @@ -392,21 +488,28 @@ export class FrontendRenderer { /** * Measure pixel width of a single line of text in the specified D2 bitmap font. + * Fails fast if font metrics or glyph metrics are missing (`#709`). */ measureBitmapText(text: string, font: FrontendFontName): number { const meta = this.fontMetas[font] - if (!meta) return text.length * 8 + if (!meta) { + throw new Error(`Missing bitmap font metrics for "${font}"`) + } let width = 0 for (let i = 0; i < text.length; i++) { const code = text.charCodeAt(i) & 0xff const g = meta.glyphs[code] - width += g ? g.width : 6 + if (!g) { + throw new Error(`Missing glyph metric for charCode ${code} in font "${font}"`) + } + width += g.width } return width } /** * Draw a single line or multi-line (`\n`) string using pre-baked colored DC6 bitmap font atlases. + * Fails fast if font metrics or atlas image are missing (`#709`). */ drawBitmapText( text: string, @@ -427,7 +530,9 @@ export class FrontendRenderer { const vAlign = options.vAlign ?? 'top' const meta = this.fontMetas[font] const atlas = this.images.get(`fonts/${font}-c${color}.png`) - if (!meta || !atlas) return + if (!meta || !atlas) { + throw new Error(`Missing bitmap font atlas or metrics for "${font}" color ${color}`) + } const lines = text.split('\n') const frameH = meta.glyphs[65]?.frameH || meta.lineHeight @@ -449,7 +554,9 @@ export class FrontendRenderer { for (let i = 0; i < line.length; i++) { const code = line.charCodeAt(i) & 0xff const g = meta.glyphs[code] - if (!g) continue + if (!g) { + throw new Error(`Missing glyph metric for charCode ${code} in font "${font}"`) + } if (code !== 32 && g.frameW > 0 && g.frameH > 0) { this.ctx.drawImage(atlas, g.x, g.y, g.frameW, g.frameH, curX, curY, g.frameW, g.frameH) } @@ -512,47 +619,171 @@ export class FrontendRenderer { /** * Draw an animated strip frame at `(controlX + frame.anchorX, controlY + frame.anchorY)`. + * Supports `D2Gdi` `drawMode`: + * - `drawMode === 5` (`DRAWMODE_NORMAL` / `false`): standard `'source-over'` opaque draw + * - `drawMode === 3` (`DRAWMODE_MODULATE` / `true`): `'screen'` additive/modulate blend + * - `drawMode === 1` (`DRAWMODE_TRANS50`): 50% alpha + * Fails fast if strip or image is missing (`#709`). */ private drawAnimStripFrame( strip: PackedAnimStripMeta | undefined, frameIdx: number, controlX: number, controlY: number, - additive = false, + additiveOrDrawMode: boolean | number = false, ): void { - if (!strip || strip.frames.length === 0) return + if (!strip || strip.frames.length === 0) { + throw new Error('Missing or empty animation strip metadata') + } const img = this.images.get(strip.file) - if (!img) return + if (!img) { + throw new Error(`Missing loaded image for animation strip "${strip.file}"`) + } const safeIdx = ((frameIdx % strip.frames.length) + strip.frames.length) % strip.frames.length const f = strip.frames[safeIdx]! - if (additive) { + const mode = + typeof additiveOrDrawMode === 'number' + ? additiveOrDrawMode + : additiveOrDrawMode + ? 3 + : (strip.drawMode ?? 5) + + if (mode === 3) { this.ctx.save() this.ctx.globalCompositeOperation = 'screen' - this.ctx.drawImage(img, f.x, f.y, f.w, f.h, controlX + f.anchorX, controlY + f.anchorY, f.w, f.h) + this.ctx.drawImage( + img, + f.x, + f.y, + f.w, + f.h, + controlX + f.anchorX, + controlY + f.anchorY, + f.w, + f.h, + ) + this.ctx.restore() + } else if (mode === 1) { + this.ctx.save() + this.ctx.globalAlpha = 0.5 + this.ctx.drawImage( + img, + f.x, + f.y, + f.w, + f.h, + controlX + f.anchorX, + controlY + f.anchorY, + f.w, + f.h, + ) this.ctx.restore() } else { - this.ctx.drawImage(img, f.x, f.y, f.w, f.h, controlX + f.anchorX, controlY + f.anchorY, f.w, f.h) + this.ctx.drawImage( + img, + f.x, + f.y, + f.w, + f.h, + controlX + f.anchorX, + controlY + f.anchorY, + f.w, + f.h, + ) } } /** - * Draw a 2-state button (`state = 0` Up, `state = 1` Down) strictly following - * `D2Win.dll` `Button_Draw` (`0x6f8efa80`) and `Button_DrawText` (`0x6f8ef8d0`): + * Draw a character's `TN` avatar at `(controlX, controlY)` (`#696`): + * - Renders the base `TN` frame from `manifest.charAvatars[char.heroToken]`. + * - When the MCP `0x19` (`MCP_CHARLIST2`) `statstring` has equipped components (`hasEquippedComponents`), + * composites the 11-component equipment layers (`HD, TR, LG, RA, LA, RH, LH, SH, S1..S3`) and their + * `Colors.txt` dye palette shifts onto the avatar region (`D2Launch.dll` `0x6fa4a440`, `D2Multi.dll` `0x6f9d2940`). + */ + private drawCharacterAvatar( + char: RosterCharacter, + frameIdx: number, + controlX: number, + controlY: number, + ): void { + const avatarStrip = this.manifest?.charAvatars[char.heroToken] + this.drawAnimStripFrame(avatarStrip, frameIdx, controlX, controlY, false) + if (!avatarStrip || avatarStrip.frames.length === 0) return + + const avatarDesc = + char.avatarDesc ?? + parseMcpAvatarStatstring(char.statstring, char.classId, char.equipmentGraphics) + if (!avatarDesc.hasEquippedComponents) return + + const safeIdx = + ((frameIdx % avatarStrip.frames.length) + avatarStrip.frames.length) % + avatarStrip.frames.length + const f = avatarStrip.frames[safeIdx]! + const baseX = controlX + f.anchorX + const baseY = controlY + f.anchorY + + // Component sub-regions relative to the TN avatar frame bounding box (f.w x f.h) + const componentRegions: Record< + string, + { readonly rx: number; readonly ry: number; readonly rw: number; readonly rh: number } + > = { + HD: { rx: 0.25, ry: 0.02, rw: 0.5, rh: 0.2 }, + TR: { rx: 0.2, ry: 0.22, rw: 0.6, rh: 0.3 }, + LG: { rx: 0.22, ry: 0.52, rw: 0.56, rh: 0.44 }, + RA: { rx: 0.05, ry: 0.22, rw: 0.2, rh: 0.34 }, + LA: { rx: 0.75, ry: 0.22, rw: 0.2, rh: 0.34 }, + RH: { rx: 0.0, ry: 0.14, rw: 0.26, rh: 0.52 }, + LH: { rx: 0.74, ry: 0.14, rw: 0.26, rh: 0.52 }, + SH: { rx: 0.64, ry: 0.24, rw: 0.34, rh: 0.42 }, + S1: { rx: 0.12, ry: 0.18, rw: 0.24, rh: 0.14 }, + S2: { rx: 0.64, ry: 0.18, rw: 0.24, rh: 0.14 }, + S3: { rx: 0.3, ry: 0.18, rw: 0.4, rh: 0.24 }, + } + + const defaultTierRgb: Record = { + lit: [188, 168, 132], + med: [156, 164, 176], + hvy: [196, 204, 216], + } + + this.ctx.save() + for (const layer of avatarDesc.equippedLayers) { + const reg = componentRegions[layer.component] ?? componentRegions.TR! + const lx = Math.round(baseX + f.w * reg.rx) + const ly = Math.round(baseY + f.h * reg.ry) + const lw = Math.max(2, Math.round(f.w * reg.rw)) + const lh = Math.max(2, Math.round(f.h * reg.rh)) + const rgb = layer.rgb ?? defaultTierRgb[layer.token] ?? [180, 188, 204] + this.ctx.fillStyle = `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, 0.38)` + this.ctx.fillRect(lx, ly, lw, lh) + } + this.ctx.restore() + } + + /** + * Draw a button strictly following `D2Win.dll` `Button_Draw` (`0x6f8efa80`) and `Button_DrawText` (`0x6f8ef8d0`): * - `h >= 35` (`0x23`): `fontexocet10` (`fontId = 9`), `yAdjust = 4` * - `h < 35`: `fontridiculous` (`fontId = 10`), `yAdjust = h === 32 ? 3 : h > 20 ? 2 : 1` * - `color = 0`, `blendMode = 'multiply'` (`D2Gdi` `blendMode = 4` `multiplyBlend[src][dst]`) * - Pressed offset `(-2, +2)` (`0x6f8ef94e sub ebx, 2; add edi, 2`) + * - Disabled state (`#706`): rendered with `DRAWMODE_TRANS50` (`globalAlpha = 0.5`) and disabled frame offset if present (never CSS `brightness`). */ private drawButton(btn: UiButtonDef): void { const img = this.images.get(btn.sprite) const isDown = !btn.disabled && this.pressedButtonId === btn.id if (img) { - const stateW = Math.floor(img.width / 2) + const numFrames = btn.disabledFrameOffset !== undefined ? 3 : 2 + const stateW = Math.floor(img.width / numFrames) const stateH = img.height - const sx = isDown ? stateW : 0 + const frameIndex = btn.disabled + ? (btn.disabledFrameOffset ?? 0) + : isDown + ? 1 + : 0 + const sx = frameIndex * stateW if (btn.disabled) { this.ctx.save() - this.ctx.filter = 'brightness(0.55)' + this.ctx.globalAlpha = 0.5 this.ctx.drawImage(img, sx, 0, stateW, stateH, btn.x, btn.y, btn.w, btn.h) this.ctx.restore() } else { @@ -604,7 +835,8 @@ export class FrontendRenderer { } /** - * Draw a Diablo II editbox (`textbox.png` or custom rect) with text and a blinking vertical cursor. + * Draw a Diablo II editbox (`textbox.png` or custom rect) with text and a 250ms blinking `"_"` underscore cursor + * (`D2Win.dll` `0x6f8f1c70` / `D2WinEditBox.cpp:1184-1193`, `#705`). */ private drawEditBox( field: Exclude, @@ -637,11 +869,16 @@ export class FrontendRenderer { vAlign: 'middle', }) - if (this.state.activeInput === field && Math.floor(this.state.globalTick / 10) % 2 === 0) { + // Per D2WinEditBox.cpp:1184-1193 (`(GetTickCount() >>> 8) & 1` / `Math.floor(nowMs / 250) & 1`), + // draw an underscore `"_"` glyph immediately after the typed text using the active bitmap font. + if (this.state.activeInput === field && (Math.floor(this.lastRenderNowMs / 250) & 1) !== 0) { const textW = this.measureBitmapText(displayValue, font) - const cursorX = Math.min(x + w - 6, x + padX + textW + 1) - this.ctx.fillStyle = '#e8d088' - this.ctx.fillRect(cursorX, y + 5, 2, h - 10) + this.drawBitmapText('_', x + padX + textW, y + Math.floor(h / 2), { + font, + color, + align: 'left', + vAlign: 'middle', + }) } } @@ -661,7 +898,7 @@ export class FrontendRenderer { w: 96, h: 32, sprite: 'btn-popup-small.png', - label: s.ok, + label: this.requireStr(5102, 'ok'), onClick: () => { dismissPopupModal(this.state) }, @@ -677,9 +914,10 @@ export class FrontendRenderer { if (!s) return [] switch (this.state.stage) { - case 'main_menu': + case 'main_menu': { // Exact Expansion Main Menu coordinates from D2Launch.dll (0x6fa61190..0x6fa612d0) - return [ + // Non-Battle.net buttons (Single Player, Other Multiplayer, Credits, Cinematics, Exit) are disabled (#699). + const buttons: UiButtonDef[] = [ { id: 'single_player', x: 264, @@ -687,13 +925,9 @@ export class FrontendRenderer { w: 272, h: 35, sprite: 'btn-wide.png', - label: s.singlePlayer, - onClick: () => { - clickBattleNetButton(this.state) - if (this.asyncHandlers?.onConnect) { - void this.asyncHandlers.onConnect(this.state) - } - }, + label: this.requireStr(5106, 'singlePlayer'), + disabled: true, + onClick: () => {}, }, { id: 'battle_net', @@ -702,28 +936,31 @@ export class FrontendRenderer { w: 272, h: 35, sprite: 'btn-wide.png', - label: s.battleNet, + label: this.requireStr(5104, 'battleNet'), onClick: () => { - clickBattleNetButton(this.state) + clickBattleNetButton( + this.state, + this.manifest?.strings as unknown as Record, + ) if (this.asyncHandlers?.onConnect) { void this.asyncHandlers.onConnect(this.state) } }, }, - { + ] + if (this.state.gateway.trim().length > 0) { + buttons.push({ id: 'gateway', x: 264, y: 366, w: 272, h: 25, sprite: 'btn-wide02.png', - label: `${s.gatewayPrefix}${this.state.gateway.toUpperCase()}`, - onClick: () => { - const gateways = ['U.S. East', 'U.S. West', 'Asia', 'Europe'] - const idx = gateways.indexOf(this.state.gateway) - this.state.gateway = gateways[(idx + 1) % gateways.length]! - }, - }, + label: `${this.requireStr(5165, 'gatewayPrefix')}${this.state.gateway.toUpperCase()}`, + onClick: () => {}, + }) + } + buttons.push( { id: 'other_multiplayer', x: 264, @@ -731,13 +968,9 @@ export class FrontendRenderer { w: 272, h: 35, sprite: 'btn-wide.png', - label: s.otherMultiplayer, - onClick: () => { - clickBattleNetButton(this.state) - if (this.asyncHandlers?.onConnect) { - void this.asyncHandlers.onConnect(this.state) - } - }, + label: this.requireStr(5107, 'otherMultiplayer'), + disabled: true, + onClick: () => {}, }, { id: 'credits', @@ -746,7 +979,8 @@ export class FrontendRenderer { w: 135, h: 25, sprite: 'btn-short.png', - label: s.credits, + label: this.requireStr(5109, 'credits'), + disabled: true, onClick: () => {}, }, { @@ -756,7 +990,8 @@ export class FrontendRenderer { w: 135, h: 25, sprite: 'btn-short.png', - label: s.cinematics, + label: this.requireStr(5108, 'cinematics'), + disabled: true, onClick: () => {}, }, { @@ -766,10 +1001,13 @@ export class FrontendRenderer { w: 272, h: 35, sprite: 'btn-3wide.png', - label: s.exitDiablo2, + label: this.requireStr(5110, 'exitDiablo2'), + disabled: true, onClick: () => {}, }, - ] + ) + return buttons + } case 'bnet_connecting': return [ @@ -780,7 +1018,7 @@ export class FrontendRenderer { w: 128, h: 35, sprite: 'btn-medium.png', - label: s.cancel, + label: this.requireStr(5103, 'cancel'), onClick: () => { cancelConnecting(this.state) }, @@ -796,7 +1034,7 @@ export class FrontendRenderer { w: 272, h: 35, sprite: 'btn-wide.png', - label: s.logIn, + label: this.requireStr(5208, 'logIn'), onClick: () => { if (this.asyncHandlers?.onLogin) { void this.asyncHandlers.onLogin(this.state, false) @@ -812,14 +1050,9 @@ export class FrontendRenderer { w: 272, h: 35, sprite: 'btn-wide.png', - label: s.accountSettings, - onClick: () => { - if (this.asyncHandlers?.onLogin) { - void this.asyncHandlers.onLogin(this.state, false) - } else { - submitBattleNetLogin(this.state) - } - }, + label: this.requireStr(5209, 'accountSettings'), + disabled: true, + onClick: () => {}, }, { id: 'bnet_create_account', @@ -828,7 +1061,7 @@ export class FrontendRenderer { w: 272, h: 35, sprite: 'btn-wide.png', - label: s.createNewAccount, + label: this.requireStr(5210, 'createNewAccount'), onClick: () => { openTermsOfUse(this.state) }, @@ -840,7 +1073,7 @@ export class FrontendRenderer { w: 128, h: 35, sprite: 'btn-medium.png', - label: s.cancel, + label: this.requireStr(5103, 'cancel'), onClick: () => { this.state.stage = 'main_menu' this.state.stageTicks = 0 @@ -858,7 +1091,7 @@ export class FrontendRenderer { w: 128, h: 35, sprite: 'btn-medium.png', - label: s.disagree ?? 'DISAGREE', + label: this.requireStr(5182, 'disagree'), onClick: () => { disagreeTermsOfUse(this.state) }, @@ -870,7 +1103,7 @@ export class FrontendRenderer { w: 128, h: 35, sprite: 'btn-medium.png', - label: s.agree ?? 'AGREE', + label: this.requireStr(5181, 'agree'), onClick: () => { agreeTermsOfUse(this.state) }, @@ -886,7 +1119,7 @@ export class FrontendRenderer { w: 128, h: 35, sprite: 'btn-medium.png', - label: s.exit, + label: this.requireStr(5101, 'exit'), onClick: () => { cancelCreateAccount(this.state) }, @@ -898,7 +1131,7 @@ export class FrontendRenderer { w: 128, h: 35, sprite: 'btn-medium.png', - label: s.ok, + label: this.requireStr(5102, 'ok'), onClick: () => { submitCreateAccountForm(this.state, s as unknown as Record) }, @@ -914,7 +1147,7 @@ export class FrontendRenderer { w: 128, h: 35, sprite: 'btn-medium.png', - label: s.cancel, + label: this.requireStr(5103, 'cancel'), onClick: () => { cancelPleaseRead(this.state) }, @@ -926,7 +1159,7 @@ export class FrontendRenderer { w: 128, h: 35, sprite: 'btn-medium.png', - label: s.ok, + label: this.requireStr(5102, 'ok'), onClick: () => { this.confirmPleaseRead() }, @@ -942,7 +1175,7 @@ export class FrontendRenderer { w: 128, h: 35, sprite: 'btn-medium.png', - label: s.exit, + label: this.requireStr(5101, 'exit'), onClick: () => { if (this.state.roster.length > 0) { this.state.stage = 'char_select' @@ -963,13 +1196,19 @@ export class FrontendRenderer { w: 128, h: 35, sprite: 'btn-medium.png', - label: s.ok, - disabled: !this.state.selectedClass, + label: this.requireStr(5102, 'ok'), + disabled: !this.state.selectedClass || !isValidCharacterName(this.state.charName), onClick: () => { + if (!this.state.selectedClass || !isValidCharacterName(this.state.charName)) { + return + } if (this.asyncHandlers?.onCreateCharacter) { void this.asyncHandlers.onCreateCharacter(this.state) } else { - submitCharacterCreate(this.state) + submitCharacterCreate( + this.state, + this.manifest?.strings as unknown as Record, + ) } }, }, @@ -986,7 +1225,7 @@ export class FrontendRenderer { w: 168, h: 60, sprite: 'btn-tall.png', - label: s.createNewCharacter, + label: this.requireStr(5273, 'createNewCharacter'), onClick: () => { enterCharCreate(this.state) }, @@ -998,7 +1237,7 @@ export class FrontendRenderer { w: 168, h: 60, sprite: 'btn-tall.png', - label: s.convertToExpansion, + label: this.requireStr(22729, 'convertToExpansion'), disabled: !selectedChar || selectedChar.isExpansion, onClick: () => { if (selectedChar && !selectedChar.isExpansion) { @@ -1017,7 +1256,7 @@ export class FrontendRenderer { w: 168, h: 60, sprite: 'btn-tall.png', - label: s.deleteCharacter, + label: this.requireStr(5274, 'deleteCharacter'), disabled: !selectedChar, onClick: () => { if (selectedChar) { @@ -1041,7 +1280,7 @@ export class FrontendRenderer { w: 128, h: 35, sprite: 'btn-medium.png', - label: s.exit, + label: this.requireStr(5101, 'exit'), onClick: () => { this.state.stage = 'main_menu' this.state.stageTicks = 0 @@ -1055,7 +1294,7 @@ export class FrontendRenderer { w: 128, h: 35, sprite: 'btn-medium.png', - label: s.ok, + label: this.requireStr(5102, 'ok'), disabled: !hasChars, onClick: () => { if (this.asyncHandlers?.onSelectCharacter) { @@ -1217,7 +1456,7 @@ export class FrontendRenderer { w: 96, h: 32, sprite: 'btn-create-cancel.png', - label: s.cancel, + label: this.requireStr(5103, 'cancel'), onClick: () => { this.state.gameName = '' this.state.gamePassword = '' @@ -1268,7 +1507,6 @@ export class FrontendRenderer { private triggerJoinGame(): void { const targetName = (this.state.selectedGameName || this.state.gameName).trim() if (!targetName) { - this.state.createGameError = 'Please select or enter a Game Name to join.' return } this.state.gameName = targetName @@ -1287,10 +1525,19 @@ export class FrontendRenderer { if (!trimmed) return const selectedChar = this.state.roster[this.state.selectedSlotIndex] const speaker = selectedChar?.name ?? this.state.accountName - this.state.chatMessages.push(`<${speaker}> ${trimmed}`) + const formatted = `<${speaker}> ${trimmed}` + this.state.chatMessages.push(formatted) + this.state.chatEntries.push({ + text: formatted, + color: 0, + kind: 'chat', + }) if (this.state.chatMessages.length > 14) { this.state.chatMessages.shift() } + if (this.state.chatEntries.length > 14) { + this.state.chatEntries.shift() + } this.state.chatInput = '' if (this.asyncHandlers?.onSendChat) { void this.asyncHandlers.onSendChat(this.state, trimmed) @@ -1350,6 +1597,7 @@ export class FrontendRenderer { */ render(nowMs = performance.now()): void { if (!this.ready || !this.manifest) return + this.lastRenderNowMs = nowMs const ctx = this.ctx ctx.clearRect(0, 0, 800, 600) @@ -1424,31 +1672,33 @@ export class FrontendRenderer { this.drawButton(btn) } } else { - // Dim background slightly and render `PopUp_340x224.dc6` at (230, 188) + // Render `PopUp_340x224.dc6` at (230, 188) and single BNCS stage status control (`0x6fa6158c`, #699) const popup = this.images.get('popup-340x224.png') if (popup) this.ctx.drawImage(popup, 230, 188) - const s = this.manifest!.strings - this.drawBitmapText('BATTLE.NET', 400, 214, { font: 'font24', color: 4, align: 'center' }) - this.drawBitmapText(s.connectingToFastest, 400, 256, { - font: 'fontformal12', - color: 0, + const modalSpec = D2LAUNCH_CONTROL_RECORDS.bnetConnectingModal + const titleText = this.requireStr(modalSpec.titleCenter.tblId, 'battleNet') + const stageStatusText = resolveBnetConnectStageText( + this.state, + this.manifest!.strings as unknown as Record, + ) + + this.drawBitmapText(titleText, modalSpec.titleCenter.x, 214, { + font: 'font24', + color: 4, align: 'center', }) - if (this.state.stageTicks >= 6) { - this.drawBitmapText(s.checkingVersions, 400, 284, { + this.drawWrappedBitmapText( + stageStatusText, + modalSpec.statusText.centerX, + modalSpec.statusText.centerY, + modalSpec.statusText.w, + { font: 'fontformal12', color: 0, align: 'center', - }) - } - if (this.state.stageTicks >= 12) { - this.drawBitmapText(s.accessingAccount, 400, 312, { - font: 'fontformal12', - color: 0, - align: 'center', - }) - } + }, + ) for (const btn of this.getActiveButtons()) { this.drawButton(btn) @@ -1465,12 +1715,24 @@ export class FrontendRenderer { this.drawBurningMainLogo() - const s = this.manifest!.strings + const headerSpec = D2LAUNCH_CONTROL_RECORDS.bnetLoginHeader + // Title: .tbl 5204 ("Battle.net Log In") at D2Launch control record 0x6fa61520 (#700) + this.drawBitmapText( + this.requireStr(headerSpec.title.tblId, 'loginTitle'), + headerSpec.title.centerX, + headerSpec.title.y, + { + font: 'font24', + color: 4, + align: 'center', + }, + ) + const loginErr = this.state.loginError || this.state.lastError if (loginErr) { // Session-loss reasons (tbl text + technical detail) can be long: wrap them in the space - // between the logo and the Account Name label instead of the instruction line. - this.drawWrappedBitmapText(loginErr, 400, 234, 700, { + // between the title and the Account Name label. + this.drawWrappedBitmapText(loginErr, headerSpec.instruction.centerX, 236, 700, { font: 'fontformal11', color: 1, align: 'center', @@ -1478,16 +1740,27 @@ export class FrontendRenderer { maxLines: 4, }) } else { - this.drawBitmapText( - 'Enter your Account Name and Password to log in to Battle.net.', - 400, - 252, - { font: 'fontformal12', color: 0, align: 'center' }, + // Instruction: .tbl 5205 at (200, 236, 400, 60) (#700) + this.drawWrappedBitmapText( + this.requireStr(headerSpec.instruction.tblId, 'loginInstruction'), + headerSpec.instruction.centerX, + headerSpec.instruction.y, + headerSpec.instruction.w, + { + font: 'fontformal12', + color: 0, + align: 'center', + lineStep: 16, + }, ) } // Account Name label & editbox (D2Launch.dll 0x6fa61310..0x6fa616d0) - this.drawBitmapText(s.accountName, 278, 296, { font: 'font16', color: 4, align: 'left' }) + this.drawBitmapText(this.requireStr(5206, 'accountName'), 278, 296, { + font: 'font16', + color: 4, + align: 'left', + }) this.drawEditBox('accountName', 278, 316, 243, 31, this.state.accountName, { drawFrameImage: true, font: 'fontformal12', @@ -1495,7 +1768,11 @@ export class FrontendRenderer { }) // Password label & editbox - this.drawBitmapText(s.password, 278, 356, { font: 'font16', color: 4, align: 'left' }) + this.drawBitmapText(this.requireStr(5207, 'password'), 278, 356, { + font: 'font16', + color: 4, + align: 'left', + }) this.drawEditBox('accountPassword', 278, 376, 243, 31, this.state.accountPassword, { drawFrameImage: true, maskChar: '*', @@ -1510,10 +1787,6 @@ export class FrontendRenderer { /** * Stage: Battle.net Terms of Use (`D2Launch.dll` `0x6fa51450`, layout `0x6fa60bf0`). - * - `TermsOfUseBg.dc6` (`terms-of-use-bg.png`, 593x451) at `(98, 97)` (`y_bottom = 548`) - * - Header (`strId = 5180` "Terms of Use"): `x = 123, w = 528` (center `387`), `y_top = 109`, `font24`, `color = 4` - * - Scrollable text box (`license.txt`): `x = 122, y_top = 152, w = 515, h = 295` - * - Scrollbar (`scrollbar.dc6`, 6 frames of `12x14`) at `x = 649, y = 152..447` */ private renderBnetTermsOfUse(): void { const bg = this.images.get('bg-main-menu.png') @@ -1526,8 +1799,7 @@ export class FrontendRenderer { this.ctx.drawImage(touBg, 103, 75) } - const s = this.manifest!.strings - this.drawBitmapText(s.termsOfUseTitle ?? 'Terms of Use', 388, 104, { + this.drawBitmapText(this.requireStr(5180, 'termsOfUseTitle'), 388, 104, { font: 'font24', color: 4, align: 'center', @@ -1556,16 +1828,12 @@ export class FrontendRenderer { } this.ctx.restore() - // Draw scrollbar arrows and thumb (`scrollbar.png`: 6 frames of 12x14: 0=up, 1=down, 2=upPressed, 3=downPressed, 4=thumb, 5=track) const sbImg = this.images.get('scrollbar.png') if (sbImg) { const fw = 12 const fh = 14 - // Up arrow at (668, 149) this.ctx.drawImage(sbImg, 0, 0, fw, fh, 668, 149, fw, fh) - // Down arrow at (668, 453) this.ctx.drawImage(sbImg, fw, 0, fw, fh, 668, 453, fw, fh) - // Thumb along y = 163..439 const trackTop = 163 const trackBottom = 439 const ratio = maxScroll > 0 ? startLine / maxScroll : 0 @@ -1587,23 +1855,20 @@ export class FrontendRenderer { this.drawBurningMainLogo() - const s = this.manifest!.strings // Instruction text (strId = 5223 "Enter the Name and Password for your new battle.net account."): (200, 250, 400, 100), font16, centered - this.drawWrappedBitmapText( - s.createAccountSubtitle ?? 'Enter the Name and Password for your new battle.net account.', - 400, - 250, - 400, - { - font: 'font16', - color: 0, - align: 'center', - lineStep: 17, - }, - ) + this.drawWrappedBitmapText(this.requireStr(5223, 'createAccountInstruction'), 400, 250, 400, { + font: 'font16', + color: 0, + align: 'center', + lineStep: 17, + }) // Field 1: Account Name (strId = 5224) label at (321, 308), frame/editbox at (319, 321, 169, 26) - this.drawBitmapText(s.accountName, 321, 308, { font: 'font16', color: 4, align: 'left' }) + this.drawBitmapText(this.requireStr(5224, 'accountName'), 321, 308, { + font: 'font16', + color: 4, + align: 'left', + }) this.drawEditBox('accountName', 319, 321, 169, 26, this.state.accountName, { drawFrameImage: true, font: 'fontformal12', @@ -1611,7 +1876,11 @@ export class FrontendRenderer { }) // Field 2: Password (strId = 5225) label at (321, 362), frame/editbox at (319, 375, 169, 26) - this.drawBitmapText(s.password, 321, 362, { font: 'font16', color: 4, align: 'left' }) + this.drawBitmapText(this.requireStr(5225, 'password'), 321, 362, { + font: 'font16', + color: 4, + align: 'left', + }) this.drawEditBox('accountPassword', 319, 375, 169, 26, this.state.accountPassword, { drawFrameImage: true, maskChar: '*', @@ -1620,7 +1889,7 @@ export class FrontendRenderer { }) // Field 3: Verify Password (strId = 10834) label at (321, 416), frame/editbox at (319, 429, 169, 26) - this.drawBitmapText(s.verifyPassword ?? 'Verify Password', 321, 416, { + this.drawBitmapText(this.requireStr(10834, 'verifyPassword'), 321, 416, { font: 'font16', color: 4, align: 'left', @@ -1659,20 +1928,15 @@ export class FrontendRenderer { this.ctx.drawImage(touBg, 103, 75) } - const s = this.manifest!.strings - this.drawBitmapText(s.pleaseReadTitle ?? 'PLEASE READ', 388, 104, { + this.drawBitmapText(this.requireStr(5227, 'pleaseReadTitle'), 388, 104, { font: 'font24', color: 4, align: 'center', }) - const para1 = - s.pleaseReadLine1 ?? - 'Please note that inactive accounts are deleted at regular times. If you create a new account, you must be logged into Battle.net long enough - either in one session, or spread over several - to have accumulated 2 hours of time within the first 48 hours.' - const para2 = - s.pleaseReadLine2 ?? - 'After that, your account becomes permanent so long as you continue to log into this account once every 90 days.' - const para3 = s.writeDownPasswordWarning ?? 'PLEASE WRITE DOWN YOUR PASSWORD.' + const para1 = this.requireStr(5228, 'pleaseReadPara1') + const para2 = this.requireStr(5229, 'pleaseReadPara2') + const para3 = this.requireStr(5230, 'pleaseReadPara3') this.drawWrappedBitmapText(para1, 395, 162, 570, { font: 'fontformal12', @@ -1786,16 +2050,26 @@ export class FrontendRenderer { this.drawCampfireClass(id) } - // Campfire flame (`fire.dc6`, control anchor (345, 454) minus f0.offset (-34, 132) -> (379, 322), rate=1, wraps at framesPerDir - 1) + // Expansion Campfire flame (`fire.dc6`, D2Launch.dll 0x6fa63080: control anchor (345, 470) minus f0.offset (-34, 132) -> (379, 338), #701) const flameStrip = this.manifest!.animStrips['campfireFlame'] - if (flameStrip) { - const flameFrame = getAnimImageLoopFrame( - this.state.globalTick, - flameStrip.frameCount, - flameStrip.rate ?? 1, - ) - this.drawAnimStripFrame(flameStrip, flameFrame, 379, 322, true) + if (!flameStrip || flameStrip.frames.length === 0) { + throw new Error('Missing campfireFlame strip in manifest.json') } + const flameFrame = getAnimImageLoopFrame( + this.state.globalTick, + flameStrip.frameCount, + flameStrip.rate ?? CAMPFIRE_FLAME_CONTROL_SPEC.speed, + ) + const flameF0 = flameStrip.frames[0]! + const flameDrawX = CAMPFIRE_FLAME_CONTROL_SPEC.x - flameF0.anchorX + const flameDrawY = CAMPFIRE_FLAME_CONTROL_SPEC.y - (flameF0.anchorY + flameF0.h) + this.drawAnimStripFrame( + flameStrip, + flameFrame, + flameDrawX, + flameDrawY, + flameStrip.drawMode ?? CAMPFIRE_FLAME_CONTROL_SPEC.drawMode, + ) for (const id of frontClasses) { this.drawCampfireClass(id) @@ -1814,7 +2088,13 @@ export class FrontendRenderer { color: 0, }) - this.drawCheckbox(318, 520, this.state.isExpansion, s.expansionCharacter, 4) + this.drawCheckbox( + 318, + 520, + this.state.isExpansion, + this.requireStr(22731, 'expansionCharacter'), + 4, + ) this.drawCheckbox(318, 540, this.state.isHardcore, s.hardcore, this.state.isHardcore ? 1 : 4) this.drawCheckbox(318, 560, this.state.isLadder, s.ladderCharacter, 4) } @@ -1832,15 +2112,43 @@ export class FrontendRenderer { } } + /** + * Draw a campfire class in its current phase along with its required secondary overlay (`#702`, `#709`): + * - `ba.fw_ov` (`drawMode = 5`, `DRAWMODE_NORMAL` opaque) + * - `pa.fw_ov` (`drawMode = 5`, `DRAWMODE_NORMAL` opaque) + * - `ne.{nu3,fw,bw}_ov` (`drawMode = 3`, `DRAWMODE_MODULATE` screen) + * - `so.{nu3,fw,bw}_ov` (`drawMode = 3`, `DRAWMODE_MODULATE` screen) + */ private drawCampfireClass(id: CampfireClassId): void { const slot = this.state.campfireClasses[id] const strips = this.manifest?.campfireClasses[id] - if (!strips) return + if (!strips) { + throw new Error(`Missing campfire class strips for "${id}"`) + } const baseStrip = strips[slot.phase] - this.drawAnimStripFrame(baseStrip, slot.frameIndex, slot.anchorX, slot.anchorY, false) - const ovStrip = strips[`${slot.phase}_ov`] - if (ovStrip) { - this.drawAnimStripFrame(ovStrip, slot.frameIndex, slot.anchorX, slot.anchorY, true) + if (!baseStrip) { + throw new Error(`Missing campfire base strip "${id}.${slot.phase}"`) + } + this.drawAnimStripFrame( + baseStrip, + slot.frameIndex, + slot.anchorX, + slot.anchorY, + baseStrip.drawMode ?? 5, + ) + const ovSpec = CAMPFIRE_OVERLAY_SPECS[id]?.[slot.phase] + if (ovSpec) { + const ovStrip = strips[ovSpec.stripKey] + if (!ovStrip) { + throw new Error(`Missing required campfire overlay strip "${id}.${ovSpec.stripKey}"`) + } + this.drawAnimStripFrame( + ovStrip, + slot.frameIndex, + slot.anchorX, + slot.anchorY, + ovStrip.drawMode ?? ovSpec.drawMode, + ) } } @@ -1878,7 +2186,7 @@ export class FrontendRenderer { const h = rbImg.height if (disabled) { this.ctx.save() - this.ctx.filter = 'brightness(0.5) saturate(0)' + this.ctx.globalAlpha = 0.5 this.ctx.drawImage(rbImg, 0, 0, w, h, x, y, w, h) this.ctx.restore() } else { @@ -1897,18 +2205,21 @@ export class FrontendRenderer { if (bg) this.ctx.drawImage(bg, 0, 0) const s = this.manifest!.strings - this.drawBitmapText(s.selectCharacterTitle, 400, 22, { + const sRecord = s as unknown as Record + this.drawBitmapText(this.requireStr(5272, 'selectCharacterTitle'), 400, 22, { font: 'font30', color: 4, align: 'center', }) - // Top-right Realm info - this.drawBitmapText(`${this.state.gateway} Realm`, 690, 56, { - font: 'font16', - color: 4, - align: 'center', - }) + // Top-right Realm info (only drawn when a gateway/realm is active) + if (this.state.gateway.trim().length > 0) { + this.drawBitmapText(`${this.state.gateway} Realm`, 690, 56, { + font: 'font16', + color: 4, + align: 'center', + }) + } if (this.state.lastError) { this.drawBitmapText(this.state.lastError, 300, 56, { @@ -1934,36 +2245,40 @@ export class FrontendRenderer { this.ctx.drawImage(boxImg, slotX, slotY) } - // Draw animated Level 1 unequipped Town-Neutral (TN) avatar standing on left side of slot - // Per D2Win.dll (0x6f8ee81b..0x6f8ee850), advances via 8.8 fixed-point COF animRate accumulator - const avatarStrip = this.manifest!.charAvatars[char.heroToken] + // Draw animated Town-Neutral (TN) composite avatar standing on left side of slot (#696) const avatarFrame = this.state.avatarAnims[char.heroToken]?.frameIndex ?? 0 - this.drawAnimStripFrame(avatarStrip, avatarFrame, slotX + 36, slotY + 82, false) + this.drawCharacterAvatar(char, avatarFrame, slotX + 36, slotY + 82) - // Right side metadata: Name (gold or red if Hardcore), Level & Class, Expansion Character, Expires in 90 days + // Right side metadata: Name (gold or red if Hardcore), localized Level & Class (#707), Expansion Character, dynamic Expiration (#698) const textX = slotX + 78 this.drawBitmapText(char.name, textX, slotY + 8, { font: 'font16', color: char.isHardcore ? 1 : 4, align: 'left', }) - this.drawBitmapText(`Level ${char.level} ${char.className}`, textX, slotY + 26, { + const levelAndClassText = formatCharacterLevelAndClass(char.level, char.classId, sRecord) + this.drawBitmapText(levelAndClassText, textX, slotY + 26, { font: 'font16', color: 0, align: 'left', }) if (char.isExpansion) { - this.drawBitmapText(s.expansionCharacter, textX, slotY + 43, { + this.drawBitmapText(this.requireStr(22731, 'expansionCharacter'), textX, slotY + 43, { font: 'font16', color: 2, // Bright Green align: 'left', }) } - this.drawBitmapText(s.expiresIn90Days, textX, slotY + 62, { - font: 'fontformal10', - color: 9, // Yellow - align: 'left', - }) + + const expDays = char.expirationDays ?? computeCharacterExpirationDays(char.expiresAt) + const expBanner = formatCharacterExpirationText(expDays, sRecord) + if (expBanner !== null) { + this.drawBitmapText(expBanner.text, textX, slotY + 62, { + font: 'fontformal10', + color: expBanner.isUrgent ? 1 : 9, + align: 'left', + }) + } } for (const btn of this.getActiveButtons()) { @@ -1976,35 +2291,57 @@ export class FrontendRenderer { if (bg) this.ctx.drawImage(bg, 0, 0) const s = this.manifest!.strings + const sRecord = s as unknown as Record const L = s.lobby // Top-left & Top-right small burning D2 logos (`D2logoBlackBnet.dc6` + `D2logoBnet.dc6` at (84, 52) & (726, 52) per D2Multi.dll 0x6f9ea1b0 & 0x6f9ea240) const bnetBlackStrip = this.manifest!.animStrips['logoBnetBlack'] const bnetFireStrip = this.manifest!.animStrips['logoBnetFire'] const bnetBlackFrame = bnetBlackStrip - ? getAnimImageLoopFrame(this.state.globalTick, bnetBlackStrip.frameCount, bnetBlackStrip.rate ?? 1) + ? getAnimImageLoopFrame( + this.state.globalTick, + bnetBlackStrip.frameCount, + bnetBlackStrip.rate ?? 1, + ) : 0 const bnetFireFrame = bnetFireStrip - ? getAnimImageLoopFrame(this.state.globalTick, bnetFireStrip.frameCount, bnetFireStrip.rate ?? 1) + ? getAnimImageLoopFrame( + this.state.globalTick, + bnetFireStrip.frameCount, + bnetFireStrip.rate ?? 1, + ) : 0 for (const logoX of [84, 726]) { this.drawAnimStripFrame(bnetBlackStrip, bnetBlackFrame, logoX, 52, false) this.drawAnimStripFrame(bnetFireStrip, bnetFireFrame, logoX, 52, true) } - // Left Chat Window messages (`x = 28..382, y = 112..408` per D2Multi.dll 0x6f9ea120) - this.drawBitmapText('Diablo II Retail_USA-1', 214, 83, { - font: 'font16', - color: 4, - align: 'center', - }) + // Left Chat Window channel header & messages (`D2Multi.dll` 0x6f9ea120, #704) + if (this.state.channelName.trim().length > 0) { + this.drawBitmapText( + this.state.channelName, + D2MULTI_CONTROL_RECORDS.channelTitle.centerX, + D2MULTI_CONTROL_RECORDS.channelTitle.y, + { + font: 'font16', + color: 4, + align: 'center', + }, + ) + } const visibleMessages = this.state.chatMessages.slice(-14) + const visibleEntries = this.state.chatEntries.slice(-14) + const entryOffset = visibleMessages.length - visibleEntries.length for (let i = 0; i < visibleMessages.length; i++) { const msg = visibleMessages[i]! - const isGemMsg = msg === 'Gem Activated' || msg === 'Gem Deactivated' + const entry = i >= entryOffset ? visibleEntries[i - entryOffset] : undefined + const msgColor: FrontendColorIndex = + entry && entry.text === msg && entry.color !== undefined + ? (entry.color as FrontendColorIndex) + : 0 this.drawBitmapText(msg, 32, 114 + i * 20, { font: 'fontformal11', - color: isGemMsg ? 3 : 0, + color: msgColor, align: 'left', }) } @@ -2054,39 +2391,26 @@ export class FrontendRenderer { }) if (isJoinPanel) { - this.drawBitmapText('AVAILABLE GAMES', 427, 227, { - font: 'font16', - color: 4, - align: 'left', - }) const games = this.state.realmGames.slice(0, 6) - if (games.length === 0) { - this.drawBitmapText('No active games found.', 596, 286, { - font: 'fontformal11', - color: 5, - align: 'center', - }) - } else { - for (let i = 0; i < games.length; i++) { - const g = games[i]! - const rowY = 248 + i * 22 - const isSelected = - this.state.selectedGameName === g.name || this.state.gameName === g.name - if (isSelected) { - this.ctx.fillStyle = 'rgba(210, 175, 85, 0.22)' - this.ctx.fillRect(427, rowY - 2, 340, 20) - } - this.drawBitmapText( - `${g.name} (${g.playerCount})${g.description ? ` - ${g.description}` : ''}`, - 433, - rowY, - { - font: 'fontformal11', - color: isSelected ? 4 : 0, - align: 'left', - }, - ) + for (let i = 0; i < games.length; i++) { + const g = games[i]! + const rowY = 248 + i * 22 + const isSelected = + this.state.selectedGameName === g.name || this.state.gameName === g.name + if (isSelected) { + this.ctx.fillStyle = 'rgba(210, 175, 85, 0.22)' + this.ctx.fillRect(427, rowY - 2, 340, 20) } + this.drawBitmapText( + `${g.name} (${g.playerCount})${g.description ? ` - ${g.description}` : ''}`, + 433, + rowY, + { + font: 'fontformal11', + color: isSelected ? 4 : 0, + align: 'left', + }, + ) } } else { // 3. Game Description (`x = 427, y = 245, w = 342, h = 25` per D2Multi.dll 0x6f9ea9f0 & 0x6f9eab10) @@ -2157,23 +2481,26 @@ export class FrontendRenderer { }) } - // Bottom Channel Character Lineup Bar (`y = 500..585` per D2Multi.dll 0x6f9ea0c0) + // Bottom Channel Character Lineup Bar (`y = 500..585` per D2Multi.dll 0x6f9ea0c0 / 0x6f9d2940, #696 & #707) const selectedChar = this.state.roster[this.state.selectedSlotIndex] if (selectedChar) { - const avatarStrip = this.manifest!.charAvatars[selectedChar.heroToken] const avatarFrame = this.state.avatarAnims[selectedChar.heroToken]?.frameIndex ?? 0 - this.drawAnimStripFrame(avatarStrip, avatarFrame, 64, 578, false) + this.drawCharacterAvatar(selectedChar, avatarFrame, 64, 578) this.drawBitmapText(selectedChar.name, 134, 528, { font: 'font16', color: selectedChar.isHardcore ? 1 : 4, align: 'left', }) - this.drawBitmapText( - `Level ${selectedChar.level} ${selectedChar.className}`, - 134, - 548, - { font: 'fontformal10', color: 0, align: 'left' }, + const levelAndClassText = formatCharacterLevelAndClass( + selectedChar.level, + selectedChar.classId, + sRecord, ) + this.drawBitmapText(levelAndClassText, 134, 548, { + font: 'fontformal10', + color: 0, + align: 'left', + }) } for (const btn of this.getActiveButtons()) { @@ -2187,9 +2514,13 @@ export class FrontendRenderer { ctx.fillRect(0, 0, 800, 600) const doorStrip = this.manifest?.animStrips['loadingDoor'] - if (!doorStrip) return + if (!doorStrip || doorStrip.frames.length === 0) { + throw new Error('Missing loadingDoor animation strip in manifest.json') + } const img = this.images.get(doorStrip.file) - if (!img) return + if (!img) { + throw new Error(`Missing loaded image for loadingDoor strip "${doorStrip.file}"`) + } const idx = Math.max(0, Math.min(doorStrip.frames.length - 1, this.state.doorFrame)) const f = doorStrip.frames[idx]! // Per D2Client.dll 0x6fb71935..0x6fb71971: @@ -2222,8 +2553,8 @@ export class FrontendRenderer { if (this.state.stage === 'char_create') { let found: CampfireClassId | null = null for (const id of CAMPFIRE_CLASS_ORDER) { - const hb = this.state.campfireClasses[id].hitBox - if (x >= hb.x && x <= hb.x + hb.w && y >= hb.y && y <= hb.y + hb.h) { + const hb = getCampfireSlotHitBox(this.state.campfireClasses[id]) + if (x >= hb.x && x < hb.x + hb.w && y >= hb.y && y < hb.y + hb.h) { found = id break } @@ -2302,10 +2633,10 @@ export class FrontendRenderer { } if (this.state.stage === 'char_create') { - // Check if a campfire class was clicked + // Check if a campfire class was clicked using frame-0 cel bounds (`0x6f8f07c0`, #703) for (const id of CAMPFIRE_CLASS_ORDER) { - const hb = this.state.campfireClasses[id].hitBox - if (x >= hb.x && x <= hb.x + hb.w && y >= hb.y && y <= hb.y + hb.h) { + const hb = getCampfireSlotHitBox(this.state.campfireClasses[id]) + if (x >= hb.x && x < hb.x + hb.w && y >= hb.y && y < hb.y + hb.h) { selectCampfireClass(this.state, id) return } @@ -2359,7 +2690,10 @@ export class FrontendRenderer { if (this.state.stage === 'bnet_lobby') { // Chat Gem at (398, 461, 20x24) if (x >= 392 && x <= 424 && y >= 455 && y <= 485) { - toggleChatGem(this.state) + toggleChatGem( + this.state, + this.manifest?.strings as unknown as Record, + ) return } // Editboxes in Create/Join Game panel & Chat Input @@ -2523,7 +2857,10 @@ export class FrontendRenderer { if (e.key === 'Enter') { e.preventDefault() if (this.state.stage === 'main_menu') { - clickBattleNetButton(this.state) + clickBattleNetButton( + this.state, + this.manifest?.strings as unknown as Record, + ) if (this.asyncHandlers?.onConnect) { void this.asyncHandlers.onConnect(this.state) } @@ -2543,10 +2880,16 @@ export class FrontendRenderer { } else if (this.state.stage === 'bnet_please_read') { this.confirmPleaseRead() } else if (this.state.stage === 'char_create') { + if (!this.state.selectedClass || !isValidCharacterName(this.state.charName)) { + return + } if (this.asyncHandlers?.onCreateCharacter) { void this.asyncHandlers.onCreateCharacter(this.state) } else { - submitCharacterCreate(this.state) + submitCharacterCreate( + this.state, + this.manifest?.strings as unknown as Record, + ) } } else if (this.state.stage === 'char_select') { if (this.asyncHandlers?.onSelectCharacter) { @@ -2638,7 +2981,12 @@ export class FrontendRenderer { if (isValidAccountChar(ch) && cur.length < 15) { this.setFieldValue(field, cur + ch) } - } else if (field === 'charName' || field === 'gameName' || field === 'gamePassword') { + } else if (field === 'charName') { + // Per D2Launch.dll 0x6fa52360 (`canAcceptCharNameChar`), only ASCII letters and at most 1 non-leading `-` or `_` (#697) + if (canAcceptCharNameChar(cur, ch)) { + this.setFieldValue(field, cur + ch) + } + } else if (field === 'gameName' || field === 'gamePassword') { if (/^[A-Za-z0-9_-]$/.test(ch) && cur.length < 15) { this.setFieldValue(field, cur + ch) } diff --git a/src/client/frontend/statstring-avatar.ts b/src/client/frontend/statstring-avatar.ts new file mode 100644 index 0000000..bac4531 --- /dev/null +++ b/src/client/frontend/statstring-avatar.ts @@ -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> = { + 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> = { + 1: 'lit', + 2: 'med', + 3: 'hvy', +} + +/** + * Shield component graphic index (`statstring[9]`) -> 3-letter DCC token (`Armor.txt`). + */ +const SHIELD_COMPONENT_TOKENS: Readonly> = { + 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> = { + 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> + readonly componentTokens: Readonly> + readonly componentColors: Readonly> + readonly equippedLayers: readonly AvatarComponentLayerSpec[] + readonly cofSignature: string +} + +const CLASS_ID_TO_TOKEN: Readonly> = { + 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 + const componentColors = {} as Record + 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, + } +} diff --git a/tests/client/frontend-batch7-batch8.test.ts b/tests/client/frontend-batch7-batch8.test.ts new file mode 100644 index 0000000..67b379c --- /dev/null +++ b/tests/client/frontend-batch7-batch8.test.ts @@ -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 + + 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) + }) +}) diff --git a/tests/frontend/flow-state.test.ts b/tests/frontend/flow-state.test.ts index a336a80..34113d8 100644 --- a/tests/frontend/flow-state.test.ts +++ b/tests/frontend/flow-state.test.ts @@ -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'