From 6a010670cd3394a75ff8f3505ced3382314329c4 Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Fri, 2 Oct 2026 06:26:35 +0000 Subject: [PATCH] feat(frontend,netproto): implement 1.13c Battle.net Create New Account flow --- public/ui/frontend/btn-popup-small.png | Bin 0 -> 5599 bytes public/ui/frontend/manifest.json | 62 ++- public/ui/frontend/scrollbar.png | Bin 0 -> 1406 bytes public/ui/frontend/terms-of-use-bg.png | Bin 0 -> 90717 bytes src/baker/packers/pack-frontend-ui.ts | 201 ++++++- src/client/frontend/flow-state.ts | 491 ++++++++++++++++- src/client/frontend/frontend-renderer.ts | 571 ++++++++++++++++++- src/client/main.ts | 42 +- src/client/session/online-session.ts | 25 +- src/netproto/bncs/packets.ts | 15 +- src/netproto/core/errors.ts | 19 + src/netproto/flow/online-flow.ts | 36 +- src/netproto/index.ts | 17 +- tests/e2e-bnet-create-account.test.ts | 675 +++++++++++++++++++++++ tests/frontend-flow.test.ts | 350 ++++++++++++ tests/netproto-bncs-packets.test.ts | 89 +++ tests/netproto-online-flow.test.ts | 85 +++ tests/netproto/fake-realm-servers.ts | 29 +- 18 files changed, 2662 insertions(+), 45 deletions(-) create mode 100644 public/ui/frontend/btn-popup-small.png create mode 100644 public/ui/frontend/scrollbar.png create mode 100644 public/ui/frontend/terms-of-use-bg.png create mode 100644 tests/e2e-bnet-create-account.test.ts create mode 100644 tests/frontend-flow.test.ts create mode 100644 tests/netproto-bncs-packets.test.ts create mode 100644 tests/netproto-online-flow.test.ts diff --git a/public/ui/frontend/btn-popup-small.png b/public/ui/frontend/btn-popup-small.png new file mode 100644 index 0000000000000000000000000000000000000000..cd443c5f860dc778ee56d65025498f5405e0fa43 GIT binary patch literal 5599 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..deafbfc3751c970f347c204f486168b98a398861 GIT binary patch literal 1406 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 z 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 M07*qoM6N<$f^Jfk1ONa4 literal 0 HcmV?d00001 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)