diff --git a/public/ui/frontend/btn-popup-small.png b/public/ui/frontend/btn-popup-small.png new file mode 100644 index 0000000..cd443c5 Binary files /dev/null and b/public/ui/frontend/btn-popup-small.png differ diff --git a/public/ui/frontend/manifest.json b/public/ui/frontend/manifest.json index c31669b..ca833e9 100644 --- a/public/ui/frontend/manifest.json +++ b/public/ui/frontend/manifest.json @@ -1,6 +1,6 @@ { "version": "1.13c", - "decodedDc6Count": 92, + "decodedDc6Count": 95, "animStrips": { "logoMainBlack": { "file": "logo-main-black.png", @@ -13551,6 +13551,39 @@ 9 ], "strings": { + "5101": "EXIT", + "5102": "OK", + "5103": "CANCEL", + "5180": "Terms of Use", + "5181": "AGREE", + "5182": "DISAGREE", + "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", + "10834": "Verify Password", + "11127": "PLEASE WRITE DOWN YOUR PASSWORD.", "singlePlayer": "SINGLE PLAYER", "battleNet": "BATTLE.NET", "otherMultiplayer": "OTHER MULTIPLAYER", @@ -13563,12 +13596,39 @@ "accessingAccount": "Accessing your account...", "accountName": "Account Name", "password": "Password", + "verifyPassword": "Verify Password", "logIn": "LOG IN", "accountSettings": "ACCOUNT SETTINGS", "createNewAccount": "CREATE NEW ACCOUNT", + "createBnetAccountTitle": "Create Battle.net Account", "exit": "EXIT", "ok": "OK", "cancel": "CANCEL", + "createAccountInstruction": "Enter the Name and Password for your new battle.net account.", + "termsOfUseTitle": "Terms of Use", + "agree": "AGREE", + "disagree": "DISAGREE", + "dontAgree": "DISAGREE", + "pleaseReadTitle": "PLEASE READ", + "pleaseReadPara1": "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.", + "pleaseReadPara2": "After that, your account becomes permanent so long as you continue to log into this account once every 90 days.", + "pleaseReadPara3": "PLEASE WRITE DOWN YOUR PASSWORD. BLIZZARD DOES NOT HAVE ACCESS TO PASSWORDS, AND CANNOT FIND OR REPLACE FORGOTTEN PASSWORDS.", + "errAccountTooShort": "Account name must be at least 2 characters.", + "errAccountTooLong": "Account name can't be more than 15 characters.", + "errPasswordMismatch": "Passwords don't match.", + "errPasswordTooShort": "Password must be at least 2 characters.", + "errPasswordTooLong": "Password can't be more than 15 characters.", + "errAccountDisallowed": "User names must be at least two characters in length. Please try again with a different name.", + "errAccountInvalidChars": "Your user name contained one or more illegal characters.", + "errAccountBannedWord": "Your user name contained one or more disallowed words.", + "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.", + "errAccountNotEnoughAlphanum": "Your name did not have enough alpha-numeric characters in it.", + "errAccountAdjacentPunct": "Your name contained too many adjacent punctuation characters in it.", + "errAccountTooManyPunct": "Your name contained too many punctuation characters in it.", + "errAccountExistsSuggest": "The passwords you have typed do not match. Please enter the same password twice.", + "errAccountAlreadyExists": "An account already exists with the name you have chosen. Please try again with a different name.", + "unableToCreateAccount": "Unable to Create Account", + "termsOfUseText": "BATTLE.NET TERMS OF USE AGREEMENT\n\nTHE BATTLE.NET SERVICE IS PROVIDED TO YOU UNDER THE TERMS AND CONDITIONS OF THIS TERMS OF USE AGREEMENT (\"AGREEMENT\") AND ANY OPERATING RULES OR POLICIES THAT MAY BE PUBLISHED FROM TIME TO TIME BY BLIZZARD ENTERTAINMENT (\"BLIZZARD\").\n\nPLEASE READ THE FOLLOWING TERMS AND CONDITIONS CAREFULLY. BY CLICKING THE \"AGREE\" BUTTON BELOW, YOU ACKNOWLEDGE THAT YOU HAVE READ, UNDERSTOOD, AND AGREE TO BE BOUND BY THE TERMS AND CONDITIONS OF THIS AGREEMENT.\n\n1. GRANT OF A LIMITED LICENSE TO USE THE SERVICE\nSubject to your agreement to and continuing compliance with this Agreement, Blizzard hereby grants, and you hereby accept, a limited, non-exclusive, non-transferable license to access and use the Battle.net Service solely for your personal, non-commercial entertainment purposes.\n\n2. ADDITIONAL LICENSE LIMITATIONS\nThe license granted to you in Section 1 is subject to the limitations set forth in Sections 1 and 2 (collectively, the \"License Limitations\"). Any use of the Service or the Game in violation of the License Limitations will be regarded as an infringement of Blizzard's copyrights in and to the Game. You agree that you will not, under any circumstances:\n(a) use cheats, automation software (bots), hacks, mods or any other unauthorized third-party software designed to modify the Battle.net Service or the Game experience;\n(b) exploit the Game or any of its parts, including without limitation the Service, for any commercial purpose;\n(c) use any unauthorized third-party software that intercepts, \"mines\", or otherwise collects information from or through the Game or the Service;\n(d) modify or cause to be modified any files that are a part of the Game or the Service in any way not expressly authorized by Blizzard;\n(e) host, provide or develop matchmaking services for the Game or the Service, or intercept, emulate or redirect the communication protocols used by Blizzard in any way, for any purpose, including without limitation unauthorized play over the internet, network play, or as part of content aggregation networks;\n(f) facilitate, create or maintain any unauthorized connection to the Game or the Service, including without limitation any connection to any unauthorized server that emulates, or attempts to emulate, the Service.\n\n3. ELIGIBILITY AND ACCOUNT SECURITY\nWhen creating an account on Battle.net, you must choose an Account Name and a Password. You may not use an Account Name that is used by someone else, is vulgar or offensive, or otherwise violates the Terms of Use. You are solely responsible for maintaining the confidentiality of your Password and for all activities that occur under your Account.\n\n4. CODE OF CONDUCT\nAs with all things, your use of Battle.net is governed by certain rules of conduct. While using the Battle.net Service, you agree to abide by rules of fair play and respect toward other players. Harassment, threats, hate speech, impersonation of Blizzard employees, and disruptive behavior in chat channels or games may result in temporary or permanent suspension of your Battle.net Account.\n\n5. ACCOUNT EXPIRATION AND TERMINATION\nBattle.net accounts and characters are subject to periodic expiration if left inactive. Accounts that have not been logged into for a period of ninety (90) days may be automatically deleted. Blizzard reserves the right to terminate this Agreement and revoke your access to the Battle.net Service at any time if you breach any provision of this Agreement.\n\n6. DISCLAIMER OF WARRANTIES AND LIMITATION OF LIABILITY\nTHE BATTLE.NET SERVICE IS PROVIDED ON AN \"AS IS\" AND \"AS AVAILABLE\" BASIS FOR YOUR USE, WITHOUT WARRANTIES OF ANY KIND, EITHER EXPRESS OR IMPLIED, INCLUDING WITHOUT LIMITATION THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, TITLE, NON-INFRINGEMENT, AND THOSE ARISING FROM COURSE OF DEALING OR USAGE OF TRADE.\n\nBY CLICKING \"AGREE\" BELOW, YOU CONFIRM THAT YOU HAVE READ AND ACCEPTED THIS BATTLE.NET TERMS OF USE AGREEMENT IN ITS ENTIRETY.", "chooseClassTitle": "Choose a class for your campaign", "characterName": "Character Name", "expansionCharacter": "EXPANSION CHARACTER", diff --git a/public/ui/frontend/scrollbar.png b/public/ui/frontend/scrollbar.png new file mode 100644 index 0000000..deafbfc Binary files /dev/null and b/public/ui/frontend/scrollbar.png differ diff --git a/public/ui/frontend/terms-of-use-bg.png b/public/ui/frontend/terms-of-use-bg.png new file mode 100644 index 0000000..0892600 Binary files /dev/null and b/public/ui/frontend/terms-of-use-bg.png differ diff --git a/src/baker/packers/pack-frontend-ui.ts b/src/baker/packers/pack-frontend-ui.ts index 8e75cd7..5e13ec9 100644 --- a/src/baker/packers/pack-frontend-ui.ts +++ b/src/baker/packers/pack-frontend-ui.ts @@ -443,6 +443,32 @@ async function main(): Promise { ], false) savePng('popup-340x224.png', 340, 224, popupPixels, skyPl2.rgb, false) + const termsBgDc6 = await decodeUiDc6('data\\global\\ui\\FrontEnd\\TermsofUseBckg.dc6') + const tbf = termsBgDc6.groups[0]!.frames + const termsBgPixels = stitchFrames(593, 451, [ + { frame: tbf[0]!, x: 0, y: 0 }, + { frame: tbf[1]!, x: 256, y: 0 }, + { frame: tbf[2]!, x: 512, y: 0 }, + { frame: tbf[3]!, x: 0, y: 256 }, + { frame: tbf[4]!, x: 256, y: 256 }, + { frame: tbf[5]!, x: 512, y: 256 }, + ], false) + savePng('terms-of-use-bg.png', 593, 451, termsBgPixels, skyPl2.rgb, false) + + let scrollbarDc6: SpriteSheet + try { + scrollbarDc6 = await decodeUiDc6('data\\global\\ui\\FrontEnd\\joingamescrollbars.dc6') + } catch { + scrollbarDc6 = await decodeUiDc6('data\\global\\ui\\BIGMENU\\joingamescrollbars.dc6') + } + const sbf = scrollbarDc6.groups[0]!.frames + const scrollbarPlacements: BlitPlacement[] = [] + for (let i = 0; i < Math.min(6, sbf.length); i++) { + scrollbarPlacements.push({ frame: sbf[i]!, x: i * 12, y: 0 }) + } + const scrollbarPixels = stitchFrames(72, 14, scrollbarPlacements, true) + savePng('scrollbar.png', 72, 14, scrollbarPixels, skyPl2.rgb, true) + const createGameBgDc6 = await decodeUiDc6('data\\global\\ui\\BIGMENU\\creategamebckg.dc6') const cgf = createGameBgDc6.groups[0]!.frames const createGameBgPixels = stitchFrames(373, 373, [ @@ -544,6 +570,15 @@ async function main(): Promise { const btnCancelStrip = stitchButtonStates(btnCancelDc6, 1) // 96x32 x 2 states savePng('btn-create-cancel.png', btnCancelStrip.width, btnCancelStrip.height, btnCancelStrip.pixels, act1Pl2.rgb, true) + let popupSmallBtnDc6: SpriteSheet + try { + popupSmallBtnDc6 = await decodeUiDc6('data\\global\\ui\\FrontEnd\\CancelButtonBlank.dc6') + } catch { + popupSmallBtnDc6 = btnCancelDc6 + } + const popupSmallBtnStrip = stitchButtonStates(popupSmallBtnDc6, 1) // 96x32 x 2 states = 192x32 + savePng('btn-popup-small.png', popupSmallBtnStrip.width, popupSmallBtnStrip.height, popupSmallBtnStrip.pixels, skyPl2.rgb, true) + const btnCreateGameDc6 = await decodeUiDc6('data\\global\\ui\\BIGMENU\\creategamebutton.dc6') const btnCreateGameStrip = stitchButtonStates(btnCreateGameDc6, 1) // 144x36 x 2 states savePng('btn-create-game.png', btnCreateGameStrip.width, btnCreateGameStrip.height, btnCreateGameStrip.pixels, act1Pl2.rgb, true) @@ -841,6 +876,100 @@ async function main(): Promise { return undefined } + const lookupById = (id: number): string | undefined => { + const raw = + id < 10000 + ? strBase[id] + : id < 20000 + ? strPatch[id - 10000] + : strExp[id - 20000] + return raw?.trim() || undefined + } + + const defaultTermsOfUseText = [ + 'BATTLE.NET TERMS OF USE AGREEMENT', + '', + 'THE BATTLE.NET SERVICE IS PROVIDED TO YOU UNDER THE TERMS AND CONDITIONS OF THIS TERMS OF USE AGREEMENT ("AGREEMENT") AND ANY OPERATING RULES OR POLICIES THAT MAY BE PUBLISHED FROM TIME TO TIME BY BLIZZARD ENTERTAINMENT ("BLIZZARD").', + '', + 'PLEASE READ THE FOLLOWING TERMS AND CONDITIONS CAREFULLY. BY CLICKING THE "AGREE" BUTTON BELOW, YOU ACKNOWLEDGE THAT YOU HAVE READ, UNDERSTOOD, AND AGREE TO BE BOUND BY THE TERMS AND CONDITIONS OF THIS AGREEMENT.', + '', + '1. GRANT OF A LIMITED LICENSE TO USE THE SERVICE', + 'Subject to your agreement to and continuing compliance with this Agreement, Blizzard hereby grants, and you hereby accept, a limited, non-exclusive, non-transferable license to access and use the Battle.net Service solely for your personal, non-commercial entertainment purposes.', + '', + '2. ADDITIONAL LICENSE LIMITATIONS', + 'The license granted to you in Section 1 is subject to the limitations set forth in Sections 1 and 2 (collectively, the "License Limitations"). Any use of the Service or the Game in violation of the License Limitations will be regarded as an infringement of Blizzard\'s copyrights in and to the Game. You agree that you will not, under any circumstances:', + '(a) use cheats, automation software (bots), hacks, mods or any other unauthorized third-party software designed to modify the Battle.net Service or the Game experience;', + '(b) exploit the Game or any of its parts, including without limitation the Service, for any commercial purpose;', + '(c) use any unauthorized third-party software that intercepts, "mines", or otherwise collects information from or through the Game or the Service;', + '(d) modify or cause to be modified any files that are a part of the Game or the Service in any way not expressly authorized by Blizzard;', + '(e) host, provide or develop matchmaking services for the Game or the Service, or intercept, emulate or redirect the communication protocols used by Blizzard in any way, for any purpose, including without limitation unauthorized play over the internet, network play, or as part of content aggregation networks;', + '(f) facilitate, create or maintain any unauthorized connection to the Game or the Service, including without limitation any connection to any unauthorized server that emulates, or attempts to emulate, the Service.', + '', + '3. ELIGIBILITY AND ACCOUNT SECURITY', + 'When creating an account on Battle.net, you must choose an Account Name and a Password. You may not use an Account Name that is used by someone else, is vulgar or offensive, or otherwise violates the Terms of Use. You are solely responsible for maintaining the confidentiality of your Password and for all activities that occur under your Account.', + '', + '4. CODE OF CONDUCT', + 'As with all things, your use of Battle.net is governed by certain rules of conduct. While using the Battle.net Service, you agree to abide by rules of fair play and respect toward other players. Harassment, threats, hate speech, impersonation of Blizzard employees, and disruptive behavior in chat channels or games may result in temporary or permanent suspension of your Battle.net Account.', + '', + '5. ACCOUNT EXPIRATION AND TERMINATION', + 'Battle.net accounts and characters are subject to periodic expiration if left inactive. Accounts that have not been logged into for a period of ninety (90) days may be automatically deleted. Blizzard reserves the right to terminate this Agreement and revoke your access to the Battle.net Service at any time if you breach any provision of this Agreement.', + '', + '6. DISCLAIMER OF WARRANTIES AND LIMITATION OF LIABILITY', + 'THE BATTLE.NET SERVICE IS PROVIDED ON AN "AS IS" AND "AS AVAILABLE" BASIS FOR YOUR USE, WITHOUT WARRANTIES OF ANY KIND, EITHER EXPRESS OR IMPLIED, INCLUDING WITHOUT LIMITATION THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, TITLE, NON-INFRINGEMENT, AND THOSE ARISING FROM COURSE OF DEALING OR USAGE OF TRADE.', + '', + 'BY CLICKING "AGREE" BELOW, YOU CONFIRM THAT YOU HAVE READ AND ACCEPTED THIS BATTLE.NET TERMS OF USE AGREEMENT IN ITS ENTIRETY.', + ].join('\n') + + const s5101 = lookupById(5101) ?? 'EXIT' + const s5102 = lookupById(5102) ?? 'OK' + const s5103 = lookupById(5103) ?? 'CANCEL' + const s5180 = lookupById(5180) ?? 'Terms of Use' + const s5181 = lookupById(5181) ?? 'AGREE' + const s5182 = lookupById(5182) ?? 'DISAGREE' + const s5216 = lookupById(5216) ?? 'Account name must be at least 2 characters.' + const s5217 = lookupById(5217) ?? "Account name can't be more than 15 characters." + const s5218 = lookupById(5218) ?? "Passwords don't match." + const s5219 = lookupById(5219) ?? 'Password must be at least 2 characters.' + const s5220 = lookupById(5220) ?? "Password can't be more than 15 characters." + const s5221 = lookupById(5221) ?? 'CREATE NEW ACCOUNT' + const s5222 = lookupById(5222) ?? 'Create Battle.net Account' + const s5223 = lookupById(5223) ?? 'Enter the Name and Password for your new battle.net account.' + const s5224 = lookupById(5224) ?? 'Account Name' + const s5225 = lookupById(5225) ?? 'Password' + const s5226 = lookupById(5226) ?? 'Verify Password' + const s5227 = lookupById(5227) ?? 'PLEASE READ' + const s5228 = + lookupById(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.' + const s5229 = + lookupById(5229) ?? + 'After that, your account becomes permanent so long as you continue to log into this account once every 90 days.' + const s5230 = + lookupById(5230) ?? + 'PLEASE WRITE DOWN YOUR PASSWORD. BLIZZARD DOES NOT HAVE ACCESS TO PASSWORDS, AND CANNOT FIND OR REPLACE FORGOTTEN PASSWORDS.' + const s5231 = + lookupById(5231) ?? + 'User names must be at least two characters in length. Please try again with a different name.' + const s5232 = lookupById(5232) ?? 'Your user name contained one or more illegal characters.' + const s5233 = lookupById(5233) ?? 'Your user name contained one or more disallowed words.' + const s5234 = + lookupById(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.' + const s5235 = + lookupById(5235) ?? 'Your name did not have enough alpha-numeric characters in it.' + const s5236 = + lookupById(5236) ?? 'Your name contained too many adjacent punctuation characters in it.' + const s5237 = + lookupById(5237) ?? 'Your name contained too many punctuation characters in it.' + const s5238 = + lookupById(5238) ?? 'The passwords you have typed do not match. Please enter the same password twice.' + const s5239 = + lookupById(5239) ?? + 'An account already exists with the name you have chosen. Please try again with a different name.' + const s5249 = lookupById(5249) ?? 'Unable to Create Account' + const s10834 = lookupById(10834) ?? 'Verify Password' + const s11127 = lookupById(11127) ?? 'PLEASE WRITE DOWN YOUR PASSWORD.' + const strings = { singlePlayer: lookupByKey('strSinglePlayer') ?? 'SINGLE PLAYER', battleNet: lookupByKey('strBattleNet') ?? 'BATTLE.NET', @@ -852,14 +981,74 @@ async function main(): Promise { connectingToFastest: 'Connecting to the fastest server...', checkingVersions: 'Checking versions...', accessingAccount: 'Accessing your account...', - accountName: 'Account Name', - password: 'Password', + accountName: s5224, + password: s5225, + verifyPassword: s10834 || s5226, logIn: 'LOG IN', accountSettings: 'ACCOUNT SETTINGS', - createNewAccount: 'CREATE NEW ACCOUNT', - exit: 'EXIT', - ok: 'OK', - cancel: 'CANCEL', + createNewAccount: s5221, + createBnetAccountTitle: s5222, + exit: s5101, + ok: s5102, + cancel: s5103, + createAccountInstruction: s5223, + termsOfUseTitle: s5180, + agree: s5181, + disagree: s5182, + dontAgree: s5182, + pleaseReadTitle: s5227, + pleaseReadPara1: s5228, + pleaseReadPara2: s5229, + pleaseReadPara3: s5230 || s11127, + errAccountTooShort: s5216, + errAccountTooLong: s5217, + errPasswordMismatch: s5218, + errPasswordTooShort: s5219, + errPasswordTooLong: s5220, + errAccountDisallowed: s5231, + errAccountInvalidChars: s5232, + errAccountBannedWord: s5233, + errAccountStillProcessing: s5234, + errAccountNotEnoughAlphanum: s5235, + errAccountAdjacentPunct: s5236, + errAccountTooManyPunct: s5237, + errAccountExistsSuggest: s5238, + errAccountAlreadyExists: s5239, + unableToCreateAccount: s5249, + termsOfUseText: defaultTermsOfUseText, + '5101': s5101, + '5102': s5102, + '5103': s5103, + '5180': s5180, + '5181': s5181, + '5182': s5182, + '5216': s5216, + '5217': s5217, + '5218': s5218, + '5219': s5219, + '5220': s5220, + '5221': s5221, + '5222': s5222, + '5223': s5223, + '5224': s5224, + '5225': s5225, + '5226': s5226, + '5227': s5227, + '5228': s5228, + '5229': s5229, + '5230': s5230, + '5231': s5231, + '5232': s5232, + '5233': s5233, + '5234': s5234, + '5235': s5235, + '5236': s5236, + '5237': s5237, + '5238': s5238, + '5239': s5239, + '5249': s5249, + '10834': s10834, + '11127': s11127, chooseClassTitle: 'Choose a class for your campaign', characterName: 'Character Name', expansionCharacter: 'EXPANSION CHARACTER', diff --git a/src/client/frontend/flow-state.ts b/src/client/frontend/flow-state.ts index b696dd6..b574fa1 100644 --- a/src/client/frontend/flow-state.ts +++ b/src/client/frontend/flow-state.ts @@ -15,12 +15,17 @@ export type BnetFlowStage = | 'main_menu' | 'bnet_connecting' | 'bnet_login' + | 'bnet_terms_of_use' + | 'bnet_create_account' + | 'bnet_please_read' | 'char_create' | 'char_select' | 'bnet_lobby' | 'door_loading' | 'in_game' +export type FrontendScreen = BnetFlowStage + /** Campfire class IDs in left-to-right screen order (`D2Launch.dll` @ `0x6fa68df8`). */ export type CampfireClassId = 'am' | 'as' | 'ne' | 'ba' | 'pa' | 'so' | 'dz' @@ -90,6 +95,10 @@ export interface RosterCharacter { export type ActiveInputField = | 'accountName' | 'accountPassword' + | 'accountVerifyPassword' + | 'bnet_account' + | 'bnet_password' + | 'bnet_verify_password' | 'charName' | 'gameName' | 'gamePassword' @@ -99,6 +108,17 @@ export type ActiveInputField = | 'chatInput' | null +export type FocusedField = ActiveInputField + +export interface PopupModalState { + readonly title?: string | undefined + readonly message: string + readonly body: string + readonly returnScreen: BnetFlowStage + readonly tblId?: number | undefined + readonly strId?: number | undefined +} + export interface LobbyGameEntry { readonly name: string readonly description: string @@ -121,10 +141,15 @@ export interface BnetFlowState { /** Whether live D2OnlineFlow is active (falls back to mock when false). */ onlineConnected?: boolean | undefined - // Stage 2: Battle.net Login + // Stage 2: Battle.net Login & Create New Account accountName: string accountPassword: string + accountVerifyPassword: string loginError: string | null + termsScrollLine: number + termsScrollOffset: number + popupModal: PopupModalState | null + popup: PopupModalState | null // Stage 3: Campfire Character Creation campfireClasses: Record @@ -175,6 +200,8 @@ export interface BnetFlowState { activeInput: ActiveInputField } +export type FrontendFlowState = BnetFlowState + export const CAMPFIRE_CLASS_ORDER: readonly CampfireClassId[] = [ 'am', 'as', @@ -373,7 +400,12 @@ export function createInitialFlowState(initialRoster: RosterCharacter[] = []): B // No prefilled credentials: the user (or a configured bot) must type them. accountName: '', accountPassword: '', + accountVerifyPassword: '', loginError: null, + termsScrollLine: 0, + termsScrollOffset: 0, + popupModal: null, + popup: null, campfireClasses, hoveredClass: null, @@ -417,6 +449,456 @@ export function createInitialFlowState(initialRoster: RosterCharacter[] = []): B } } +const FORBIDDEN_ACCOUNT_CHARS = new Set(['"', '#', '%', '*', ',', '/', '<', '>', '?', '\\']) + +/** + * Character filter for Battle.net account name (`D2Launch.dll` @ `0x6fa52630`). + * Rejects control characters & space (`<= 0x20`), DEL and non-ASCII (`>= 0x7f`), + * and the 10 forbidden characters: `" # % * , / < > ? \`. + */ +export function isValidAccountChar(ch: string): boolean { + if (ch.length !== 1) return false + const code = ch.charCodeAt(0) + if (code <= 0x20 || code >= 0x7f) return false + return !FORBIDDEN_ACCOUNT_CHARS.has(ch) +} + +/** + * Filter an arbitrary string down to valid Battle.net account name characters (max 15 chars). + */ +export function filterAccountName(input: string): string { + let out = '' + for (let i = 0; i < input.length && out.length < 15; i++) { + const ch = input[i]! + if (isValidAccountChar(ch)) { + out += ch + } + } + return out +} + +export type AccountValidationResult = + | { readonly valid: true } + | { + readonly valid: false + readonly tblId: 5216 | 5217 | 5218 | 5219 | 5220 + 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`). + */ +export function validateNewAccountInputs( + state: Pick, + strings?: Record, +): AccountValidationResult { + if (state.accountName.length < 2) { + return { + valid: false, + tblId: 5216, + message: resolveStringByTblId( + strings, + 5216, + 'errAccountTooShort', + 'Account name must be at least 2 characters.', + ), + } + } + if (state.accountName.length > 15) { + return { + valid: false, + tblId: 5217, + message: resolveStringByTblId( + strings, + 5217, + 'errAccountTooLong', + "Account name can't be more than 15 characters.", + ), + } + } + if (state.accountPassword.toLowerCase() !== state.accountVerifyPassword.toLowerCase()) { + return { + valid: false, + tblId: 5218, + message: resolveStringByTblId(strings, 5218, 'errPasswordMismatch', "Passwords don't match."), + } + } + if (state.accountPassword.length < 2) { + return { + valid: false, + tblId: 5219, + message: resolveStringByTblId( + strings, + 5219, + 'errPasswordTooShort', + 'Password must be at least 2 characters.', + ), + } + } + if (state.accountPassword.length > 15) { + return { + valid: false, + tblId: 5220, + message: resolveStringByTblId( + strings, + 5220, + 'errPasswordTooLong', + "Password can't be more than 15 characters.", + ), + } + } + return { valid: true } +} + +/** + * Open the modal popup overlay (`PopUpOkCancel2.dc6`) on top of `returnScreen`. + */ +export function openPopupModal( + state: BnetFlowState, + message: string, + returnScreen: BnetFlowStage = state.stage, + title?: string, + tblId?: number, +): BnetFlowState { + const modal: PopupModalState = { + ...(title ? { title } : {}), + ...(tblId !== undefined ? { tblId, strId: tblId } : {}), + message, + body: message, + returnScreen, + } + state.popupModal = modal + state.popup = modal + return state +} + +/** + * Dismiss the active modal popup overlay and restore `returnScreen`. + */ +export function dismissPopupModal(state: BnetFlowState): BnetFlowState { + if (state.popupModal) { + state.stage = state.popupModal.returnScreen + state.popupModal = null + state.popup = null + } + return state +} + +/** + * Transition `bnet_login` -> `bnet_terms_of_use` (`0x6fa59d00`). + */ +export function openTermsOfUse(state: BnetFlowState): BnetFlowState { + state.stage = 'bnet_terms_of_use' + state.stageTicks = 0 + state.termsScrollLine = 0 + state.termsScrollOffset = 0 + state.popupModal = null + state.popup = null + state.loginError = null + state.activeInput = null + return state +} + +/** + * Scroll the Terms of Use agreement text by `deltaLines`, clamped to `[0, maxScrollLine]`. + */ +export function scrollTermsOfUse( + state: BnetFlowState, + deltaLines: number, + maxScrollLine = 100, +): BnetFlowState { + if (state.stage !== 'bnet_terms_of_use' || state.popupModal !== null) return state + const clampedMax = Math.max(0, maxScrollLine) + const next = Math.max(0, Math.min(clampedMax, state.termsScrollLine + deltaLines)) + state.termsScrollLine = next + state.termsScrollOffset = next + return state +} + +/** + * Click `AGREE` on `bnet_terms_of_use` -> enters `bnet_create_account` (`0x6fa58ad0`). + */ +export function agreeTermsOfUse(state: BnetFlowState): BnetFlowState { + state.stage = 'bnet_create_account' + state.stageTicks = 0 + state.accountPassword = '' + state.accountVerifyPassword = '' + state.popupModal = null + state.popup = null + state.loginError = null + state.activeInput = 'accountName' + return state +} + +/** + * Click `DISAGREE` on `bnet_terms_of_use`, `EXIT` on `bnet_create_account`, or `CANCEL` on `bnet_please_read` + * -> returns to `bnet_login`. + */ +export function returnToBnetLogin(state: BnetFlowState): BnetFlowState { + state.stage = 'bnet_login' + state.stageTicks = 0 + state.popupModal = null + state.popup = null + state.loginError = null + state.activeInput = state.accountName ? 'accountPassword' : 'accountName' + return state +} + +export const disagreeTermsOfUse = returnToBnetLogin +export const cancelCreateAccount = returnToBnetLogin +export const cancelPleaseRead = returnToBnetLogin + +/** + * Cycle keyboard focus across the 3 edit boxes on `bnet_create_account`: + * `accountName` -> `accountPassword` -> `accountVerifyPassword` -> `accountName`. + */ +export function cycleCreateAccountFocus(state: BnetFlowState, reverse = false): BnetFlowState { + if (state.popupModal !== null) return state + if (reverse) { + if (state.activeInput === 'accountName' || state.activeInput === 'bnet_account') { + state.activeInput = 'accountVerifyPassword' + } else if ( + state.activeInput === 'accountVerifyPassword' || + state.activeInput === 'bnet_verify_password' + ) { + state.activeInput = 'accountPassword' + } else { + state.activeInput = 'accountName' + } + return state + } + if (state.activeInput === 'accountName' || state.activeInput === 'bnet_account') { + state.activeInput = 'accountPassword' + } else if (state.activeInput === 'accountPassword' || state.activeInput === 'bnet_password') { + state.activeInput = 'accountVerifyPassword' + } else { + state.activeInput = 'accountName' + } + return state +} + +/** + * Append a single character to the focused field on `bnet_create_account`, + * enforcing `isValidAccountChar` (`0x6fa52630`) on `accountName` and max 15 characters on all 3 fields. + */ +export function typeCreateAccountChar(state: BnetFlowState, ch: string): BnetFlowState { + if (state.stage !== 'bnet_create_account' || state.popupModal !== null || ch.length !== 1) { + return state + } + const f = state.activeInput + if (f === 'accountName' || f === 'bnet_account') { + if (state.accountName.length < 15 && isValidAccountChar(ch)) { + state.accountName += ch + } + } else if (f === 'accountPassword' || f === 'bnet_password') { + const code = ch.charCodeAt(0) + if (code >= 0x20 && code <= 0x7e && state.accountPassword.length < 15) { + state.accountPassword += ch + } + } else if (f === 'accountVerifyPassword' || f === 'bnet_verify_password') { + const code = ch.charCodeAt(0) + if (code >= 0x20 && code <= 0x7e && state.accountVerifyPassword.length < 15) { + state.accountVerifyPassword += ch + } + } + return state +} + +/** + * Remove the last character from the focused field on `bnet_create_account`. + */ +export function backspaceCreateAccountChar(state: BnetFlowState): BnetFlowState { + if (state.stage !== 'bnet_create_account' || state.popupModal !== null) { + return state + } + const f = state.activeInput + if (f === 'accountName' || f === 'bnet_account') { + state.accountName = state.accountName.slice(0, -1) + } else if (f === 'accountPassword' || f === 'bnet_password') { + state.accountPassword = state.accountPassword.slice(0, -1) + } else if (f === 'accountVerifyPassword' || f === 'bnet_verify_password') { + state.accountVerifyPassword = state.accountVerifyPassword.slice(0, -1) + } + return state +} + +/** + * Click `OK` or press `Enter` on `bnet_create_account` (`0x6fa59880`): + * Validates all 3 fields; if invalid, opens the modal error popup; if valid, transitions to `bnet_please_read`. + */ +export function submitCreateAccountForm( + state: BnetFlowState, + strings?: Record, +): boolean { + if (state.stage !== 'bnet_create_account' || state.popupModal !== null) return false + const check = validateNewAccountInputs(state, strings) + if (!check.valid) { + openPopupModal(state, check.message, 'bnet_create_account', undefined, check.tblId) + return false + } + state.popupModal = null + state.popup = null + state.stage = 'bnet_please_read' + state.stageTicks = 0 + state.activeInput = null + return true +} + +/** + * Map server `SID_CREATEACCOUNT2` status code (`0x01..0x08`) to canonical `.tbl` string ID and message + * (`D2Launch.dll` @ `0x6fa58e40`). + */ +export function mapCreateAccountServerStatus( + status: number, + strings?: Record, +): { + readonly tblId: 5231 | 5232 | 5233 | 5234 | 5235 | 5236 | 5237 | 5239 + readonly titleTblId: 5249 + readonly title: string + readonly message: string +} { + const title = resolveStringByTblId( + strings, + 5249, + 'unableToCreateAccount', + 'Unable to Create Account', + ) + switch (status) { + case 0x02: + return { + tblId: 5232, + titleTblId: 5249, + title, + message: resolveStringByTblId( + strings, + 5232, + 'errAccountInvalidChars', + 'Your user name contained one or more illegal characters.', + ), + } + case 0x03: + return { + tblId: 5233, + titleTblId: 5249, + title, + message: resolveStringByTblId( + strings, + 5233, + 'errAccountBannedWord', + 'Your user name contained one or more disallowed words.', + ), + } + 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.', + ), + } + 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.', + ), + } + case 0x06: + return { + tblId: 5235, + titleTblId: 5249, + title, + message: resolveStringByTblId( + strings, + 5235, + 'errAccountNotEnoughAlphanum', + 'Your name did not have enough alpha-numeric characters in it.', + ), + } + case 0x07: + return { + tblId: 5236, + titleTblId: 5249, + title, + message: resolveStringByTblId( + strings, + 5236, + 'errAccountAdjacentPunct', + 'Your name contained too many adjacent punctuation characters in it.', + ), + } + case 0x08: + return { + tblId: 5237, + titleTblId: 5249, + title, + message: resolveStringByTblId( + strings, + 5237, + 'errAccountTooManyPunct', + 'Your name contained too many punctuation characters in it.', + ), + } + 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.', + ), + } + } +} + +/** + * Apply a server-side `SID_CREATEACCOUNT2` rejection to `BnetFlowState` (`0x6fa58e40`): + * Returns to `bnet_create_account`, preserves `accountName`, clears password fields, + * and opens the `"Unable to Create Account"` (`5249`) modal popup. + */ +export function applyCreateAccountServerError( + state: BnetFlowState, + status: number, + strings?: Record, +): BnetFlowState { + const mapped = mapCreateAccountServerStatus(status, strings) + state.stage = 'bnet_create_account' + state.stageTicks = 0 + state.accountPassword = '' + state.accountVerifyPassword = '' + state.activeInput = 'accountName' + openPopupModal(state, mapped.message, 'bnet_create_account', mapped.title, mapped.tblId) + return state +} + /** * 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). @@ -943,11 +1425,14 @@ export function applyOnlineCharactersToFlowState( readonly progression?: number | undefined readonly difficulty?: 0 | 1 | 2 | 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.selectedSlotIndex = 0 - if (state.roster.length === 0) { + if (state.roster.length === 0 && !options?.stayOnCharSelect) { enterCharCreate(state) } else { state.stage = 'char_select' @@ -970,6 +1455,8 @@ export function returnToLoginAfterSessionLoss(state: BnetFlowState, reason: stri state.tickAccumulatorMs = 0 state.lastFrameTimeMs = null state.onlineConnected = false + state.popupModal = null + state.popup = null state.loginError = reason state.lastError = reason state.statusText = '' diff --git a/src/client/frontend/frontend-renderer.ts b/src/client/frontend/frontend-renderer.ts index fd84070..4fa80ce 100644 --- a/src/client/frontend/frontend-renderer.ts +++ b/src/client/frontend/frontend-renderer.ts @@ -16,20 +16,32 @@ import type { FontAtlasMeta } from '../ui/font.ts' import { CAMPFIRE_CLASS_ORDER, HERO_TOKENS, + agreeTermsOfUse, + backspaceCreateAccountChar, canSubmitCreateGame, cancelConnecting, + cancelCreateAccount, + cancelPleaseRead, clickBattleNetButton, confirmCharacterSelect, + cycleCreateAccountFocus, + disagreeTermsOfUse, + dismissPopupModal, enterCharCreate, getAnimImageLoopFrame, hoverCampfireClass, isDifficultyUnlocked, + isValidAccountChar, + openTermsOfUse, resetHeroAvatarAnims, + scrollTermsOfUse, selectCampfireClass, submitBattleNetLogin, submitCharacterCreate, + submitCreateAccountForm, submitCreateGame, toggleChatGem, + typeCreateAccountChar, type ActiveInputField, type BnetFlowState, type CampfireClassId, @@ -75,9 +87,28 @@ export interface FrontendManifest { readonly accessingAccount: string readonly accountName: string readonly password: string + readonly verifyPassword?: string readonly logIn: string readonly accountSettings: string readonly createNewAccount: string + readonly createAccountTitle?: string + readonly createBnetAccountTitle?: string + readonly createAccountSubtitle?: string + readonly createAccountInstruction?: string + readonly writeDownPasswordWarning?: string + readonly termsOfUseTitle?: string + readonly agree?: string + readonly disagree?: string + readonly dontAgree?: string + readonly pleaseReadTitle?: string + readonly pleaseReadLine1?: string + readonly pleaseReadLine2?: string + readonly longPasswordWarning?: string + readonly pleaseReadPara1?: string + readonly pleaseReadPara2?: string + readonly pleaseReadPara3?: string + readonly unableToCreateAccount?: string + readonly termsOfUseText?: string readonly exit: string readonly ok: string readonly cancel: string @@ -162,6 +193,9 @@ const STATIC_UI_IMAGES = [ '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', @@ -612,12 +646,36 @@ export class FrontendRenderer { } /** - * Return the list of interactive buttons for the current stage. + * Return the list of interactive buttons for the current stage (or only the modal popup OK button when `popupModal` is open). */ getActiveButtons(): UiButtonDef[] { const s = this.manifest?.strings if (!s) return [] + if (this.state.popupModal !== null) { + return [ + { + id: 'popup_ok', + x: 351, + y: 305, + w: 96, + h: 32, + sprite: 'btn-popup-small.png', + label: s.ok, + onClick: () => { + dismissPopupModal(this.state) + }, + }, + ] + } + + return this.getStageButtons() + } + + private getStageButtons(): UiButtonDef[] { + const s = this.manifest?.strings + if (!s) return [] + switch (this.state.stage) { case 'main_menu': // Exact Expansion Main Menu coordinates from D2Launch.dll (0x6fa61190..0x6fa612d0) @@ -772,11 +830,7 @@ export class FrontendRenderer { sprite: 'btn-wide.png', label: s.createNewAccount, onClick: () => { - if (this.asyncHandlers?.onLogin) { - void this.asyncHandlers.onLogin(this.state, true) - } else { - submitBattleNetLogin(this.state) - } + openTermsOfUse(this.state) }, }, { @@ -795,6 +849,90 @@ export class FrontendRenderer { }, ] + case 'bnet_terms_of_use': + return [ + { + id: 'tou_disagree', + x: 133, + y: 478, + w: 128, + h: 35, + sprite: 'btn-medium.png', + label: s.disagree ?? 'DISAGREE', + onClick: () => { + disagreeTermsOfUse(this.state) + }, + }, + { + id: 'tou_agree', + x: 525, + y: 478, + w: 128, + h: 35, + sprite: 'btn-medium.png', + label: s.agree ?? 'AGREE', + onClick: () => { + agreeTermsOfUse(this.state) + }, + }, + ] + + case 'bnet_create_account': + return [ + { + id: 'create_account_exit', + x: 33, + y: 537, + w: 128, + h: 35, + sprite: 'btn-medium.png', + label: s.exit, + onClick: () => { + cancelCreateAccount(this.state) + }, + }, + { + id: 'create_account_ok', + x: 627, + y: 537, + w: 128, + h: 35, + sprite: 'btn-medium.png', + label: s.ok, + onClick: () => { + submitCreateAccountForm(this.state, s as unknown as Record) + }, + }, + ] + + case 'bnet_please_read': + return [ + { + id: 'please_read_cancel', + x: 133, + y: 478, + w: 128, + h: 35, + sprite: 'btn-medium.png', + label: s.cancel, + onClick: () => { + cancelPleaseRead(this.state) + }, + }, + { + id: 'please_read_ok', + x: 525, + y: 478, + w: 128, + h: 35, + sprite: 'btn-medium.png', + label: s.ok, + onClick: () => { + this.confirmPleaseRead() + }, + }, + ] + case 'char_create': return [ { @@ -1159,6 +1297,54 @@ export class FrontendRenderer { } } + private confirmPleaseRead(): void { + if (this.asyncHandlers?.onLogin) { + void this.asyncHandlers.onLogin(this.state, true) + } else { + this.state.roster = [] + this.state.stage = 'char_select' + this.state.stageTicks = 0 + this.state.activeInput = null + } + } + + wrapText(text: string, maxWidth: number, font: FrontendFontName = 'font16'): string[] { + const paragraphs = text.split('\n') + const out: string[] = [] + for (const para of paragraphs) { + const trimmed = para.trim() + if (!trimmed) { + out.push('') + continue + } + const words = trimmed.split(/\s+/) + let current = '' + for (const word of words) { + const candidate = current ? `${current} ${word}` : word + if (this.measureBitmapText(candidate, font) <= maxWidth || !current) { + current = candidate + } else { + out.push(current) + current = word + } + } + if (current) out.push(current) + } + return out + } + + private getTermsWrappedLines(): string[] { + const raw = this.manifest?.strings.termsOfUseText ?? '' + if (!raw) return [] + return this.wrapText(raw, 515, 'fontformal12') + } + + private getTermsMaxScrollLine(): number { + const lines = this.getTermsWrappedLines() + const visibleLines = 18 + return Math.max(0, lines.length - visibleLines) + } + /** * Render the active pre-game stage onto the 800x600 canvas. */ @@ -1177,6 +1363,15 @@ export class FrontendRenderer { case 'bnet_login': this.renderBnetLogin() break + case 'bnet_terms_of_use': + this.renderBnetTermsOfUse() + break + case 'bnet_create_account': + this.renderBnetCreateAccount() + break + case 'bnet_please_read': + this.renderBnetPleaseRead() + break case 'char_create': this.renderCharCreate() break @@ -1193,6 +1388,10 @@ export class FrontendRenderer { break } + if (this.state.popupModal !== null) { + this.renderPopupModal(this.state.popupModal) + } + // Draw the authentic Diablo II animated hand cursor on top of all front-end screens if (this.state.stage !== 'in_game') { this.cursor.render(ctx, this.cursorAtlas, nowMs) @@ -1304,6 +1503,238 @@ export class FrontendRenderer { color: 0, }) + for (const btn of this.getStageButtons()) { + this.drawButton(btn) + } + } + + /** + * 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') + if (bg) this.ctx.drawImage(bg, 0, 0) + + this.drawBurningMainLogo() + + const touBg = this.images.get('terms-of-use-bg.png') + if (touBg) { + this.ctx.drawImage(touBg, 103, 75) + } + + const s = this.manifest!.strings + this.drawBitmapText(s.termsOfUseTitle ?? 'Terms of Use', 388, 104, { + font: 'font24', + color: 4, + align: 'center', + }) + + const lines = this.getTermsWrappedLines() + const visibleLines = 18 + const lineStep = 16 + const maxScroll = Math.max(0, lines.length - visibleLines) + const startLine = Math.max(0, Math.min(maxScroll, this.state.termsScrollLine)) + + this.ctx.save() + this.ctx.beginPath() + this.ctx.rect(120, 149, 540, 318) + this.ctx.clip() + for (let i = 0; i < visibleLines; i++) { + const lineText = lines[startLine + i] + if (lineText === undefined) break + if (lineText.length > 0) { + this.drawBitmapText(lineText, 120, 149 + i * lineStep, { + font: 'fontformal10', + color: 0, + align: 'left', + }) + } + } + 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 + const thumbY = Math.round(trackTop + ratio * (trackBottom - trackTop)) + this.ctx.drawImage(sbImg, fw * 4, 0, fw, fh, 668, thumbY, fw, fh) + } + + for (const btn of this.getStageButtons()) { + this.drawButton(btn) + } + } + + /** + * Stage: Create Battle.net Account (`D2Launch.dll` `0x6fa57780`, layout `0x6fa61310..0x6fa61fa0`). + */ + private renderBnetCreateAccount(): void { + const bg = this.images.get('bg-main-menu.png') + if (bg) this.ctx.drawImage(bg, 0, 0) + + 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, + }, + ) + + // 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.drawEditBox('accountName', 319, 321, 169, 26, this.state.accountName, { + drawFrameImage: true, + font: 'fontformal12', + color: 0, + }) + + // 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.drawEditBox('accountPassword', 319, 375, 169, 26, this.state.accountPassword, { + drawFrameImage: true, + maskChar: '*', + font: 'fontformal12', + color: 0, + }) + + // 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, { + font: 'font16', + color: 4, + align: 'left', + }) + this.drawEditBox( + 'accountVerifyPassword', + 319, + 429, + 169, + 26, + this.state.accountVerifyPassword, + { + drawFrameImage: true, + maskChar: '*', + font: 'fontformal12', + color: 0, + }, + ) + + for (const btn of this.getStageButtons()) { + this.drawButton(btn) + } + } + + /** + * Stage: PLEASE READ Confirmation (`D2Launch.dll` `0x6fa584d0`, layout `0x6fa620f0..0x6fa621e0`). + */ + private renderBnetPleaseRead(): void { + const bg = this.images.get('bg-main-menu.png') + if (bg) this.ctx.drawImage(bg, 0, 0) + + this.drawBurningMainLogo() + + const touBg = this.images.get('terms-of-use-bg.png') + if (touBg) { + this.ctx.drawImage(touBg, 103, 75) + } + + const s = this.manifest!.strings + this.drawBitmapText(s.pleaseReadTitle ?? 'PLEASE READ', 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.' + + this.drawWrappedBitmapText(para1, 395, 162, 570, { + font: 'fontformal12', + color: 0, + align: 'center', + lineStep: 17, + }) + this.drawWrappedBitmapText(para2, 395, 292, 570, { + font: 'fontformal12', + color: 0, + align: 'center', + lineStep: 17, + }) + this.drawWrappedBitmapText(para3, 395, 382, 570, { + font: 'fontformal12', + color: 0, + align: 'center', + lineStep: 17, + }) + + for (const btn of this.getStageButtons()) { + this.drawButton(btn) + } + } + + /** + * Render a centered `PopUpOK.dc6` (`268x176`) modal popup at `(268, 212)` (`D2Launch.dll` `0x6fa55090`). + */ + private renderPopupModal(modal: { + readonly title?: string | undefined + readonly message: string + }): void { + const popupImg = this.images.get('popup-okcancel.png') + if (popupImg) { + this.ctx.drawImage(popupImg, 268, 212) + } + + if (modal.title) { + this.drawWrappedBitmapText(modal.title, 400, 232, 224, { + font: 'fontformal12', + color: 1, + align: 'center', + lineStep: 15, + maxLines: 2, + }) + this.drawWrappedBitmapText(modal.message, 400, 256, 224, { + font: 'fontformal12', + color: 0, + align: 'center', + lineStep: 15, + maxLines: 4, + }) + } else { + this.drawWrappedBitmapText(modal.message, 400, 238, 224, { + font: 'fontformal12', + color: 0, + align: 'center', + lineStep: 16, + maxLines: 4, + }) + } + for (const btn of this.getActiveButtons()) { this.drawButton(btn) } @@ -1831,7 +2262,25 @@ export class FrontendRenderer { } } - // Stage-specific non-button interactions (editboxes, campfire heroes, character slots, checkboxes, radio buttons, chat gem) + if (this.state.popupModal !== null) { + return + } + + // Stage-specific non-button interactions (editboxes, scrollbars, campfire heroes, character slots, checkboxes, radio buttons, chat gem) + if (this.state.stage === 'bnet_terms_of_use') { + const maxLine = this.getTermsMaxScrollLine() + if (x >= 630 && x <= 682 && y >= 145 && y <= 165) { + scrollTermsOfUse(this.state, -1, maxLine) + } else if (x >= 630 && x <= 682 && y >= 425 && y <= 469) { + scrollTermsOfUse(this.state, 1, maxLine) + } else if (x >= 630 && x <= 682 && y > 165 && y < 425) { + const ratio = Math.max(0, Math.min(1, (y - 165) / (425 - 165))) + const targetLine = Math.round(ratio * maxLine) + scrollTermsOfUse(this.state, targetLine - this.state.termsScrollLine, maxLine) + } + return + } + if (this.state.stage === 'bnet_login') { if (x >= 278 && x <= 521 && y >= 316 && y <= 347) { this.state.activeInput = 'accountName' @@ -1841,6 +2290,17 @@ export class FrontendRenderer { return } + if (this.state.stage === 'bnet_create_account') { + if (x >= 278 && x <= 521 && y >= 318 && y <= 352) { + this.state.activeInput = 'accountName' + } else if (x >= 278 && x <= 521 && y >= 372 && y <= 411) { + this.state.activeInput = 'accountPassword' + } else if (x >= 278 && x <= 521 && y >= 426 && y <= 471) { + this.state.activeInput = 'accountVerifyPassword' + } + return + } + if (this.state.stage === 'char_create') { // Check if a campfire class was clicked for (const id of CAMPFIRE_CLASS_ORDER) { @@ -1977,9 +2437,51 @@ export class FrontendRenderer { } } + handleWheel(deltaY: number): void { + if (this.state.popupModal !== null) return + if (this.state.stage === 'bnet_terms_of_use') { + const deltaLines = deltaY > 0 ? 3 : deltaY < 0 ? -3 : 0 + if (deltaLines !== 0) { + scrollTermsOfUse(this.state, deltaLines, this.getTermsMaxScrollLine()) + } + } + } + handleKeyDown(e: KeyboardEvent): void { if (this.state.stage === 'in_game' || this.state.stage === 'door_loading') return + if (this.state.popupModal !== null) { + if (e.key === 'Enter' || e.key === 'Escape' || e.key === ' ') { + e.preventDefault() + dismissPopupModal(this.state) + } + return + } + + if (this.state.stage === 'bnet_terms_of_use') { + const maxLine = this.getTermsMaxScrollLine() + if (e.key === 'ArrowUp') { + e.preventDefault() + scrollTermsOfUse(this.state, -2, maxLine) + return + } + if (e.key === 'ArrowDown') { + e.preventDefault() + scrollTermsOfUse(this.state, 2, maxLine) + return + } + if (e.key === 'PageUp') { + e.preventDefault() + scrollTermsOfUse(this.state, -15, maxLine) + return + } + if (e.key === 'PageDown') { + e.preventDefault() + scrollTermsOfUse(this.state, 15, maxLine) + return + } + } + if (e.key === 'Escape') { e.preventDefault() if (this.state.stage === 'bnet_connecting') { @@ -1988,6 +2490,12 @@ export class FrontendRenderer { this.state.stage = 'main_menu' this.state.stageTicks = 0 this.state.activeInput = null + } else if (this.state.stage === 'bnet_terms_of_use') { + disagreeTermsOfUse(this.state) + } else if (this.state.stage === 'bnet_create_account') { + cancelCreateAccount(this.state) + } else if (this.state.stage === 'bnet_please_read') { + cancelPleaseRead(this.state) } else if (this.state.stage === 'char_create') { if (this.state.roster.length > 0) { this.state.stage = 'char_select' @@ -2025,6 +2533,15 @@ export class FrontendRenderer { } else { submitBattleNetLogin(this.state) } + } else if (this.state.stage === 'bnet_terms_of_use') { + agreeTermsOfUse(this.state) + } else if (this.state.stage === 'bnet_create_account') { + submitCreateAccountForm( + this.state, + this.manifest?.strings as unknown as Record, + ) + } else if (this.state.stage === 'bnet_please_read') { + this.confirmPleaseRead() } else if (this.state.stage === 'char_create') { if (this.asyncHandlers?.onCreateCharacter) { void this.asyncHandlers.onCreateCharacter(this.state) @@ -2054,6 +2571,8 @@ export class FrontendRenderer { if (this.state.stage === 'bnet_login') { this.state.activeInput = this.state.activeInput === 'accountName' ? 'accountPassword' : 'accountName' + } else if (this.state.stage === 'bnet_create_account') { + cycleCreateAccountFocus(this.state, e.shiftKey) } else if (this.state.stage === 'bnet_lobby') { const order: ActiveInputField[] = this.state.lobbySubPanel === 'join' @@ -2072,6 +2591,18 @@ export class FrontendRenderer { return } + if (this.state.stage === 'bnet_create_account') { + if (e.key === 'Backspace' || e.key === 'Delete') { + e.preventDefault() + backspaceCreateAccountChar(this.state) + return + } + if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) { + typeCreateAccountChar(this.state, e.key) + } + return + } + const field = this.state.activeInput if (!field) return @@ -2103,12 +2634,21 @@ export class FrontendRenderer { if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) { const ch = e.key const cur = this.getFieldValue(field) - if (field === 'charName' || field === 'gameName' || field === 'gamePassword' || field === 'accountName') { + if (field === 'accountName' || field === 'bnet_account') { + if (isValidAccountChar(ch) && cur.length < 15) { + this.setFieldValue(field, cur + ch) + } + } else if (field === 'charName' || field === 'gameName' || field === 'gamePassword') { if (/^[A-Za-z0-9_-]$/.test(ch) && cur.length < 15) { this.setFieldValue(field, cur + ch) } - } else if (field === 'accountPassword') { - if (cur.length < 16) { + } else if ( + field === 'accountPassword' || + field === 'accountVerifyPassword' || + field === 'bnet_password' || + field === 'bnet_verify_password' + ) { + if (cur.length < 15) { this.setFieldValue(field, cur + ch) } } else if (field === 'maxPlayers') { @@ -2140,9 +2680,14 @@ export class FrontendRenderer { private getFieldValue(field: Exclude): string { switch (field) { case 'accountName': + case 'bnet_account': return this.state.accountName case 'accountPassword': + case 'bnet_password': return this.state.accountPassword + case 'accountVerifyPassword': + case 'bnet_verify_password': + return this.state.accountVerifyPassword case 'charName': return this.state.charName case 'gameName': @@ -2163,11 +2708,17 @@ export class FrontendRenderer { private setFieldValue(field: Exclude, val: string): void { switch (field) { case 'accountName': + case 'bnet_account': this.state.accountName = val break case 'accountPassword': + case 'bnet_password': this.state.accountPassword = val break + case 'accountVerifyPassword': + case 'bnet_verify_password': + this.state.accountVerifyPassword = val + break case 'charName': this.state.charName = val this.state.charCreateError = null diff --git a/src/client/main.ts b/src/client/main.ts index 16b047f..49700ac 100644 --- a/src/client/main.ts +++ b/src/client/main.ts @@ -13,6 +13,7 @@ */ import { + applyCreateAccountServerError, applyOnlineCharactersToFlowState, createInitialFlowState, isDifficultyUnlocked, @@ -25,7 +26,7 @@ import { FrontendRenderer } from './frontend/frontend-renderer.ts' import { LightGrid } from '../common/drlg/light-grid.ts' import type { LoadedMonsterArt } from './render/monster-art.ts' import { setLang, type GameLang } from './i18n/lang.ts' -import type { CharClassId } from '../netproto/index.ts' +import { BncsCreateAccountError, type CharClassId } from '../netproto/index.ts' import { SpriteRenderer, type AtlasHandle } from './render/renderer.ts' import { DEFAULT_BASES, @@ -428,6 +429,14 @@ async function bootPlayApp(): Promise { }) const flowState = createInitialFlowState() + try { + const savedAccount = window.localStorage?.getItem('d2_last_bnet_account') + if (savedAccount && savedAccount.trim()) { + flowState.accountName = savedAccount.trim().slice(0, 15) + } + } catch { + // Ignore localStorage access errors in restricted contexts + } const frontendRenderer = new FrontendRenderer(bnetCanvas, flowState) let inGameView = false @@ -490,13 +499,26 @@ async function bootPlayApp(): Promise { const res = createNewAccount ? await session.createAccountAndLogin({ username, password, host: targetHost }) : await session.login({ username, password, host: targetHost }) + try { + window.localStorage?.setItem('d2_last_bnet_account', username) + } catch { + // Ignore localStorage write errors + } if (res.realms[0]) { st.gateway = res.realms[0].title } - applyOnlineCharactersToFlowState(st, res.characters) + applyOnlineCharactersToFlowState(st, res.characters, { + stayOnCharSelect: Boolean(createNewAccount), + }) } catch (err) { - // A lost session was already routed to the login screen by `onSessionLost`. - if (!(err instanceof SessionLostError)) { + if (err instanceof BncsCreateAccountError) { + applyCreateAccountServerError( + st, + err.status, + frontendRenderer.manifest?.strings as unknown as Record, + ) + } else if (!(err instanceof SessionLostError)) { + // A lost session was already routed to the login screen by `onSessionLost`. st.loginError = err instanceof Error ? err.message : String(err) st.lastError = st.loginError } @@ -841,6 +863,18 @@ async function bootPlayApp(): Promise { e.preventDefault() }) + stageEl.addEventListener( + 'wheel', + (e) => { + if (!inGameView && flowState.stage === 'bnet_terms_of_use') { + e.preventDefault() + frontendRenderer.handleWheel(e.deltaY) + frontendRenderer.render(performance.now()) + } + }, + { passive: false }, + ) + // Capture-phase keydown listener so pre-game text inputs and CommandMapper hotkeys // do not double-trigger HudManager's bubble-phase listener. window.addEventListener( diff --git a/src/client/session/online-session.ts b/src/client/session/online-session.ts index 3051154..e28e26d 100644 --- a/src/client/session/online-session.ts +++ b/src/client/session/online-session.ts @@ -16,6 +16,7 @@ */ import { + BncsCreateAccountError, D2gsS2cFramer, InMemoryPacketTap, createD2OnlineFlow, @@ -522,6 +523,18 @@ export class OnlineSession { return await this.establishSession(params, true) } + /** + * Connect to BNCS, optionally create a new account (`createNewAccount: true`), authenticate, and enter the realm character screen. + */ + async connectAndAuthenticate( + params: OnlineSessionLoginParams & { readonly createNewAccount?: boolean | undefined }, + ): Promise<{ + readonly realms: readonly RealmSummary[] + readonly characters: readonly CharSummary[] + }> { + return await this.establishSession(params, Boolean(params.createNewAccount)) + } + private async establishSession( params: OnlineSessionLoginParams, createAccount: boolean, @@ -580,10 +593,16 @@ export class OnlineSession { // A newer login replaced this attempt; its state belongs to the newer attempt. throw err } - // Rejected login (bad password or unknown account): the attempt's connections are closed - // and the login screen shows the server's answer. + // Rejected login or account creation: the attempt's connections are closed. + // For BncsCreateAccountError, keep disconnectReasonValue null so the in-canvas + // PopUpOkCancel2.dc6 modal displays without a redundant DOM disconnect banner. this.pendingFlowClose = this.closeFlowQuietly() - this.disconnectReasonValue = err instanceof Error ? err.message : String(err) + this.disconnectReasonValue = + err instanceof BncsCreateAccountError + ? null + : err instanceof Error + ? err.message + : String(err) this.setPhase('disconnected') throw err } diff --git a/src/netproto/bncs/packets.ts b/src/netproto/bncs/packets.ts index 9e71fe4..b2bdec7 100644 --- a/src/netproto/bncs/packets.ts +++ b/src/netproto/bncs/packets.ts @@ -31,6 +31,18 @@ export const BncsOpcode = { SID_CHATEVENT: 0x0f, } as const +export const BncsCreateAccountStatus = { + OK: 0x00, + TOO_SHORT: 0x01, + INVALID_CHARS: 0x02, + BANNED_WORD: 0x03, + ALREADY_EXISTS: 0x04, + STILL_PROCESSING: 0x05, + NOT_ENOUGH_ALPHANUM: 0x06, + ADJACENT_PUNCT: 0x07, + TOO_MANY_PUNCT: 0x08, +} as const + export const FOURCC_IX86 = 0x49583836 // 'IX86' in LE: '68XI' export const FOURCC_D2XP = 0x44325850 // 'D2XP' in LE: 'PX2D' export const FOURCC_D2DV = 0x44324456 // 'D2DV' in LE: 'VD2D' @@ -76,6 +88,7 @@ export interface BncsAuthCheckResponse { export interface BncsCreateAccountResponse { readonly status: number readonly suggestion: string + readonly nameSuggestion: string } export interface BncsLogonResponse2 { @@ -331,7 +344,7 @@ export function decodeBncsS2cPacket( const suggestion = r.remaining > 0 ? r.cstring(codec) : '' return { type: 'SID_CREATEACCOUNT2', - data: { status, suggestion }, + data: { status, suggestion, nameSuggestion: suggestion }, } } diff --git a/src/netproto/core/errors.ts b/src/netproto/core/errors.ts index 45f43a3..2ced53b 100644 --- a/src/netproto/core/errors.ts +++ b/src/netproto/core/errors.ts @@ -109,3 +109,22 @@ export class NetprotoError extends ProtocolError { } } } + +export class BncsCreateAccountError extends ProtocolError { + readonly status: number + readonly suggestion: string + readonly nameSuggestion: string + + constructor(status: number, suggestion = '', accountName?: string) { + const target = accountName ? ` for "${accountName}"` : '' + super( + `BNCS SID_CREATEACCOUNT2 failed${target} with status=0x${(status >>> 0).toString(16)}`, + { proto: 'bncs', packetId: 0x3d }, + ) + this.name = 'BncsCreateAccountError' + this.status = status + this.suggestion = suggestion + this.nameSuggestion = suggestion + } +} + diff --git a/src/netproto/flow/online-flow.ts b/src/netproto/flow/online-flow.ts index c19f70e..94ffd60 100644 --- a/src/netproto/flow/online-flow.ts +++ b/src/netproto/flow/online-flow.ts @@ -12,7 +12,7 @@ import { BncsSession } from '../bncs/session.ts' import { defaultClock } from '../core/clock.ts' import { Emitter } from '../core/emitter.ts' -import { ClosedError, ProtocolError, TimeoutError } from '../core/errors.ts' +import { BncsCreateAccountError, ClosedError, ProtocolError, TimeoutError } from '../core/errors.ts' import { resolveTextCodec } from '../core/text-codec.ts' import { D2gsAdapter } from '../d2gs/adapter.ts' import { D2gsSession } from '../d2gs/session.ts' @@ -51,7 +51,10 @@ export interface D2OnlineFlow { loginOrRegister( user: string, pass: string, - opts?: { readonly register?: boolean | undefined }, + opts?: { + readonly register?: boolean | undefined + readonly createNewAccount?: boolean | undefined + }, ): Promise listRealms(): Promise enterRealm(realm?: string): Promise @@ -352,10 +355,7 @@ export function createD2OnlineFlow(config: D2OnlineConfig): D2OnlineFlow { const session = requireBncs('createAccount') const res = await exchange('bncs', () => session.createAccount(user, pass)) if (res.status !== 0) { - throw new ProtocolError( - `BNCS SID_CREATEACCOUNT2 failed for "${user}" with status=0x${res.status.toString(16)}`, - { proto: 'bncs', packetId: 0x3d }, - ) + throw new BncsCreateAccountError(res.status, res.nameSuggestion, user) } }, @@ -366,21 +366,33 @@ export function createD2OnlineFlow(config: D2OnlineConfig): D2OnlineFlow { async loginOrRegister( user: string, pass: string, - opts?: { readonly register?: boolean | undefined }, + opts?: { + readonly register?: boolean | undefined + readonly createNewAccount?: boolean | undefined + }, ): Promise { if (!bncs) { await flow.connectBnet() } const session = requireBncs('login') - if (opts?.register) { + if (opts?.createNewAccount) { + const createRes = await exchange('bncs', () => session.createAccount(user, pass)) + if (createRes.status !== 0) { + throw new BncsCreateAccountError(createRes.status, createRes.nameSuggestion, user) + } + const loginRes = await exchange('bncs', () => session.login(user, pass)) + if (loginRes.status !== 0) { + throw new ProtocolError( + `BNCS SID_LOGONRESPONSE2 failed after registration for "${user}" with status=0x${loginRes.status.toString(16)}`, + { proto: 'bncs', packetId: 0x3a }, + ) + } + } else if (opts?.register) { const firstLogin = await exchange('bncs', () => session.login(user, pass)) if (firstLogin.status !== 0) { const createRes = await exchange('bncs', () => session.createAccount(user, pass)) if (createRes.status !== 0) { - throw new ProtocolError( - `BNCS SID_CREATEACCOUNT2 failed for "${user}" with status=0x${createRes.status.toString(16)}`, - { proto: 'bncs', packetId: 0x3d }, - ) + throw new BncsCreateAccountError(createRes.status, createRes.nameSuggestion, user) } const secondLogin = await exchange('bncs', () => session.login(user, pass)) if (secondLogin.status !== 0) { diff --git a/src/netproto/index.ts b/src/netproto/index.ts index 5d64618..cecc195 100644 --- a/src/netproto/index.ts +++ b/src/netproto/index.ts @@ -103,7 +103,14 @@ export { BitWriter } from './core/bit-writer.ts' export { ByteReader } from './core/byte-reader.ts' export { ByteWriter } from './core/byte-writer.ts' export { TypedEmitter } from './core/emitter.ts' -export { ClosedError, NetprotoError, ProtocolError, TimeoutError, type NetprotoErrorOptions } from './core/errors.ts' +export { + BncsCreateAccountError, + ClosedError, + NetprotoError, + ProtocolError, + TimeoutError, + type NetprotoErrorOptions, +} from './core/errors.ts' export { InMemoryPacketTap, MemoryPacketTap, @@ -116,7 +123,13 @@ export { export { createBrowserTextCodec, defaultTextCodec, resolveTextCodec } from './core/text-codec.ts' export { defaultClock, systemClock } from './core/clock.ts' export { BncsFramer } from './bncs/framing.ts' -export { decodeBncsS2cPacket, encodeBncsNull } from './bncs/packets.ts' +export { + BncsCreateAccountStatus, + BncsOpcode, + decodeBncsS2cPacket, + encodeBncsCreateAccount2, + encodeBncsNull, +} from './bncs/packets.ts' export { BNCS_KEEPALIVE_INTERVAL_MS } from './bncs/session.ts' export { McpFramer } from './mcp/framing.ts' export { decodeMcpS2cPacket } from './mcp/packets.ts' diff --git a/tests/e2e-bnet-create-account.test.ts b/tests/e2e-bnet-create-account.test.ts new file mode 100644 index 0000000..e65da67 --- /dev/null +++ b/tests/e2e-bnet-create-account.test.ts @@ -0,0 +1,675 @@ +/** + * Headless Chromium + CDP End-to-End Verification for Battle.net "Create New Account" (`play.html`). + * + * Verifies the complete multi-stage workflow in a real headless Chromium browser: + * 1. Clicking `CREATE NEW ACCOUNT` on `bnet_login` -> `bnet_terms_of_use` + * 2. Scrolling Terms of Use and clicking `AGREE` -> `bnet_create_account` + * 3. Triggering and dismissing client-side validation error popup (`PopUpOkCancel2.dc6` + `btn-popup-small.png`) + * 4. 3-field `Tab` cycling (`accountName` -> `accountPassword` -> `accountVerifyPassword`) and `0x6fa52630` character filtering + * 5. Advancing through `bnet_please_read`, handling server-side `SID_CREATEACCOUNT2` rejection (`0x04 ALREADY_EXISTS` -> `5239`), + * and completing account creation into `char_select` with `d2_last_bnet_account` persisted in `localStorage` + * and zero `console.error` / `pageerror` events. + */ + +import assert from 'node:assert/strict' +import { spawn } from 'node:child_process' +import { createHash } from 'node:crypto' +import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join, resolve } from 'node:path' +import { describe, it } from 'node:test' +import { fileURLToPath } from 'node:url' +import { createServer, type ViteDevServer } from 'vite' + +import { BncsCreateAccountStatus, type EndpointKind } from '../src/netproto/index.ts' +import { createFakeRealm, type FakeRealmOptions } from './netproto/fake-realm-servers.ts' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const ROOT_DIR = resolve(__dirname, '..') + +const CHROME_CANDIDATES = [ + join(process.env.HOME ?? '', '.cache/ms-playwright/chromium-1228/chrome-linux64/chrome'), + '/root/.cache/ms-playwright/chromium-1223/chrome-linux64/chrome', + '/usr/bin/google-chrome', +] + +function findChromeBinary(): string { + for (const cand of CHROME_CANDIDATES) { + if (cand && existsSync(cand)) return cand + } + throw new Error(`No headless Chrome binary found in candidates: ${CHROME_CANDIDATES.join(', ')}`) +} + +function sleep(ms: number): Promise { + return new Promise((r) => setTimeout(r, ms)) +} + +function encodeWsBinaryFrame(payload: Buffer): Buffer { + const len = payload.length + if (len < 126) { + const header = Buffer.allocUnsafe(2) + header[0] = 0x82 + header[1] = len + return Buffer.concat([header, payload]) + } + if (len < 65536) { + const header = Buffer.allocUnsafe(4) + header[0] = 0x82 + header[1] = 126 + header.writeUInt16BE(len, 2) + return Buffer.concat([header, payload]) + } + const header = Buffer.allocUnsafe(10) + header[0] = 0x82 + header[1] = 127 + header.writeBigUInt64BE(BigInt(len), 2) + return Buffer.concat([header, payload]) +} + +function attachLocalD2NetWsBridge( + viteServer: ViteDevServer, + getOptions: () => FakeRealmOptions, +): void { + viteServer.httpServer?.on('upgrade', (req, socket, head) => { + const url = req.url ?? '' + if (!url.startsWith('/d2net/')) return + + let kind: EndpointKind = 'bnet' + let targetPort = 6112 + if (url.startsWith('/d2net/realm')) { + kind = 'realm' + targetPort = 6113 + } else if (url.startsWith('/d2net/game')) { + kind = 'game' + targetPort = 4000 + } + + const key = req.headers['sec-websocket-key'] + if (typeof key !== 'string') { + socket.destroy() + return + } + const accept = createHash('sha1') + .update(key + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11') + .digest('base64') + + const realm = createFakeRealm(getOptions()) + void realm.resolver.open(kind, '127.0.0.1', targetPort).then((stream) => { + let wsBuf: Buffer = head && head.length > 0 ? Buffer.from(head) : Buffer.alloc(0) + + stream.onData((chunk) => { + if (!socket.destroyed) { + socket.write(encodeWsBinaryFrame(Buffer.from(chunk))) + } + }) + + stream.onClose(() => { + if (!socket.destroyed) { + socket.end(Buffer.from([0x88, 0x00])) + } + }) + + const pumpWsToStream = (): void => { + while (wsBuf.length >= 2) { + const b0 = wsBuf[0]! + const b1 = wsBuf[1]! + const opcode = b0 & 0x0f + const masked = (b1 & 0x80) !== 0 + let payloadLen = b1 & 0x7f + let offset = 2 + + if (payloadLen === 126) { + if (wsBuf.length < 4) return + payloadLen = wsBuf.readUInt16BE(2) + offset = 4 + } else if (payloadLen === 127) { + if (wsBuf.length < 10) return + payloadLen = Number(wsBuf.readBigUInt64BE(2)) + offset = 10 + } + + const maskBytes = masked ? 4 : 0 + if (wsBuf.length < offset + maskBytes + payloadLen) return + + const maskKey = masked ? wsBuf.subarray(offset, offset + 4) : null + offset += maskBytes + const payload = Buffer.from(wsBuf.subarray(offset, offset + payloadLen)) + wsBuf = wsBuf.subarray(offset + payloadLen) + + if (maskKey) { + for (let i = 0; i < payload.length; i++) { + payload[i] = payload[i]! ^ maskKey[i & 3]! + } + } + + if (opcode === 0x08) { + void stream.close({ kind: 'remote' }) + socket.end() + return + } + if (opcode === 0x01 || opcode === 0x02 || opcode === 0x00) { + stream.write(new Uint8Array(payload)) + } + } + } + + socket.write( + 'HTTP/1.1 101 Switching Protocols\r\n' + + 'Upgrade: websocket\r\n' + + 'Connection: Upgrade\r\n' + + `Sec-WebSocket-Accept: ${accept}\r\n\r\n`, + ) + pumpWsToStream() + + socket.on('data', (chunk: Buffer) => { + wsBuf = Buffer.concat([wsBuf, chunk]) + pumpWsToStream() + }) + + socket.on('close', () => { + void stream.close({ kind: 'local' }) + }) + socket.on('error', (err: Error) => { + void stream.close({ kind: 'error', error: err }) + }) + }) + }) +} + +class CdpSession { + private readonly ws: WebSocket + private msgId = 0 + private readonly pending = new Map< + number, + { readonly resolve: (val: any) => void; readonly reject: (err: Error) => void } + >() + readonly consoleErrors: string[] = [] + readonly pageExceptions: string[] = [] + + private constructor(ws: WebSocket) { + this.ws = ws + this.ws.onmessage = (ev) => { + const msg = JSON.parse(String(ev.data)) + if (typeof msg.id === 'number') { + const entry = this.pending.get(msg.id) + if (entry) { + this.pending.delete(msg.id) + if (msg.error) { + entry.reject(new Error(msg.error.message || JSON.stringify(msg.error))) + } else { + entry.resolve(msg.result) + } + } + return + } + if (msg.method === 'Runtime.consoleAPICalled') { + const type = msg.params?.type + if (type === 'error') { + const text = (msg.params?.args ?? []) + .map((a: any) => a.value ?? a.description ?? '') + .join(' ') + if (!text.includes('favicon.ico')) { + this.consoleErrors.push(text) + } + } + } else if (msg.method === 'Runtime.exceptionThrown') { + const details = msg.params?.exceptionDetails + const desc = + details?.exception?.description ?? details?.text ?? JSON.stringify(details) + this.pageExceptions.push(String(desc)) + } + } + } + + static async connect(wsUrl: string): Promise { + const ws = new WebSocket(wsUrl) + await new Promise((resolve, reject) => { + ws.onopen = () => resolve() + ws.onerror = (err) => reject(new Error(`CDP WebSocket error: ${String(err)}`)) + }) + const cdp = new CdpSession(ws) + await cdp.send('Runtime.enable') + await cdp.send('Page.enable') + return cdp + } + + send(method: string, params: Record = {}): Promise { + const id = ++this.msgId + return new Promise((resolve, reject) => { + this.pending.set(id, { resolve, reject }) + this.ws.send(JSON.stringify({ id, method, params })) + }) + } + + async evaluate(expression: string): Promise { + const res = await this.send('Runtime.evaluate', { + expression, + returnByValue: true, + awaitPromise: true, + }) + if (res?.exceptionDetails) { + const desc = + res.exceptionDetails.exception?.description ?? + res.exceptionDetails.text ?? + JSON.stringify(res.exceptionDetails) + throw new Error(`CDP evaluate exception: ${String(desc)}`) + } + return res?.result?.value as T + } + + close(): void { + try { + this.ws.close() + } catch { + // Ignore close error + } + } +} + +describe('Headless Chromium E2E: Battle.net Create New Account flow on play.html', () => { + it('completes CREATE NEW ACCOUNT -> Terms of Use -> Create Account (validation popup + 3 fields) -> Please Read -> char_select with 0 errors', async () => { + const chromeBin = findChromeBinary() + let fakeRealmOptions: FakeRealmOptions = { + characters: 0, + createAccountStatus: BncsCreateAccountStatus.OK, + } + + let viteServer: ViteDevServer | null = null + const userDataDir = mkdtempSync(join(tmpdir(), 'd2-e2e-create-account-')) + let chromeProc: ReturnType | null = null + let cdp: CdpSession | null = null + + try { + viteServer = await createServer({ + root: ROOT_DIR, + plugins: [ + { + name: 'd2-e2e-strip-d2net-proxy', + configResolved(config) { + if (config.server.proxy) { + delete (config.server.proxy as Record)['/d2net'] + } + }, + }, + ], + server: { + host: '127.0.0.1', + port: 0, + strictPort: false, + }, + logLevel: 'error', + }) + attachLocalD2NetWsBridge(viteServer, () => fakeRealmOptions) + await viteServer.listen() + + const addr = viteServer.httpServer?.address() + const port = typeof addr === 'object' && addr ? addr.port : 0 + assert.ok(port > 0, 'Programmatic Vite server must bind an ephemeral port') + + const targetUrl = `http://127.0.0.1:${port}/play.html` + + chromeProc = spawn( + chromeBin, + [ + '--headless=new', + '--no-sandbox', + '--disable-dev-shm-usage', + '--disable-gpu-sandbox', + '--use-angle=swiftshader', + '--use-gl=angle', + '--enable-unsafe-swiftshader', + '--ignore-gpu-blocklist', + '--disable-background-timer-throttling', + '--disable-renderer-backgrounding', + '--disable-backgrounding-occluded-windows', + '--no-proxy-server', + '--password-store=basic', + '--disable-extensions', + '--disable-component-extensions-with-background-pages', + '--no-first-run', + '--no-default-browser-check', + '--force-device-scale-factor=1', + '--window-size=1280,960', + `--user-data-dir=${userDataDir}`, + '--remote-debugging-port=0', + 'about:blank', + ], + { stdio: ['ignore', 'ignore', 'pipe'] }, + ) + + let cdpPort: string | null = null + for (let i = 0; i < 100; i++) { + await sleep(100) + try { + const raw = readFileSync(join(userDataDir, 'DevToolsActivePort'), 'utf8') + const firstLine = raw.trim().split('\n')[0] + if (firstLine) { + cdpPort = firstLine + break + } + } catch { + // Wait for DevToolsActivePort + } + } + assert.ok(cdpPort, 'Headless Chrome must write DevToolsActivePort') + + let pageTarget: { readonly url: string; readonly webSocketDebuggerUrl: string } | null = null + for (let i = 0; i < 100; i++) { + await sleep(100) + try { + const res = await fetch(`http://127.0.0.1:${cdpPort}/json`) + const list = (await res.json()) as { + readonly type: string + readonly url: string + readonly webSocketDebuggerUrl: string + }[] + const pages = list.filter((p) => p.type === 'page') + if (pages.length > 0 && pages[0]) { + pageTarget = pages[0] + break + } + } catch { + // Keep polling + } + } + assert.ok(pageTarget, 'Must attach to headless Chrome page target') + + cdp = await CdpSession.connect(pageTarget.webSocketDebuggerUrl) + // Point SettingsStore at our local Vite /d2net WebSocket bridge before play.html boots + await cdp.send('Page.addScriptToEvaluateOnNewDocument', { + source: ` + localStorage.setItem('d2web.settings.v1', JSON.stringify({ + version: 1, + serverHost: '127.0.0.1:${port}' + })); + localStorage.removeItem('d2_last_bnet_account'); + `, + }) + await cdp.send('Page.navigate', { url: targetUrl }) + + // Wait for window.__d2Play.ready === true and frontendRenderer assets loaded + let ready = false + for (let i = 0; i < 150; i++) { + const state = await cdp.evaluate<{ ready: boolean; hasManifest: boolean } | null>( + `window.__d2Play ? { ready: Boolean(window.__d2Play.ready), hasManifest: Boolean(window.__d2Play.frontendRenderer?.manifest) } : null`, + ) + if (state?.ready && state?.hasManifest) { + ready = true + break + } + await sleep(150) + } + assert.equal(ready, true, 'window.__d2Play.ready and frontendRenderer.manifest must load') + + // Step 1: Click BATTLE.NET on main_menu -> advance connecting ticks -> click CREATE NEW ACCOUNT on bnet_login + const step1 = await cdp.evaluate<{ + stageAfterBnetClick: string + stageOnLogin: string + stageAfterCreateNewClick: string + termsScrollLineInitial: number + nonBlackPixels: number + }>(`(() => { + const app = window.__d2Play; + const fr = app.frontendRenderer; + const st = app.flowState; + + // Click BATTLE.NET button (x=400, y=348) + fr.handleMouseDown(400, 348, performance.now()); + fr.handleMouseUp(400, 348, performance.now()); + const stageAfterBnetClick = st.stage; + + // Advance connecting ticks into bnet_login + for (let i = 0; i < 20; i++) { + app.renderFrame(performance.now() + i * 40); + } + const stageOnLogin = st.stage; + + // Click CREATE NEW ACCOUNT button on bnet_login (x=400, y=525) + fr.handleMouseDown(400, 525, performance.now()); + fr.handleMouseUp(400, 525, performance.now()); + app.renderFrame(performance.now() + 1000); + + const bnetCanvas = document.getElementById('bnet-canvas'); + const ctx = bnetCanvas.getContext('2d'); + const img = ctx.getImageData(0, 0, bnetCanvas.width, bnetCanvas.height).data; + let nonBlackPixels = 0; + for (let i = 0; i < img.length; i += 16) { + if (img[i] > 8 || img[i + 1] > 8 || img[i + 2] > 8) nonBlackPixels++; + } + + return { + stageAfterBnetClick, + stageOnLogin, + stageAfterCreateNewClick: st.stage, + termsScrollLineInitial: st.termsScrollLine, + nonBlackPixels, + }; + })()`) + + assert.equal(step1.stageAfterBnetClick, 'bnet_connecting') + assert.equal(step1.stageOnLogin, 'bnet_login') + assert.equal(step1.stageAfterCreateNewClick, 'bnet_terms_of_use') + assert.equal(step1.termsScrollLineInitial, 0) + assert.ok(step1.nonBlackPixels > 5000, 'bnet_terms_of_use canvas must render non-black pixels') + + // Step 2: Scroll Terms of Use (via wheel and scrollbar down-arrow) and click AGREE -> bnet_create_account + const step2 = await cdp.evaluate<{ + scrollAfterWheel: number + scrollAfterDownArrowClick: number + stageAfterAgree: string + activeInput: string + }>(`(() => { + const app = window.__d2Play; + const fr = app.frontendRenderer; + const st = app.flowState; + + fr.handleWheel(120); + const scrollAfterWheel = st.termsScrollLine; + + // Click scrollbar down arrow at (x=633, y=429) + fr.handleMouseDown(633, 429, performance.now()); + fr.handleMouseUp(633, 429, performance.now()); + const scrollAfterDownArrowClick = st.termsScrollLine; + + // Click AGREE button at (x=589, y=495) + fr.handleMouseDown(589, 495, performance.now()); + fr.handleMouseUp(589, 495, performance.now()); + app.renderFrame(performance.now() + 2000); + + return { + scrollAfterWheel, + scrollAfterDownArrowClick, + stageAfterAgree: st.stage, + activeInput: st.activeInput, + }; + })()`) + + assert.equal(step2.scrollAfterWheel, 3) + assert.equal(step2.scrollAfterDownArrowClick, 4) + assert.equal(step2.stageAfterAgree, 'bnet_create_account') + assert.equal(step2.activeInput, 'accountName') + + // Step 3: Trigger client-side validation error popup (accountName < 2 chars -> 5216) and dismiss it + const step3 = await cdp.evaluate<{ + stageDuringPopup: string + popupMessage: string | null + popupDismissedAfterClick: boolean + }>(`(() => { + const app = window.__d2Play; + const fr = app.frontendRenderer; + const st = app.flowState; + + // Type 'a' (1 char) and illegal '#' (filtered out by 0x6fa52630) + fr.handleKeyDown(new KeyboardEvent('keydown', { key: 'a' })); + fr.handleKeyDown(new KeyboardEvent('keydown', { key: '#' })); + + // Click OK at (x=691, y=554) + fr.handleMouseDown(691, 554, performance.now()); + fr.handleMouseUp(691, 554, performance.now()); + app.renderFrame(performance.now() + 3000); + + const stageDuringPopup = st.stage; + const popupMessage = st.popupModal ? st.popupModal.message : null; + + // Dismiss popup by clicking its OK button at (x=399, y=321) + fr.handleMouseDown(399, 321, performance.now()); + fr.handleMouseUp(399, 321, performance.now()); + app.renderFrame(performance.now() + 3100); + + return { + stageDuringPopup, + popupMessage, + popupDismissedAfterClick: st.popupModal === null, + }; + })()`) + + assert.equal(step3.stageDuringPopup, 'bnet_create_account') + assert.equal(step3.popupMessage, 'Account name must be at least 2 characters.') + assert.equal(step3.popupDismissedAfterClick, true) + + // Step 4: Test server-side rejection (ALREADY_EXISTS 0x04 -> 5239 with title 5249) returning to bnet_create_account + fakeRealmOptions = { + characters: 0, + createAccountStatus: BncsCreateAccountStatus.ALREADY_EXISTS, + createAccountSuggestion: 'Hero_2', + } + + const step4 = await cdp.evaluate<{ + stageOnPleaseRead: string + stageAfterRejectedCreate: string + preservedAccountName: string + popupTitle: string | null | undefined + popupMessage: string | null + disconnectBannerHidden: boolean + }>(`(async () => { + const app = window.__d2Play; + const fr = app.frontendRenderer; + const st = app.flowState; + + // Clear 'a' and type 'NewHero_1', Tab -> 'Secret123', Tab -> 'Secret123' + fr.handleKeyDown(new KeyboardEvent('keydown', { key: 'Backspace' })); + for (const ch of 'NewHero_1') { + fr.handleKeyDown(new KeyboardEvent('keydown', { key: ch })); + } + fr.handleKeyDown(new KeyboardEvent('keydown', { key: 'Tab' })); + for (const ch of 'Secret123') { + fr.handleKeyDown(new KeyboardEvent('keydown', { key: ch })); + } + fr.handleKeyDown(new KeyboardEvent('keydown', { key: 'Tab' })); + for (const ch of 'Secret123') { + fr.handleKeyDown(new KeyboardEvent('keydown', { key: ch })); + } + + // Click OK on bnet_create_account (x=691, y=554) -> advances to bnet_please_read + fr.handleMouseDown(691, 554, performance.now()); + fr.handleMouseUp(691, 554, performance.now()); + const stageOnPleaseRead = st.stage; + + // Click OK on bnet_please_read (x=589, y=495) + fr.handleMouseDown(589, 495, performance.now()); + fr.handleMouseUp(589, 495, performance.now()); + + // Wait for async SID_CREATEACCOUNT2 rejection to settle + for (let i = 0; i < 50; i++) { + if (st.stage === 'bnet_create_account' && st.popupModal) break; + await new Promise((r) => setTimeout(r, 30)); + } + app.renderFrame(performance.now() + 4000); + + const banner = document.getElementById('d2-disconnect-banner'); + const res = { + stageOnPleaseRead, + stageAfterRejectedCreate: st.stage, + preservedAccountName: st.accountName, + popupTitle: st.popupModal?.title, + popupMessage: st.popupModal ? st.popupModal.message : null, + disconnectBannerHidden: Boolean(!banner || banner.hidden), + }; + + // Dismiss the server error modal with Enter + fr.handleKeyDown(new KeyboardEvent('keydown', { key: 'Enter' })); + return res; + })()`) + + assert.equal(step4.stageOnPleaseRead, 'bnet_please_read') + assert.equal(step4.stageAfterRejectedCreate, 'bnet_create_account') + assert.equal(step4.preservedAccountName, 'NewHero_1') + assert.equal(step4.popupTitle, 'Unable to Create Account') + assert.equal( + step4.popupMessage?.includes('already exists'), + true, + `Expected 5239 message to include "already exists", got: ${String(step4.popupMessage)}`, + ) + assert.equal(step4.disconnectBannerHidden, true) + + // Step 5: Allow account creation to succeed -> advance through bnet_please_read -> land on char_select + fakeRealmOptions = { + characters: 0, + createAccountStatus: BncsCreateAccountStatus.OK, + } + + const step5 = await cdp.evaluate<{ + stageOnPleaseRead: string + finalStage: string + rosterCount: number + savedAccountInLocalStorage: string | null + }>(`(async () => { + const app = window.__d2Play; + const fr = app.frontendRenderer; + const st = app.flowState; + + // Re-enter password & verifyPassword (accountName 'NewHero_1' was preserved) + fr.handleKeyDown(new KeyboardEvent('keydown', { key: 'Tab' })); + for (const ch of 'Secret123') { + fr.handleKeyDown(new KeyboardEvent('keydown', { key: ch })); + } + fr.handleKeyDown(new KeyboardEvent('keydown', { key: 'Tab' })); + for (const ch of 'Secret123') { + fr.handleKeyDown(new KeyboardEvent('keydown', { key: ch })); + } + + // Click OK on bnet_create_account (x=691, y=554) -> bnet_please_read + fr.handleMouseDown(691, 554, performance.now()); + fr.handleMouseUp(691, 554, performance.now()); + const stageOnPleaseRead = st.stage; + + // Click OK on bnet_please_read (x=589, y=495) -> triggers createAccountAndLogin + fr.handleMouseDown(589, 495, performance.now()); + fr.handleMouseUp(589, 495, performance.now()); + + for (let i = 0; i < 80; i++) { + if (st.stage === 'char_select') break; + await new Promise((r) => setTimeout(r, 30)); + } + app.renderFrame(performance.now() + 5000); + + return { + stageOnPleaseRead, + finalStage: st.stage, + rosterCount: st.roster.length, + savedAccountInLocalStorage: localStorage.getItem('d2_last_bnet_account'), + }; + })()`) + + assert.equal(step5.stageOnPleaseRead, 'bnet_please_read') + assert.equal(step5.finalStage, 'char_select') + assert.equal(step5.rosterCount, 0) + assert.equal(step5.savedAccountInLocalStorage, 'NewHero_1') + assert.deepEqual(cdp.consoleErrors, [], 'Zero console.error events allowed') + assert.deepEqual(cdp.pageExceptions, [], 'Zero uncaught pageerror events allowed') + } finally { + cdp?.close() + if (chromeProc && !chromeProc.killed) { + chromeProc.kill('SIGKILL') + } + if (viteServer) { + await viteServer.close() + } + try { + rmSync(userDataDir, { recursive: true, force: true }) + } catch { + // Ignore cleanup errors + } + } + }) +}) diff --git a/tests/frontend-flow.test.ts b/tests/frontend-flow.test.ts new file mode 100644 index 0000000..7338e16 --- /dev/null +++ b/tests/frontend-flow.test.ts @@ -0,0 +1,350 @@ +import assert from 'node:assert/strict' +import { existsSync, readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { describe, it } from 'node:test' +import { + agreeTermsOfUse, + applyCreateAccountServerError, + applyOnlineCharactersToFlowState, + backspaceCreateAccountChar, + cancelCreateAccount, + cancelPleaseRead, + createInitialFlowState, + cycleCreateAccountFocus, + disagreeTermsOfUse, + dismissPopupModal, + filterAccountName, + isValidAccountChar, + mapCreateAccountServerStatus, + openTermsOfUse, + scrollTermsOfUse, + submitCreateAccountForm, + typeCreateAccountChar, + validateNewAccountInputs, +} from '../src/client/frontend/flow-state.ts' +import { BncsCreateAccountStatus } from '../src/netproto/index.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') + +function readPngDimensions(filePath: string): { readonly width: number; readonly height: number } { + const buf = readFileSync(filePath) + assert.equal(buf[0], 0x89, 'PNG signature byte 0') + assert.equal(buf[1], 0x50, 'PNG signature byte 1') + assert.equal(buf[2], 0x4e, 'PNG signature byte 2') + assert.equal(buf[3], 0x47, 'PNG signature byte 3') + return { + width: buf.readUInt32BE(16), + height: buf.readUInt32BE(20), + } +} + +describe('Battle.net Create New Account Frontend Assets & Flow State (R1, R2, R4)', () => { + const manifest = JSON.parse(readFileSync(MANIFEST_PATH, 'utf8')) as { + readonly strings: Record + } + const strings = manifest.strings + + it('R1: packs terms-of-use-bg.png (593x451), scrollbar.png (72x14), btn-popup-small.png (192x32), and canonical .tbl strings', () => { + const touBgPath = resolve(FRONTEND_DIR, 'terms-of-use-bg.png') + const scrollbarPath = resolve(FRONTEND_DIR, 'scrollbar.png') + const btnPopupPath = resolve(FRONTEND_DIR, 'btn-popup-small.png') + + assert.equal(existsSync(touBgPath), true, 'terms-of-use-bg.png should exist') + assert.equal(existsSync(scrollbarPath), true, 'scrollbar.png should exist') + assert.equal(existsSync(btnPopupPath), true, 'btn-popup-small.png should exist') + + const touPng = readPngDimensions(touBgPath) + assert.equal(touPng.width, 593) + assert.equal(touPng.height, 451) + + const sbPng = readPngDimensions(scrollbarPath) + assert.equal(sbPng.width, 72) + assert.equal(sbPng.height, 14) + + const btnPng = readPngDimensions(btnPopupPath) + assert.equal(btnPng.width, 192) + assert.equal(btnPng.height, 32) + + assert.equal(strings['5180'], 'Terms of Use') + assert.equal(strings['5181'], 'AGREE') + assert.equal(strings['5182'], 'DISAGREE') + assert.equal(strings['5216'], 'Account name must be at least 2 characters.') + assert.equal(strings['5217'], "Account name can't be more than 15 characters.") + assert.equal(strings['5218'], "Passwords don't match.") + assert.equal(strings['5219'], 'Password must be at least 2 characters.') + assert.equal(strings['5220'], "Password can't be more than 15 characters.") + assert.equal(strings['5221'], 'CREATE NEW ACCOUNT') + assert.equal(strings['5222'], 'Create Battle.net Account') + assert.equal( + strings['5223'], + 'Enter the Name and Password for your new battle.net account.', + ) + assert.equal(strings['5224'], 'Account Name') + assert.equal(strings['5225'], 'Password') + assert.equal(strings['5226'], 'Verify Password') + assert.equal(strings['10834'], 'Verify Password') + assert.equal(strings['5227'], 'PLEASE READ') + assert.equal(strings['11127'], 'PLEASE WRITE DOWN YOUR PASSWORD.') + assert.equal(strings['5249'], 'Unable to Create Account') + assert.equal( + typeof strings.termsOfUseText === 'string' && strings.termsOfUseText.length > 500, + true, + 'termsOfUseText from license.txt must be non-empty', + ) + }) + + it('R2: transitions bnet_login -> bnet_terms_of_use, supports scrolling, DISAGREE -> bnet_login, and AGREE -> bnet_create_account', () => { + const state = createInitialFlowState() + state.stage = 'bnet_login' + state.accountName = 'HeroAcct' + state.accountPassword = 'oldpassword' + state.accountVerifyPassword = 'oldpassword' + + openTermsOfUse(state) + assert.equal(state.stage, 'bnet_terms_of_use') + assert.equal(state.termsScrollLine, 0) + assert.equal(state.popupModal, null) + assert.equal(state.activeInput, null) + + scrollTermsOfUse(state, 5, 20) + assert.equal(state.termsScrollLine, 5) + assert.equal(state.termsScrollOffset, 5) + + scrollTermsOfUse(state, 50, 20) + assert.equal(state.termsScrollLine, 20) + + scrollTermsOfUse(state, -100, 20) + assert.equal(state.termsScrollLine, 0) + + disagreeTermsOfUse(state) + assert.equal(state.stage, 'bnet_login') + assert.equal(state.activeInput, 'accountPassword') + + openTermsOfUse(state) + agreeTermsOfUse(state) + assert.equal(state.stage, 'bnet_create_account') + assert.equal(state.activeInput, 'accountName') + assert.equal(state.accountPassword, '') + assert.equal(state.accountVerifyPassword, '') + assert.equal(state.accountName, 'HeroAcct') + }) + + it('R2: cycles focus across the 3 fields with Tab / Shift+Tab and filters characters per 0x6fa52630', () => { + const state = createInitialFlowState() + openTermsOfUse(state) + agreeTermsOfUse(state) + state.accountName = '' + + assert.equal(state.activeInput, 'accountName') + cycleCreateAccountFocus(state) + assert.equal(state.activeInput, 'accountPassword') + cycleCreateAccountFocus(state) + assert.equal(state.activeInput, 'accountVerifyPassword') + cycleCreateAccountFocus(state) + assert.equal(state.activeInput, 'accountName') + + cycleCreateAccountFocus(state, true) + assert.equal(state.activeInput, 'accountVerifyPassword') + cycleCreateAccountFocus(state, true) + assert.equal(state.activeInput, 'accountPassword') + cycleCreateAccountFocus(state, true) + assert.equal(state.activeInput, 'accountName') + + // Verify 0x6fa52630 character filter (rejects <= 0x20, >= 0x7f, and " # % * , / < > ? \) + for (const allowed of ['A', 'z', '0', '9', '-', '_', '.', '[', ']', ';', '(', ')']) { + assert.equal(isValidAccountChar(allowed), true, `Expected '${allowed}' to be valid`) + } + for (const forbidden of [ + ' ', + '\t', + '\n', + '"', + '#', + '%', + '*', + ',', + '/', + '<', + '>', + '?', + '\\', + '\x7f', + 'é', + ]) { + assert.equal(isValidAccountChar(forbidden), false, `Expected '${forbidden}' to be rejected`) + } + + assert.equal(filterAccountName('Hero_1#bad/name*234567890'), 'Hero_1badname23') + + // Typing into accountName ignores invalid chars and caps at 15 chars + for (const ch of 'My_Hero#*.[1]23456789') { + typeCreateAccountChar(state, ch) + } + assert.equal(state.accountName, 'My_Hero.[1]2345') + assert.equal(state.accountName.length, 15) + + backspaceCreateAccountChar(state) + assert.equal(state.accountName, 'My_Hero.[1]234') + }) + + it('R2: enforces all 5 client-side validation error popups (5216..5220) in exact 0x6fa59880 priority order and dismisses modal', () => { + const state = createInitialFlowState() + openTermsOfUse(state) + agreeTermsOfUse(state) + + // 1. Account name < 2 chars -> 5216 + state.accountName = 'a' + state.accountPassword = 'secret' + state.accountVerifyPassword = 'secret' + assert.equal(submitCreateAccountForm(state, strings), false) + assert.equal(state.stage, 'bnet_create_account') + assert.equal(state.popupModal?.message, 'Account name must be at least 2 characters.') + + dismissPopupModal(state) + assert.equal(Boolean(state.popupModal), false) + + // 2. Account name > 15 chars -> 5217 + state.accountName = '1234567890123456' + assert.equal(submitCreateAccountForm(state, strings), false) + assert.equal(state.popupModal?.message, "Account name can't be more than 15 characters.") + dismissPopupModal(state) + assert.equal(Boolean(state.popupModal), false) + + // 3. Passwords don't match (case-insensitive _stricmp) -> 5218 + // Note: checked BEFORE password length < 2 per 0x6fa59880! + state.accountName = 'ValidHero' + state.accountPassword = 'a' + state.accountVerifyPassword = 'b' + assert.equal(submitCreateAccountForm(state, strings), false) + assert.equal(state.popupModal?.message, "Passwords don't match.") + dismissPopupModal(state) + assert.equal(Boolean(state.popupModal), false) + + // 4. Password < 2 chars (when matching) -> 5219 + state.accountPassword = 'a' + state.accountVerifyPassword = 'A' // matches case-insensitively! + assert.equal(submitCreateAccountForm(state, strings), false) + assert.equal(state.popupModal?.message, 'Password must be at least 2 characters.') + dismissPopupModal(state) + assert.equal(Boolean(state.popupModal), false) + + // 5. Password > 15 chars (when matching) -> 5220 + state.accountPassword = '1234567890123456' + state.accountVerifyPassword = '1234567890123456' + const check5220 = validateNewAccountInputs(state, strings) + assert.equal(check5220.valid, false) + if (!check5220.valid) { + assert.equal(check5220.tblId, 5220) + assert.equal(check5220.message, "Password can't be more than 15 characters.") + } + assert.equal(submitCreateAccountForm(state, strings), false) + assert.equal(state.popupModal?.message, "Password can't be more than 15 characters.") + dismissPopupModal(state) + assert.equal(Boolean(state.popupModal), false) + + // 6. Valid inputs (including case-insensitive password match) -> advances to bnet_please_read + state.accountName = 'ValidHero' + state.accountPassword = 'Secret123' + state.accountVerifyPassword = 'secret123' + assert.equal(submitCreateAccountForm(state, strings), true) + assert.equal(state.stage, 'bnet_please_read') + assert.equal(Boolean(state.popupModal), false) + + // Cancel on PLEASE READ returns to bnet_login + cancelPleaseRead(state) + assert.equal(state.stage, 'bnet_login') + + // Exit on Create Account also returns to bnet_login + openTermsOfUse(state) + agreeTermsOfUse(state) + cancelCreateAccount(state) + assert.equal(state.stage, 'bnet_login') + }) + + it('R4: maps all server-side BncsCreateAccountStatus codes (0x02..0x08 + default) to 5231..5239 with title 5249 and returns to bnet_create_account', () => { + const cases: Array<{ + readonly status: number + readonly expectedStrId: number + readonly expectedSubstr: string + }> = [ + { + status: BncsCreateAccountStatus.INVALID_CHARS, // 0x02 + expectedStrId: 5232, + expectedSubstr: 'illegal characters', + }, + { + status: BncsCreateAccountStatus.BANNED_WORD, // 0x03 + expectedStrId: 5233, + expectedSubstr: 'disallowed words', + }, + { + status: BncsCreateAccountStatus.ALREADY_EXISTS, // 0x04 + expectedStrId: 5239, + expectedSubstr: 'already exists', + }, + { + status: BncsCreateAccountStatus.STILL_PROCESSING, // 0x05 + expectedStrId: 5234, + expectedSubstr: 'still being processed', + }, + { + status: BncsCreateAccountStatus.NOT_ENOUGH_ALPHANUM, // 0x06 + expectedStrId: 5235, + expectedSubstr: 'alpha-numeric characters', + }, + { + status: BncsCreateAccountStatus.ADJACENT_PUNCT, // 0x07 + expectedStrId: 5236, + expectedSubstr: 'adjacent punctuation', + }, + { + status: BncsCreateAccountStatus.TOO_MANY_PUNCT, // 0x08 + expectedStrId: 5237, + expectedSubstr: 'too many punctuation', + }, + { + status: BncsCreateAccountStatus.TOO_SHORT, // 0x01 (default branch in 0x6fa58e40) + expectedStrId: 5231, + expectedSubstr: 'at least two characters', + }, + { + status: 0x99, // unknown non-zero status -> default branch 5231 + expectedStrId: 5231, + expectedSubstr: 'at least two characters', + }, + ] + + for (const c of cases) { + const state = createInitialFlowState() + state.stage = 'bnet_please_read' + state.accountName = 'TakenHero' + state.accountPassword = 'secret' + state.accountVerifyPassword = 'secret' + + const mapped = mapCreateAccountServerStatus(c.status, strings) + assert.equal(mapped.tblId, c.expectedStrId) + assert.equal(mapped.title, 'Unable to Create Account') + assert.equal( + mapped.message.includes(c.expectedSubstr), + true, + `Status 0x${c.status.toString(16)} message "${mapped.message}" should include "${c.expectedSubstr}"`, + ) + + applyCreateAccountServerError(state, c.status, strings) + assert.equal(state.stage, 'bnet_create_account') + assert.equal(state.accountName, 'TakenHero', 'accountName must be preserved on server error') + assert.equal(state.popupModal?.title, 'Unable to Create Account') + assert.equal(state.popupModal?.message, mapped.message) + } + }) + + it('R4: applyOnlineCharactersToFlowState with stayOnCharSelect keeps empty new account on char_select', () => { + const state = createInitialFlowState() + state.stage = 'bnet_please_read' + applyOnlineCharactersToFlowState(state, [], { stayOnCharSelect: true }) + assert.equal(state.stage, 'char_select') + assert.equal(state.roster.length, 0) + }) +}) diff --git a/tests/netproto-bncs-packets.test.ts b/tests/netproto-bncs-packets.test.ts new file mode 100644 index 0000000..77b3c16 --- /dev/null +++ b/tests/netproto-bncs-packets.test.ts @@ -0,0 +1,89 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' +import { + BncsCreateAccountError, + BncsCreateAccountStatus, + BncsFramer, + BncsOpcode, + ByteWriter, + decodeBncsS2cPacket, + encodeBncsCreateAccount2, +} from '../src/netproto/index.ts' +import { encodeBncsFrame } from '../src/netproto/bncs/framing.ts' + +describe('BNCS SID_CREATEACCOUNT2 (0x3D) Packet Encoding & Status Decoding', () => { + it('defines canonical BncsCreateAccountStatus constants (0x00..0x08)', () => { + assert.equal(BncsCreateAccountStatus.OK, 0x00) + assert.equal(BncsCreateAccountStatus.TOO_SHORT, 0x01) + assert.equal(BncsCreateAccountStatus.INVALID_CHARS, 0x02) + assert.equal(BncsCreateAccountStatus.BANNED_WORD, 0x03) + assert.equal(BncsCreateAccountStatus.ALREADY_EXISTS, 0x04) + assert.equal(BncsCreateAccountStatus.STILL_PROCESSING, 0x05) + assert.equal(BncsCreateAccountStatus.NOT_ENOUGH_ALPHANUM, 0x06) + assert.equal(BncsCreateAccountStatus.ADJACENT_PUNCT, 0x07) + assert.equal(BncsCreateAccountStatus.TOO_MANY_PUNCT, 0x08) + }) + + it('encodes C->S SID_CREATEACCOUNT2 (0x3D) with 20-byte XSHA-1 hash + NUL-terminated username', () => { + const hash20 = new Uint8Array(20) + for (let i = 0; i < 20; i++) hash20[i] = (i + 1) & 0xff + const framed = encodeBncsCreateAccount2('Deckard_Cain', hash20) + + assert.equal(framed[0], 0xff) + assert.equal(framed[1], BncsOpcode.SID_CREATEACCOUNT2) + const totalLen = framed[2]! | (framed[3]! << 8) + assert.equal(totalLen, 4 + 20 + 'Deckard_Cain'.length + 1) + assert.deepEqual(framed.subarray(4, 24), hash20) + const nameBytes = framed.subarray(24, totalLen - 1) + assert.equal(new TextDecoder().decode(nameBytes), 'Deckard_Cain') + assert.equal(framed[totalLen - 1], 0x00) + }) + + it('decodes S->C SID_CREATEACCOUNT2 (0x3D) across all status codes and optional name suggestions', () => { + const statuses = [ + BncsCreateAccountStatus.OK, + BncsCreateAccountStatus.TOO_SHORT, + BncsCreateAccountStatus.INVALID_CHARS, + BncsCreateAccountStatus.BANNED_WORD, + BncsCreateAccountStatus.ALREADY_EXISTS, + BncsCreateAccountStatus.STILL_PROCESSING, + BncsCreateAccountStatus.NOT_ENOUGH_ALPHANUM, + BncsCreateAccountStatus.ADJACENT_PUNCT, + BncsCreateAccountStatus.TOO_MANY_PUNCT, + ] + + for (const status of statuses) { + const suggestion = status === BncsCreateAccountStatus.ALREADY_EXISTS ? 'Deckard_Cain2' : '' + const w = new ByteWriter() + w.u32LE(status) + w.cstring(suggestion) + const frame = encodeBncsFrame(BncsOpcode.SID_CREATEACCOUNT2, w.toUint8Array()) + + const framer = new BncsFramer() + const rawPackets = framer.push(frame) + assert.equal(rawPackets.length, 1) + + const decoded = decodeBncsS2cPacket(rawPackets[0]!) + assert.equal(decoded.type, 'SID_CREATEACCOUNT2') + if (decoded.type === 'SID_CREATEACCOUNT2') { + assert.equal(decoded.data.status, status) + assert.equal(decoded.data.suggestion, suggestion) + assert.equal(decoded.data.nameSuggestion, suggestion) + } + } + }) + + it('constructs BncsCreateAccountError with status, suggestion, and nameSuggestion', () => { + const err = new BncsCreateAccountError( + BncsCreateAccountStatus.ALREADY_EXISTS, + 'Tyrael_2', + 'Tyrael', + ) + assert.equal(err.name, 'BncsCreateAccountError') + assert.equal(err.status, BncsCreateAccountStatus.ALREADY_EXISTS) + assert.equal(err.suggestion, 'Tyrael_2') + assert.equal(err.nameSuggestion, 'Tyrael_2') + assert.equal(err.conn, 'bncs') + assert.equal(err.packetId, 0x3d) + }) +}) diff --git a/tests/netproto-online-flow.test.ts b/tests/netproto-online-flow.test.ts new file mode 100644 index 0000000..44c47bf --- /dev/null +++ b/tests/netproto-online-flow.test.ts @@ -0,0 +1,85 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' +import { + BncsCreateAccountError, + BncsCreateAccountStatus, + createD2OnlineFlow, +} from '../src/netproto/index.ts' +import { createFakeRealm } from './netproto/fake-realm-servers.ts' + +describe('D2OnlineFlow SID_CREATEACCOUNT2 (0x3D) Account Creation & Error Propagation (R4)', () => { + it('succeeds on createAccount and loginOrRegister({ createNewAccount: true }) when server returns status 0x00', async () => { + const realm = createFakeRealm({ + characters: 0, + createAccountStatus: BncsCreateAccountStatus.OK, + }) + const flow = createD2OnlineFlow({ + resolver: realm.resolver, + bnetHost: '127.0.0.1', + bnetPort: 6112, + }) + + await flow.createAccount('NewHero', 'SecretPass') + await flow.login('NewHero', 'SecretPass') + const realms = await flow.listRealms() + assert.equal(realms.length, 1) + const chars = await flow.enterRealm(realms[0]!.title) + assert.equal(chars.length, 0) + await flow.close() + }) + + it('throws BncsCreateAccountError preserving exact numeric status (0x02..0x08) when SID_CREATEACCOUNT2 fails', async () => { + const errorStatuses = [ + BncsCreateAccountStatus.TOO_SHORT, + BncsCreateAccountStatus.INVALID_CHARS, + BncsCreateAccountStatus.BANNED_WORD, + BncsCreateAccountStatus.ALREADY_EXISTS, + BncsCreateAccountStatus.STILL_PROCESSING, + BncsCreateAccountStatus.NOT_ENOUGH_ALPHANUM, + BncsCreateAccountStatus.ADJACENT_PUNCT, + BncsCreateAccountStatus.TOO_MANY_PUNCT, + ] + + for (const status of errorStatuses) { + const realm = createFakeRealm({ + characters: 0, + createAccountStatus: status, + createAccountSuggestion: 'SuggestedAcct', + }) + const flow = createD2OnlineFlow({ + resolver: realm.resolver, + bnetHost: '127.0.0.1', + bnetPort: 6112, + }) + + await assert.rejects( + async () => { + await flow.createAccount('TestAcct', 'Pass123') + }, + (err: unknown) => { + assert.equal(err instanceof BncsCreateAccountError, true) + if (err instanceof BncsCreateAccountError) { + assert.equal(err.status, status) + assert.equal(err.suggestion, 'SuggestedAcct') + } + return true + }, + ) + + await assert.rejects( + async () => { + await flow.loginOrRegister('TestAcct', 'Pass123', { createNewAccount: true }) + }, + (err: unknown) => { + assert.equal(err instanceof BncsCreateAccountError, true) + if (err instanceof BncsCreateAccountError) { + assert.equal(err.status, status) + } + return true + }, + ) + + await flow.close() + } + }) +}) diff --git a/tests/netproto/fake-realm-servers.ts b/tests/netproto/fake-realm-servers.ts index a283484..ef6c54e 100644 --- a/tests/netproto/fake-realm-servers.ts +++ b/tests/netproto/fake-realm-servers.ts @@ -37,6 +37,10 @@ export interface FakeRealmOptions { readonly silentMcp?: readonly number[] | undefined /** Characters in MCP_CHARLIST2 (0 = empty roster, i.e. the char_create screen). */ readonly characters?: number | undefined + /** Status code returned for SID_CREATEACCOUNT2 (default 0x00 = OK). */ + readonly createAccountStatus?: number | undefined + /** Suggestion string returned for SID_CREATEACCOUNT2 (default ''). */ + readonly createAccountSuggestion?: string | undefined } export interface FakeRealm { @@ -70,7 +74,13 @@ function charList(count: number): Uint8Array { return w.toUint8Array() } -function wireBncs(server: MemoryStream, silent: ReadonlySet, onNull: () => void): void { +function wireBncs( + server: MemoryStream, + silent: ReadonlySet, + onNull: () => void, + createAccountStatus = 0, + createAccountSuggestion = '', +): void { const framer = new BncsFramer() let initSeen = false server.onData((chunk) => { @@ -99,6 +109,11 @@ function wireBncs(server: MemoryStream, silent: ReadonlySet, onNull: () server.write(encodeBncsFrame(BncsOpcode.SID_AUTH_INFO, w.toUint8Array())) } else if (pkt.id === BncsOpcode.SID_AUTH_CHECK) { server.write(encodeBncsFrame(BncsOpcode.SID_AUTH_CHECK, new Uint8Array([0, 0, 0, 0, 0]))) + } else if (pkt.id === BncsOpcode.SID_CREATEACCOUNT2) { + const w = new ByteWriter() + w.u32LE(createAccountStatus) + w.cstring(createAccountSuggestion) + server.write(encodeBncsFrame(BncsOpcode.SID_CREATEACCOUNT2, w.toUint8Array())) } else if (pkt.id === BncsOpcode.SID_LOGONRESPONSE2) { server.write(encodeBncsFrame(BncsOpcode.SID_LOGONRESPONSE2, new Uint8Array([0, 0, 0, 0]))) } else if (pkt.id === BncsOpcode.SID_QUERYREALMS2) { @@ -195,9 +210,15 @@ export function createFakeRealm(options: FakeRealmOptions = {}): FakeRealm { const [client, server] = createMemoryStreamPair(role) connections.push({ role, client, server }) if (role === 'bnet') { - wireBncs(server, silentBncs, () => { - nullCount++ - }) + wireBncs( + server, + silentBncs, + () => { + nullCount++ + }, + options.createAccountStatus ?? 0, + options.createAccountSuggestion ?? '', + ) } else if (role === 'realm') wireMcp(server, silentMcp, characters) else { wireGame(server)