feat(frontend,netproto): implement 1.13c Battle.net Create New Account flow

This commit is contained in:
troytt 2026-10-02 06:26:35 +00:00
parent 26e4265e5b
commit 6a010670cd
18 changed files with 2662 additions and 45 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

View File

@ -1,6 +1,6 @@
{ {
"version": "1.13c", "version": "1.13c",
"decodedDc6Count": 92, "decodedDc6Count": 95,
"animStrips": { "animStrips": {
"logoMainBlack": { "logoMainBlack": {
"file": "logo-main-black.png", "file": "logo-main-black.png",
@ -13551,6 +13551,39 @@
9 9
], ],
"strings": { "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", "singlePlayer": "SINGLE PLAYER",
"battleNet": "BATTLE.NET", "battleNet": "BATTLE.NET",
"otherMultiplayer": "OTHER MULTIPLAYER", "otherMultiplayer": "OTHER MULTIPLAYER",
@ -13563,12 +13596,39 @@
"accessingAccount": "Accessing your account...", "accessingAccount": "Accessing your account...",
"accountName": "Account Name", "accountName": "Account Name",
"password": "Password", "password": "Password",
"verifyPassword": "Verify Password",
"logIn": "LOG IN", "logIn": "LOG IN",
"accountSettings": "ACCOUNT SETTINGS", "accountSettings": "ACCOUNT SETTINGS",
"createNewAccount": "CREATE NEW ACCOUNT", "createNewAccount": "CREATE NEW ACCOUNT",
"createBnetAccountTitle": "Create Battle.net Account",
"exit": "EXIT", "exit": "EXIT",
"ok": "OK", "ok": "OK",
"cancel": "CANCEL", "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", "chooseClassTitle": "Choose a class for your campaign",
"characterName": "Character Name", "characterName": "Character Name",
"expansionCharacter": "EXPANSION CHARACTER", "expansionCharacter": "EXPANSION CHARACTER",

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

View File

@ -443,6 +443,32 @@ async function main(): Promise<void> {
], false) ], false)
savePng('popup-340x224.png', 340, 224, popupPixels, skyPl2.rgb, 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 createGameBgDc6 = await decodeUiDc6('data\\global\\ui\\BIGMENU\\creategamebckg.dc6')
const cgf = createGameBgDc6.groups[0]!.frames const cgf = createGameBgDc6.groups[0]!.frames
const createGameBgPixels = stitchFrames(373, 373, [ const createGameBgPixels = stitchFrames(373, 373, [
@ -544,6 +570,15 @@ async function main(): Promise<void> {
const btnCancelStrip = stitchButtonStates(btnCancelDc6, 1) // 96x32 x 2 states const btnCancelStrip = stitchButtonStates(btnCancelDc6, 1) // 96x32 x 2 states
savePng('btn-create-cancel.png', btnCancelStrip.width, btnCancelStrip.height, btnCancelStrip.pixels, act1Pl2.rgb, true) 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 btnCreateGameDc6 = await decodeUiDc6('data\\global\\ui\\BIGMENU\\creategamebutton.dc6')
const btnCreateGameStrip = stitchButtonStates(btnCreateGameDc6, 1) // 144x36 x 2 states const btnCreateGameStrip = stitchButtonStates(btnCreateGameDc6, 1) // 144x36 x 2 states
savePng('btn-create-game.png', btnCreateGameStrip.width, btnCreateGameStrip.height, btnCreateGameStrip.pixels, act1Pl2.rgb, true) savePng('btn-create-game.png', btnCreateGameStrip.width, btnCreateGameStrip.height, btnCreateGameStrip.pixels, act1Pl2.rgb, true)
@ -841,6 +876,100 @@ async function main(): Promise<void> {
return undefined 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 = { const strings = {
singlePlayer: lookupByKey('strSinglePlayer') ?? 'SINGLE PLAYER', singlePlayer: lookupByKey('strSinglePlayer') ?? 'SINGLE PLAYER',
battleNet: lookupByKey('strBattleNet') ?? 'BATTLE.NET', battleNet: lookupByKey('strBattleNet') ?? 'BATTLE.NET',
@ -852,14 +981,74 @@ async function main(): Promise<void> {
connectingToFastest: 'Connecting to the fastest server...', connectingToFastest: 'Connecting to the fastest server...',
checkingVersions: 'Checking versions...', checkingVersions: 'Checking versions...',
accessingAccount: 'Accessing your account...', accessingAccount: 'Accessing your account...',
accountName: 'Account Name', accountName: s5224,
password: 'Password', password: s5225,
verifyPassword: s10834 || s5226,
logIn: 'LOG IN', logIn: 'LOG IN',
accountSettings: 'ACCOUNT SETTINGS', accountSettings: 'ACCOUNT SETTINGS',
createNewAccount: 'CREATE NEW ACCOUNT', createNewAccount: s5221,
exit: 'EXIT', createBnetAccountTitle: s5222,
ok: 'OK', exit: s5101,
cancel: 'CANCEL', 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', chooseClassTitle: 'Choose a class for your campaign',
characterName: 'Character Name', characterName: 'Character Name',
expansionCharacter: 'EXPANSION CHARACTER', expansionCharacter: 'EXPANSION CHARACTER',

View File

@ -15,12 +15,17 @@ export type BnetFlowStage =
| 'main_menu' | 'main_menu'
| 'bnet_connecting' | 'bnet_connecting'
| 'bnet_login' | 'bnet_login'
| 'bnet_terms_of_use'
| 'bnet_create_account'
| 'bnet_please_read'
| 'char_create' | 'char_create'
| 'char_select' | 'char_select'
| 'bnet_lobby' | 'bnet_lobby'
| 'door_loading' | 'door_loading'
| 'in_game' | 'in_game'
export type FrontendScreen = BnetFlowStage
/** Campfire class IDs in left-to-right screen order (`D2Launch.dll` @ `0x6fa68df8`). */ /** Campfire class IDs in left-to-right screen order (`D2Launch.dll` @ `0x6fa68df8`). */
export type CampfireClassId = 'am' | 'as' | 'ne' | 'ba' | 'pa' | 'so' | 'dz' export type CampfireClassId = 'am' | 'as' | 'ne' | 'ba' | 'pa' | 'so' | 'dz'
@ -90,6 +95,10 @@ export interface RosterCharacter {
export type ActiveInputField = export type ActiveInputField =
| 'accountName' | 'accountName'
| 'accountPassword' | 'accountPassword'
| 'accountVerifyPassword'
| 'bnet_account'
| 'bnet_password'
| 'bnet_verify_password'
| 'charName' | 'charName'
| 'gameName' | 'gameName'
| 'gamePassword' | 'gamePassword'
@ -99,6 +108,17 @@ export type ActiveInputField =
| 'chatInput' | 'chatInput'
| null | 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 { export interface LobbyGameEntry {
readonly name: string readonly name: string
readonly description: string readonly description: string
@ -121,10 +141,15 @@ export interface BnetFlowState {
/** Whether live D2OnlineFlow is active (falls back to mock when false). */ /** Whether live D2OnlineFlow is active (falls back to mock when false). */
onlineConnected?: boolean | undefined onlineConnected?: boolean | undefined
// Stage 2: Battle.net Login // Stage 2: Battle.net Login & Create New Account
accountName: string accountName: string
accountPassword: string accountPassword: string
accountVerifyPassword: string
loginError: string | null loginError: string | null
termsScrollLine: number
termsScrollOffset: number
popupModal: PopupModalState | null
popup: PopupModalState | null
// Stage 3: Campfire Character Creation // Stage 3: Campfire Character Creation
campfireClasses: Record<CampfireClassId, CampfireClassSlotState> campfireClasses: Record<CampfireClassId, CampfireClassSlotState>
@ -175,6 +200,8 @@ export interface BnetFlowState {
activeInput: ActiveInputField activeInput: ActiveInputField
} }
export type FrontendFlowState = BnetFlowState
export const CAMPFIRE_CLASS_ORDER: readonly CampfireClassId[] = [ export const CAMPFIRE_CLASS_ORDER: readonly CampfireClassId[] = [
'am', 'am',
'as', 'as',
@ -373,7 +400,12 @@ export function createInitialFlowState(initialRoster: RosterCharacter[] = []): B
// No prefilled credentials: the user (or a configured bot) must type them. // No prefilled credentials: the user (or a configured bot) must type them.
accountName: '', accountName: '',
accountPassword: '', accountPassword: '',
accountVerifyPassword: '',
loginError: null, loginError: null,
termsScrollLine: 0,
termsScrollOffset: 0,
popupModal: null,
popup: null,
campfireClasses, campfireClasses,
hoveredClass: null, 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<string, unknown> | 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<BnetFlowState, 'accountName' | 'accountPassword' | 'accountVerifyPassword'>,
strings?: Record<string, unknown>,
): 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<string, unknown>,
): 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<string, unknown>,
): {
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<string, unknown>,
): 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, * 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). * cannot start or end with hyphen/underscore or contain consecutive hyphens).
@ -943,11 +1425,14 @@ export function applyOnlineCharactersToFlowState(
readonly progression?: number | undefined readonly progression?: number | undefined
readonly difficulty?: 0 | 1 | 2 | undefined readonly difficulty?: 0 | 1 | 2 | undefined
}[], }[],
options?: { readonly stayOnCharSelect?: boolean | undefined },
): void { ): void {
state.onlineConnected = true state.onlineConnected = true
state.popupModal = null
state.popup = null
state.roster = characters.slice(0, 8).map(charSummaryToRosterCharacter) state.roster = characters.slice(0, 8).map(charSummaryToRosterCharacter)
state.selectedSlotIndex = 0 state.selectedSlotIndex = 0
if (state.roster.length === 0) { if (state.roster.length === 0 && !options?.stayOnCharSelect) {
enterCharCreate(state) enterCharCreate(state)
} else { } else {
state.stage = 'char_select' state.stage = 'char_select'
@ -970,6 +1455,8 @@ export function returnToLoginAfterSessionLoss(state: BnetFlowState, reason: stri
state.tickAccumulatorMs = 0 state.tickAccumulatorMs = 0
state.lastFrameTimeMs = null state.lastFrameTimeMs = null
state.onlineConnected = false state.onlineConnected = false
state.popupModal = null
state.popup = null
state.loginError = reason state.loginError = reason
state.lastError = reason state.lastError = reason
state.statusText = '' state.statusText = ''

View File

@ -16,20 +16,32 @@ import type { FontAtlasMeta } from '../ui/font.ts'
import { import {
CAMPFIRE_CLASS_ORDER, CAMPFIRE_CLASS_ORDER,
HERO_TOKENS, HERO_TOKENS,
agreeTermsOfUse,
backspaceCreateAccountChar,
canSubmitCreateGame, canSubmitCreateGame,
cancelConnecting, cancelConnecting,
cancelCreateAccount,
cancelPleaseRead,
clickBattleNetButton, clickBattleNetButton,
confirmCharacterSelect, confirmCharacterSelect,
cycleCreateAccountFocus,
disagreeTermsOfUse,
dismissPopupModal,
enterCharCreate, enterCharCreate,
getAnimImageLoopFrame, getAnimImageLoopFrame,
hoverCampfireClass, hoverCampfireClass,
isDifficultyUnlocked, isDifficultyUnlocked,
isValidAccountChar,
openTermsOfUse,
resetHeroAvatarAnims, resetHeroAvatarAnims,
scrollTermsOfUse,
selectCampfireClass, selectCampfireClass,
submitBattleNetLogin, submitBattleNetLogin,
submitCharacterCreate, submitCharacterCreate,
submitCreateAccountForm,
submitCreateGame, submitCreateGame,
toggleChatGem, toggleChatGem,
typeCreateAccountChar,
type ActiveInputField, type ActiveInputField,
type BnetFlowState, type BnetFlowState,
type CampfireClassId, type CampfireClassId,
@ -75,9 +87,28 @@ export interface FrontendManifest {
readonly accessingAccount: string readonly accessingAccount: string
readonly accountName: string readonly accountName: string
readonly password: string readonly password: string
readonly verifyPassword?: string
readonly logIn: string readonly logIn: string
readonly accountSettings: string readonly accountSettings: string
readonly createNewAccount: 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 exit: string
readonly ok: string readonly ok: string
readonly cancel: string readonly cancel: string
@ -162,6 +193,9 @@ const STATIC_UI_IMAGES = [
'bg-waiting-room.png', 'bg-waiting-room.png',
'popup-340x224.png', 'popup-340x224.png',
'popup-okcancel.png', 'popup-okcancel.png',
'terms-of-use-bg.png',
'scrollbar.png',
'btn-popup-small.png',
'create-game-bg.png', 'create-game-bg.png',
'charselectbox.png', 'charselectbox.png',
'realmselect.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[] { getActiveButtons(): UiButtonDef[] {
const s = this.manifest?.strings const s = this.manifest?.strings
if (!s) return [] 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) { switch (this.state.stage) {
case 'main_menu': case 'main_menu':
// Exact Expansion Main Menu coordinates from D2Launch.dll (0x6fa61190..0x6fa612d0) // Exact Expansion Main Menu coordinates from D2Launch.dll (0x6fa61190..0x6fa612d0)
@ -772,11 +830,7 @@ export class FrontendRenderer {
sprite: 'btn-wide.png', sprite: 'btn-wide.png',
label: s.createNewAccount, label: s.createNewAccount,
onClick: () => { onClick: () => {
if (this.asyncHandlers?.onLogin) { openTermsOfUse(this.state)
void this.asyncHandlers.onLogin(this.state, true)
} else {
submitBattleNetLogin(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<string, unknown>)
},
},
]
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': case 'char_create':
return [ 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. * Render the active pre-game stage onto the 800x600 canvas.
*/ */
@ -1177,6 +1363,15 @@ export class FrontendRenderer {
case 'bnet_login': case 'bnet_login':
this.renderBnetLogin() this.renderBnetLogin()
break 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': case 'char_create':
this.renderCharCreate() this.renderCharCreate()
break break
@ -1193,6 +1388,10 @@ export class FrontendRenderer {
break 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 // Draw the authentic Diablo II animated hand cursor on top of all front-end screens
if (this.state.stage !== 'in_game') { if (this.state.stage !== 'in_game') {
this.cursor.render(ctx, this.cursorAtlas, nowMs) this.cursor.render(ctx, this.cursorAtlas, nowMs)
@ -1304,6 +1503,238 @@ export class FrontendRenderer {
color: 0, 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()) { for (const btn of this.getActiveButtons()) {
this.drawButton(btn) 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 (this.state.stage === 'bnet_login') {
if (x >= 278 && x <= 521 && y >= 316 && y <= 347) { if (x >= 278 && x <= 521 && y >= 316 && y <= 347) {
this.state.activeInput = 'accountName' this.state.activeInput = 'accountName'
@ -1841,6 +2290,17 @@ export class FrontendRenderer {
return 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') { if (this.state.stage === 'char_create') {
// Check if a campfire class was clicked // Check if a campfire class was clicked
for (const id of CAMPFIRE_CLASS_ORDER) { 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 { handleKeyDown(e: KeyboardEvent): void {
if (this.state.stage === 'in_game' || this.state.stage === 'door_loading') return 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') { if (e.key === 'Escape') {
e.preventDefault() e.preventDefault()
if (this.state.stage === 'bnet_connecting') { if (this.state.stage === 'bnet_connecting') {
@ -1988,6 +2490,12 @@ export class FrontendRenderer {
this.state.stage = 'main_menu' this.state.stage = 'main_menu'
this.state.stageTicks = 0 this.state.stageTicks = 0
this.state.activeInput = null 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') { } else if (this.state.stage === 'char_create') {
if (this.state.roster.length > 0) { if (this.state.roster.length > 0) {
this.state.stage = 'char_select' this.state.stage = 'char_select'
@ -2025,6 +2533,15 @@ export class FrontendRenderer {
} else { } else {
submitBattleNetLogin(this.state) 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<string, unknown>,
)
} else if (this.state.stage === 'bnet_please_read') {
this.confirmPleaseRead()
} else if (this.state.stage === 'char_create') { } else if (this.state.stage === 'char_create') {
if (this.asyncHandlers?.onCreateCharacter) { if (this.asyncHandlers?.onCreateCharacter) {
void this.asyncHandlers.onCreateCharacter(this.state) void this.asyncHandlers.onCreateCharacter(this.state)
@ -2054,6 +2571,8 @@ export class FrontendRenderer {
if (this.state.stage === 'bnet_login') { if (this.state.stage === 'bnet_login') {
this.state.activeInput = this.state.activeInput =
this.state.activeInput === 'accountName' ? 'accountPassword' : 'accountName' 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') { } else if (this.state.stage === 'bnet_lobby') {
const order: ActiveInputField[] = const order: ActiveInputField[] =
this.state.lobbySubPanel === 'join' this.state.lobbySubPanel === 'join'
@ -2072,6 +2591,18 @@ export class FrontendRenderer {
return 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 const field = this.state.activeInput
if (!field) return if (!field) return
@ -2103,12 +2634,21 @@ export class FrontendRenderer {
if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) { if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
const ch = e.key const ch = e.key
const cur = this.getFieldValue(field) 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) { if (/^[A-Za-z0-9_-]$/.test(ch) && cur.length < 15) {
this.setFieldValue(field, cur + ch) this.setFieldValue(field, cur + ch)
} }
} else if (field === 'accountPassword') { } else if (
if (cur.length < 16) { field === 'accountPassword' ||
field === 'accountVerifyPassword' ||
field === 'bnet_password' ||
field === 'bnet_verify_password'
) {
if (cur.length < 15) {
this.setFieldValue(field, cur + ch) this.setFieldValue(field, cur + ch)
} }
} else if (field === 'maxPlayers') { } else if (field === 'maxPlayers') {
@ -2140,9 +2680,14 @@ export class FrontendRenderer {
private getFieldValue(field: Exclude<ActiveInputField, null>): string { private getFieldValue(field: Exclude<ActiveInputField, null>): string {
switch (field) { switch (field) {
case 'accountName': case 'accountName':
case 'bnet_account':
return this.state.accountName return this.state.accountName
case 'accountPassword': case 'accountPassword':
case 'bnet_password':
return this.state.accountPassword return this.state.accountPassword
case 'accountVerifyPassword':
case 'bnet_verify_password':
return this.state.accountVerifyPassword
case 'charName': case 'charName':
return this.state.charName return this.state.charName
case 'gameName': case 'gameName':
@ -2163,11 +2708,17 @@ export class FrontendRenderer {
private setFieldValue(field: Exclude<ActiveInputField, null>, val: string): void { private setFieldValue(field: Exclude<ActiveInputField, null>, val: string): void {
switch (field) { switch (field) {
case 'accountName': case 'accountName':
case 'bnet_account':
this.state.accountName = val this.state.accountName = val
break break
case 'accountPassword': case 'accountPassword':
case 'bnet_password':
this.state.accountPassword = val this.state.accountPassword = val
break break
case 'accountVerifyPassword':
case 'bnet_verify_password':
this.state.accountVerifyPassword = val
break
case 'charName': case 'charName':
this.state.charName = val this.state.charName = val
this.state.charCreateError = null this.state.charCreateError = null

View File

@ -13,6 +13,7 @@
*/ */
import { import {
applyCreateAccountServerError,
applyOnlineCharactersToFlowState, applyOnlineCharactersToFlowState,
createInitialFlowState, createInitialFlowState,
isDifficultyUnlocked, isDifficultyUnlocked,
@ -25,7 +26,7 @@ import { FrontendRenderer } from './frontend/frontend-renderer.ts'
import { LightGrid } from '../common/drlg/light-grid.ts' import { LightGrid } from '../common/drlg/light-grid.ts'
import type { LoadedMonsterArt } from './render/monster-art.ts' import type { LoadedMonsterArt } from './render/monster-art.ts'
import { setLang, type GameLang } from './i18n/lang.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 { SpriteRenderer, type AtlasHandle } from './render/renderer.ts'
import { import {
DEFAULT_BASES, DEFAULT_BASES,
@ -428,6 +429,14 @@ async function bootPlayApp(): Promise<PlayAppController> {
}) })
const flowState = createInitialFlowState() 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) const frontendRenderer = new FrontendRenderer(bnetCanvas, flowState)
let inGameView = false let inGameView = false
@ -490,13 +499,26 @@ async function bootPlayApp(): Promise<PlayAppController> {
const res = createNewAccount const res = createNewAccount
? await session.createAccountAndLogin({ username, password, host: targetHost }) ? await session.createAccountAndLogin({ username, password, host: targetHost })
: await session.login({ 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]) { if (res.realms[0]) {
st.gateway = res.realms[0].title st.gateway = res.realms[0].title
} }
applyOnlineCharactersToFlowState(st, res.characters) applyOnlineCharactersToFlowState(st, res.characters, {
stayOnCharSelect: Boolean(createNewAccount),
})
} catch (err) { } catch (err) {
// A lost session was already routed to the login screen by `onSessionLost`. if (err instanceof BncsCreateAccountError) {
if (!(err instanceof SessionLostError)) { applyCreateAccountServerError(
st,
err.status,
frontendRenderer.manifest?.strings as unknown as Record<string, unknown>,
)
} 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.loginError = err instanceof Error ? err.message : String(err)
st.lastError = st.loginError st.lastError = st.loginError
} }
@ -841,6 +863,18 @@ async function bootPlayApp(): Promise<PlayAppController> {
e.preventDefault() 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 // Capture-phase keydown listener so pre-game text inputs and CommandMapper hotkeys
// do not double-trigger HudManager's bubble-phase listener. // do not double-trigger HudManager's bubble-phase listener.
window.addEventListener( window.addEventListener(

View File

@ -16,6 +16,7 @@
*/ */
import { import {
BncsCreateAccountError,
D2gsS2cFramer, D2gsS2cFramer,
InMemoryPacketTap, InMemoryPacketTap,
createD2OnlineFlow, createD2OnlineFlow,
@ -522,6 +523,18 @@ export class OnlineSession {
return await this.establishSession(params, true) 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( private async establishSession(
params: OnlineSessionLoginParams, params: OnlineSessionLoginParams,
createAccount: boolean, createAccount: boolean,
@ -580,10 +593,16 @@ export class OnlineSession {
// A newer login replaced this attempt; its state belongs to the newer attempt. // A newer login replaced this attempt; its state belongs to the newer attempt.
throw err throw err
} }
// Rejected login (bad password or unknown account): the attempt's connections are closed // Rejected login or account creation: the attempt's connections are closed.
// and the login screen shows the server's answer. // For BncsCreateAccountError, keep disconnectReasonValue null so the in-canvas
// PopUpOkCancel2.dc6 modal displays without a redundant DOM disconnect banner.
this.pendingFlowClose = this.closeFlowQuietly() 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') this.setPhase('disconnected')
throw err throw err
} }

View File

@ -31,6 +31,18 @@ export const BncsOpcode = {
SID_CHATEVENT: 0x0f, SID_CHATEVENT: 0x0f,
} as const } 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_IX86 = 0x49583836 // 'IX86' in LE: '68XI'
export const FOURCC_D2XP = 0x44325850 // 'D2XP' in LE: 'PX2D' export const FOURCC_D2XP = 0x44325850 // 'D2XP' in LE: 'PX2D'
export const FOURCC_D2DV = 0x44324456 // 'D2DV' in LE: 'VD2D' export const FOURCC_D2DV = 0x44324456 // 'D2DV' in LE: 'VD2D'
@ -76,6 +88,7 @@ export interface BncsAuthCheckResponse {
export interface BncsCreateAccountResponse { export interface BncsCreateAccountResponse {
readonly status: number readonly status: number
readonly suggestion: string readonly suggestion: string
readonly nameSuggestion: string
} }
export interface BncsLogonResponse2 { export interface BncsLogonResponse2 {
@ -331,7 +344,7 @@ export function decodeBncsS2cPacket(
const suggestion = r.remaining > 0 ? r.cstring(codec) : '' const suggestion = r.remaining > 0 ? r.cstring(codec) : ''
return { return {
type: 'SID_CREATEACCOUNT2', type: 'SID_CREATEACCOUNT2',
data: { status, suggestion }, data: { status, suggestion, nameSuggestion: suggestion },
} }
} }

View File

@ -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
}
}

View File

@ -12,7 +12,7 @@
import { BncsSession } from '../bncs/session.ts' import { BncsSession } from '../bncs/session.ts'
import { defaultClock } from '../core/clock.ts' import { defaultClock } from '../core/clock.ts'
import { Emitter } from '../core/emitter.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 { resolveTextCodec } from '../core/text-codec.ts'
import { D2gsAdapter } from '../d2gs/adapter.ts' import { D2gsAdapter } from '../d2gs/adapter.ts'
import { D2gsSession } from '../d2gs/session.ts' import { D2gsSession } from '../d2gs/session.ts'
@ -51,7 +51,10 @@ export interface D2OnlineFlow {
loginOrRegister( loginOrRegister(
user: string, user: string,
pass: string, pass: string,
opts?: { readonly register?: boolean | undefined }, opts?: {
readonly register?: boolean | undefined
readonly createNewAccount?: boolean | undefined
},
): Promise<void> ): Promise<void>
listRealms(): Promise<readonly RealmInfo[]> listRealms(): Promise<readonly RealmInfo[]>
enterRealm(realm?: string): Promise<readonly CharSummary[]> enterRealm(realm?: string): Promise<readonly CharSummary[]>
@ -352,10 +355,7 @@ export function createD2OnlineFlow(config: D2OnlineConfig): D2OnlineFlow {
const session = requireBncs('createAccount') const session = requireBncs('createAccount')
const res = await exchange('bncs', () => session.createAccount(user, pass)) const res = await exchange('bncs', () => session.createAccount(user, pass))
if (res.status !== 0) { if (res.status !== 0) {
throw new ProtocolError( throw new BncsCreateAccountError(res.status, res.nameSuggestion, user)
`BNCS SID_CREATEACCOUNT2 failed for "${user}" with status=0x${res.status.toString(16)}`,
{ proto: 'bncs', packetId: 0x3d },
)
} }
}, },
@ -366,21 +366,33 @@ export function createD2OnlineFlow(config: D2OnlineConfig): D2OnlineFlow {
async loginOrRegister( async loginOrRegister(
user: string, user: string,
pass: string, pass: string,
opts?: { readonly register?: boolean | undefined }, opts?: {
readonly register?: boolean | undefined
readonly createNewAccount?: boolean | undefined
},
): Promise<void> { ): Promise<void> {
if (!bncs) { if (!bncs) {
await flow.connectBnet() await flow.connectBnet()
} }
const session = requireBncs('login') 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)) const firstLogin = await exchange('bncs', () => session.login(user, pass))
if (firstLogin.status !== 0) { if (firstLogin.status !== 0) {
const createRes = await exchange('bncs', () => session.createAccount(user, pass)) const createRes = await exchange('bncs', () => session.createAccount(user, pass))
if (createRes.status !== 0) { if (createRes.status !== 0) {
throw new ProtocolError( throw new BncsCreateAccountError(createRes.status, createRes.nameSuggestion, user)
`BNCS SID_CREATEACCOUNT2 failed for "${user}" with status=0x${createRes.status.toString(16)}`,
{ proto: 'bncs', packetId: 0x3d },
)
} }
const secondLogin = await exchange('bncs', () => session.login(user, pass)) const secondLogin = await exchange('bncs', () => session.login(user, pass))
if (secondLogin.status !== 0) { if (secondLogin.status !== 0) {

View File

@ -103,7 +103,14 @@ export { BitWriter } from './core/bit-writer.ts'
export { ByteReader } from './core/byte-reader.ts' export { ByteReader } from './core/byte-reader.ts'
export { ByteWriter } from './core/byte-writer.ts' export { ByteWriter } from './core/byte-writer.ts'
export { TypedEmitter } from './core/emitter.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 { export {
InMemoryPacketTap, InMemoryPacketTap,
MemoryPacketTap, MemoryPacketTap,
@ -116,7 +123,13 @@ export {
export { createBrowserTextCodec, defaultTextCodec, resolveTextCodec } from './core/text-codec.ts' export { createBrowserTextCodec, defaultTextCodec, resolveTextCodec } from './core/text-codec.ts'
export { defaultClock, systemClock } from './core/clock.ts' export { defaultClock, systemClock } from './core/clock.ts'
export { BncsFramer } from './bncs/framing.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 { BNCS_KEEPALIVE_INTERVAL_MS } from './bncs/session.ts'
export { McpFramer } from './mcp/framing.ts' export { McpFramer } from './mcp/framing.ts'
export { decodeMcpS2cPacket } from './mcp/packets.ts' export { decodeMcpS2cPacket } from './mcp/packets.ts'

View File

@ -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<void> {
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<CdpSession> {
const ws = new WebSocket(wsUrl)
await new Promise<void>((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<string, unknown> = {}): Promise<any> {
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<T = any>(expression: string): Promise<T> {
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<typeof spawn> | 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<string, unknown>)['/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
}
}
})
})

350
tests/frontend-flow.test.ts Normal file
View File

@ -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<string, unknown>
}
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)
})
})

View File

@ -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)
})
})

View File

@ -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()
}
})
})

View File

@ -37,6 +37,10 @@ export interface FakeRealmOptions {
readonly silentMcp?: readonly number[] | undefined readonly silentMcp?: readonly number[] | undefined
/** Characters in MCP_CHARLIST2 (0 = empty roster, i.e. the char_create screen). */ /** Characters in MCP_CHARLIST2 (0 = empty roster, i.e. the char_create screen). */
readonly characters?: number | undefined 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 { export interface FakeRealm {
@ -70,7 +74,13 @@ function charList(count: number): Uint8Array {
return w.toUint8Array() return w.toUint8Array()
} }
function wireBncs(server: MemoryStream, silent: ReadonlySet<number>, onNull: () => void): void { function wireBncs(
server: MemoryStream,
silent: ReadonlySet<number>,
onNull: () => void,
createAccountStatus = 0,
createAccountSuggestion = '',
): void {
const framer = new BncsFramer() const framer = new BncsFramer()
let initSeen = false let initSeen = false
server.onData((chunk) => { server.onData((chunk) => {
@ -99,6 +109,11 @@ function wireBncs(server: MemoryStream, silent: ReadonlySet<number>, onNull: ()
server.write(encodeBncsFrame(BncsOpcode.SID_AUTH_INFO, w.toUint8Array())) server.write(encodeBncsFrame(BncsOpcode.SID_AUTH_INFO, w.toUint8Array()))
} else if (pkt.id === BncsOpcode.SID_AUTH_CHECK) { } else if (pkt.id === BncsOpcode.SID_AUTH_CHECK) {
server.write(encodeBncsFrame(BncsOpcode.SID_AUTH_CHECK, new Uint8Array([0, 0, 0, 0, 0]))) 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) { } else if (pkt.id === BncsOpcode.SID_LOGONRESPONSE2) {
server.write(encodeBncsFrame(BncsOpcode.SID_LOGONRESPONSE2, new Uint8Array([0, 0, 0, 0]))) server.write(encodeBncsFrame(BncsOpcode.SID_LOGONRESPONSE2, new Uint8Array([0, 0, 0, 0])))
} else if (pkt.id === BncsOpcode.SID_QUERYREALMS2) { } else if (pkt.id === BncsOpcode.SID_QUERYREALMS2) {
@ -195,9 +210,15 @@ export function createFakeRealm(options: FakeRealmOptions = {}): FakeRealm {
const [client, server] = createMemoryStreamPair(role) const [client, server] = createMemoryStreamPair(role)
connections.push({ role, client, server }) connections.push({ role, client, server })
if (role === 'bnet') { if (role === 'bnet') {
wireBncs(server, silentBncs, () => { wireBncs(
nullCount++ server,
}) silentBncs,
() => {
nullCount++
},
options.createAccountStatus ?? 0,
options.createAccountSuggestion ?? '',
)
} else if (role === 'realm') wireMcp(server, silentMcp, characters) } else if (role === 'realm') wireMcp(server, silentMcp, characters)
else { else {
wireGame(server) wireGame(server)