diff --git a/src/netproto/bncs/session.ts b/src/netproto/bncs/session.ts index 535ed6e..e8b80cc 100644 --- a/src/netproto/bncs/session.ts +++ b/src/netproto/bncs/session.ts @@ -31,6 +31,7 @@ import { encodeBncsJoinChannel, encodeBncsLogonRealmEx, encodeBncsLogonResponse2, + encodeBncsNull, encodeBncsPing, encodeBncsQueryRealms2, type BncsAuthCheckResponse, @@ -42,6 +43,12 @@ import { type DecodedBncsS2cPacket, } from './packets.ts' +/** + * 1.13c `Bnclient.dll` (`0x6ff2ed41`: `cmp ecx, 0x2bf20`) sends `SID_NULL (0x00)` + * every 180,000 ms (3 minutes) while the BNCS socket is connected. + */ +export const BNCS_KEEPALIVE_INTERVAL_MS = 180_000 + export type BncsSessionState = | 'connecting' | 'authInfo' @@ -64,6 +71,7 @@ export interface BncsSessionOptions { readonly ownerName?: string | undefined readonly timeoutMs?: number | undefined readonly requestTimeoutMs?: number | undefined + readonly keepaliveIntervalMs?: number | undefined } export class BncsSession { @@ -76,6 +84,7 @@ export class BncsSession { private readonly chatEmitter = new TypedEmitter() private readonly closeEmitter = new TypedEmitter() private readonly timeoutMs: number + private readonly keepaliveIntervalMs: number private readonly versionCheck: VersionCheckResult private readonly cdKeys: { readonly classic: DecodedCdKey; readonly expansion: DecodedCdKey } private readonly ownerName: string @@ -83,6 +92,7 @@ export class BncsSession { readonly clientToken: number private serverToken = 0 private _state: BncsSessionState = 'connecting' + private stopKeepaliveTimer: (() => void) | undefined constructor(stream: ByteStream, options: BncsSessionOptions = {}) { this.stream = stream @@ -90,6 +100,7 @@ export class BncsSession { this.codec = options.textCodec ?? defaultTextCodec this.tap = options.packetTap this.timeoutMs = options.requestTimeoutMs ?? options.timeoutMs ?? 10_000 + this.keepaliveIntervalMs = options.keepaliveIntervalMs ?? BNCS_KEEPALIVE_INTERVAL_MS this.versionCheck = getCheckRevision113c(options.versionCheck) this.cdKeys = options.cdKeys ?? createSyntheticCdKeyPair(1) this.ownerName = options.ownerName ?? 'WebPlayer' @@ -98,6 +109,7 @@ export class BncsSession { this.stream.onData(chunk => this.handleChunk(chunk)) this.stream.onClose(reason => { + this.stopKeepalive() this._state = 'closed' this.closeEmitter.emit(reason) }) @@ -189,7 +201,7 @@ export class BncsSession { /** * Executes the BNCS initial handshake: - * 1. Sends protocol greeting byte `0x01` + `SID_AUTH_INFO (0x50)` + * 1. Sends protocol greeting byte `0x01` + `SID_AUTH_INFO (0x50)` and starts 180s `SID_NULL (0x00)` keepalive * 2. Awaits `SID_AUTH_INFO (0x50)` (while automatically echoing `SID_PING 0x25`) * 3. Sends `SID_AUTH_CHECK (0x51)` with 1.13c CheckRevision constants and hashed D2DV+D2XP CD-Keys * 4. Awaits `SID_AUTH_CHECK (0x51)` and verifies `result === 0` @@ -206,6 +218,7 @@ export class BncsSession { this.codec, ) this.sendRaw(authInfoPkt, 0x50, 'SID_AUTH_INFO') + this.startKeepalive() const { data: authInfo } = await authInfoWait this.serverToken = authInfo.serverToken @@ -241,6 +254,28 @@ export class BncsSession { return { authInfo, authCheck } } + sendNull(): void { + if (this._state === 'closed') return + this.sendRaw(encodeBncsNull(), 0x00, 'SID_NULL') + } + + private startKeepalive(): void { + this.stopKeepalive() + if (this.keepaliveIntervalMs <= 0) return + this.stopKeepaliveTimer = this.clock.setInterval(() => { + if (this._state !== 'closed') { + this.sendNull() + } + }, this.keepaliveIntervalMs) + } + + private stopKeepalive(): void { + if (this.stopKeepaliveTimer) { + this.stopKeepaliveTimer() + this.stopKeepaliveTimer = undefined + } + } + async createAccount(username: string, password: string): Promise { const wait = this.waitForPacket('SID_CREATEACCOUNT2', 'SID_CREATEACCOUNT2') const pwHash = hashBnetPassword(password) @@ -327,6 +362,7 @@ export class BncsSession { } close(): void { + this.stopKeepalive() this._state = 'closed' this.stream.close() } diff --git a/src/netproto/flow/config.ts b/src/netproto/flow/config.ts index 8bca320..47f2763 100644 --- a/src/netproto/flow/config.ts +++ b/src/netproto/flow/config.ts @@ -60,4 +60,5 @@ export interface D2OnlineConfig { readonly itemTables?: ItemDataTables | undefined readonly tap?: PacketTap | undefined readonly clock?: Clock | undefined + readonly bncsKeepaliveIntervalMs?: number | undefined } diff --git a/src/netproto/flow/online-flow.ts b/src/netproto/flow/online-flow.ts index e96d1a0..c19f70e 100644 --- a/src/netproto/flow/online-flow.ts +++ b/src/netproto/flow/online-flow.ts @@ -333,6 +333,7 @@ export function createD2OnlineFlow(config: D2OnlineConfig): D2OnlineFlow { clock, textCodec, packetTap: config.tap, + keepaliveIntervalMs: config.bncsKeepaliveIntervalMs, }) bncs = session watchLobbyConnection('bncs', session) diff --git a/src/netproto/index.ts b/src/netproto/index.ts index c5611cc..7479dbf 100644 --- a/src/netproto/index.ts +++ b/src/netproto/index.ts @@ -116,7 +116,8 @@ export { export { createBrowserTextCodec, defaultTextCodec, resolveTextCodec } from './core/text-codec.ts' export { defaultClock, systemClock } from './core/clock.ts' export { BncsFramer } from './bncs/framing.ts' -export { decodeBncsS2cPacket } from './bncs/packets.ts' +export { decodeBncsS2cPacket, encodeBncsNull } from './bncs/packets.ts' +export { BNCS_KEEPALIVE_INTERVAL_MS } from './bncs/session.ts' export { McpFramer } from './mcp/framing.ts' export { decodeMcpS2cPacket } from './mcp/packets.ts' export { validateC2sPacket } from './d2gs/framing-c2s.ts' diff --git a/tests/netproto/core-crypto-bncs-mcp.test.ts b/tests/netproto/core-crypto-bncs-mcp.test.ts index 9cb2d94..ecc621c 100644 --- a/tests/netproto/core-crypto-bncs-mcp.test.ts +++ b/tests/netproto/core-crypto-bncs-mcp.test.ts @@ -247,4 +247,74 @@ describe('BNCS & MCP framing, packets, and sessions', () => { const [rawPkt] = framer.push(setEmail) expect(decodeBncsS2cPacket(rawPkt!).type).toBe('SID_SETEMAIL') }) + + it('sends 1.13c SID_NULL (0x00) every 180,000 ms after handshake begins and stops on close', async () => { + const { FakeClock } = await import('../../src/netproto/core/clock.ts') + const { BNCS_KEEPALIVE_INTERVAL_MS } = await import('../../src/netproto/bncs/session.ts') + const { InMemoryPacketTap } = await import('../../src/netproto/core/packet-tap.ts') + + expect(BNCS_KEEPALIVE_INTERVAL_MS).toBe(180_000) + + const clock = new FakeClock(1000) + const tap = new InMemoryPacketTap() + const [clientStream, serverStream] = createMemoryStreamPair() + const bncs = new BncsSession(clientStream, { clock, packetTap: tap, clientToken: 0x11223344 }) + + const serverFramer = new BncsFramer() + let initSeen = false + const serverNullsReceived: Uint8Array[] = [] + + serverStream.onData((chunk) => { + let data = chunk + if (!initSeen && data[0] === 0x01) { + initSeen = true + data = data.subarray(1) + } + for (const pkt of serverFramer.push(data)) { + if (pkt.id === BncsOpcode.SID_AUTH_INFO) { + const w = new ByteWriter() + w.u32LE(0) + w.u32LE(0xabcdef01) + w.u32LE(0) + w.u32LE(0) + w.u32LE(0) + w.cstring('ver-IX86-1.mpq') + w.cstring('A=1 B=2 C=3 4 A=A^S B=B^C C=C^A A=A^B') + serverStream.write(encodeBncsFrame(BncsOpcode.SID_AUTH_INFO, w.toUint8Array())) + } else if (pkt.id === BncsOpcode.SID_AUTH_CHECK) { + serverStream.write(encodeBncsFrame(BncsOpcode.SID_AUTH_CHECK, new Uint8Array([0, 0, 0, 0, 0]))) + } else if (pkt.id === BncsOpcode.SID_NULL) { + serverNullsReceived.push(pkt.raw) + // PvPGN bnetd _client_pingreq echoes 4-byte SID_NULL (FF 00 04 00) + serverStream.write(encodeBncsFrame(BncsOpcode.SID_NULL, new Uint8Array(0))) + } + } + }) + + await bncs.handshake() + expect(serverNullsReceived).toHaveLength(0) + + // Before 180,000 ms (0x2bf20): no SID_NULL sent + clock.advance(179_999) + expect(serverNullsReceived).toHaveLength(0) + + // At exactly 180,000 ms: first SID_NULL sent (FF 00 04 00) and echoed by server + clock.advance(1) + expect(serverNullsReceived).toHaveLength(1) + expect(bytesToHex(serverNullsReceived[0]!)).toBe('ff000400') + + // At 360,000 ms: second SID_NULL sent + clock.advance(180_000) + expect(serverNullsReceived).toHaveLength(2) + + const nullTapEntries = tap.entries.filter((e) => e.packetName === 'SID_NULL') + expect(nullTapEntries.filter((e) => e.dir === 'c2s')).toHaveLength(2) + expect(nullTapEntries.filter((e) => e.dir === 's2c')).toHaveLength(2) + + // Closing session stops the keepalive timer + bncs.close() + clock.advance(540_000) + expect(serverNullsReceived).toHaveLength(2) + }) }) + diff --git a/tests/netproto/fake-realm-servers.ts b/tests/netproto/fake-realm-servers.ts index e1c8ba5..a283484 100644 --- a/tests/netproto/fake-realm-servers.ts +++ b/tests/netproto/fake-realm-servers.ts @@ -42,6 +42,7 @@ export interface FakeRealmOptions { export interface FakeRealm { readonly resolver: EndpointResolver readonly connections: FakeConnection[] + readonly bncsNullCount: number /** Most recent connection for `role`. */ latest(role: EndpointRole): FakeConnection /** Every client stream the resolver handed out is closed (nothing leaked). */ @@ -69,7 +70,7 @@ function charList(count: number): Uint8Array { return w.toUint8Array() } -function wireBncs(server: MemoryStream, silent: ReadonlySet): void { +function wireBncs(server: MemoryStream, silent: ReadonlySet, onNull: () => void): void { const framer = new BncsFramer() let initSeen = false server.onData((chunk) => { @@ -79,8 +80,13 @@ function wireBncs(server: MemoryStream, silent: ReadonlySet): void { data = data.subarray(1) } for (const pkt of framer.push(data)) { + if (pkt.id === BncsOpcode.SID_NULL) { + onNull() + } if (silent.has(pkt.id) || server.isClosed) continue - if (pkt.id === BncsOpcode.SID_AUTH_INFO) { + if (pkt.id === BncsOpcode.SID_NULL) { + server.write(encodeBncsFrame(BncsOpcode.SID_NULL, new Uint8Array(0))) + } else if (pkt.id === BncsOpcode.SID_AUTH_INFO) { server.write(encodeBncsPing(0x11223344)) const w = new ByteWriter() w.u32LE(0) @@ -114,6 +120,12 @@ function wireBncs(server: MemoryStream, silent: ReadonlySet): void { w.bytes(new Uint8Array(48).fill(0x55)) w.cstring('d2webbot_sorc') server.write(encodeBncsFrame(BncsOpcode.SID_LOGONREALMEX, w.toUint8Array())) + } else if (pkt.id === BncsOpcode.SID_ENTERCHAT) { + const w = new ByteWriter() + w.cstring('d2webbot_sorc') + w.cstring('PX2D') + w.cstring('d2webbot2') + server.write(encodeBncsFrame(BncsOpcode.SID_ENTERCHAT, w.toUint8Array())) } } }) @@ -176,13 +188,17 @@ export function createFakeRealm(options: FakeRealmOptions = {}): FakeRealm { const silentMcp = new Set(options.silentMcp ?? []) const characters = options.characters ?? 1 const connections: FakeConnection[] = [] + let nullCount = 0 const resolver: EndpointResolver = { open: async (role: EndpointRole): Promise => { const [client, server] = createMemoryStreamPair(role) connections.push({ role, client, server }) - if (role === 'bnet') wireBncs(server, silentBncs) - else if (role === 'realm') wireMcp(server, silentMcp, characters) + if (role === 'bnet') { + wireBncs(server, silentBncs, () => { + nullCount++ + }) + } else if (role === 'realm') wireMcp(server, silentMcp, characters) else { wireGame(server) setTimeout(() => { @@ -196,6 +212,9 @@ export function createFakeRealm(options: FakeRealmOptions = {}): FakeRealm { return { resolver, connections, + get bncsNullCount(): number { + return nullCount + }, latest(role: EndpointRole): FakeConnection { const found = [...connections].reverse().find((c) => c.role === role) if (!found) throw new Error(`no ${role} connection was opened`) diff --git a/tests/netproto/online-flow-session-lost.test.ts b/tests/netproto/online-flow-session-lost.test.ts index 51d03d7..b977d18 100644 --- a/tests/netproto/online-flow-session-lost.test.ts +++ b/tests/netproto/online-flow-session-lost.test.ts @@ -192,4 +192,38 @@ describe('D2OnlineFlow session loss', () => { expect(h.flow.state).toBe('closed') expect(h.realm.allClientStreamsClosed()).toBe(true) }) + + it('idling >6 minutes on char_select / lobby sends 180s SID_NULL keepalives and still allows listCharacters and createGame', async () => { + const h = createHarness({ characters: 1 }) + await toCharacterScreen(h) + expect(h.realm.bncsNullCount).toBe(0) + + // Idle 6 minutes (360,000 ms) on character screen -> 2 BNCS SID_NULL keepalives + h.clock.advance(360_000) + await flushMicrotasks() + expect(h.realm.bncsNullCount).toBe(2) + expect(h.lost).toHaveLength(0) + expect(h.flow.sessionLost).toBeNull() + expect(h.flow.state).toBe('realm_ready') + + const charsAfterCharSelectIdle = await h.flow.listCharacters() + expect(charsAfterCharSelectIdle).toHaveLength(1) + expect(charsAfterCharSelectIdle[0]!.name).toBe('d2webbot_sorc') + + // Select character, enter chat lobby, and idle another 6 minutes (360,000 ms) -> 4 total SID_NULLs + await h.flow.selectCharacter('d2webbot_sorc') + await h.flow.enterChat() + h.clock.advance(360_000) + await flushMicrotasks() + expect(h.realm.bncsNullCount).toBe(4) + expect(h.lost).toHaveLength(0) + expect(h.flow.sessionLost).toBeNull() + + await h.flow.createGame({ name: 'after-idle', password: '', difficulty: 0 }) + const adapter = await h.flow.joinGame('after-idle', '') + expect(h.flow.state).toBe('ingame') + adapter.leave() + await h.flow.close() + }) }) + diff --git a/tools/d2-bot.ts b/tools/d2-bot.ts index 2108379..513bda4 100644 --- a/tools/d2-bot.ts +++ b/tools/d2-bot.ts @@ -1350,12 +1350,18 @@ async function main(): Promise { return } + const bncsKeepaliveIntervalMs = + typeof flags['bncs-keepalive-ms'] === 'string' + ? Number(flags['bncs-keepalive-ms']) + : undefined + const flow = createD2OnlineFlow({ resolver: buildResolver(via, bnetHost, wsHost), bnetHost, defaultRealm: accountsFile.realm, itemTables: tables, tap, + ...(bncsKeepaliveIntervalMs !== undefined ? { bncsKeepaliveIntervalMs } : {}), }) try { @@ -1407,6 +1413,67 @@ async function main(): Promise { return } + if (cmd === 'lobby-idle') { + const idleSec = typeof flags.idle === 'string' ? Math.max(1, Number(flags.idle)) : 190 + console.log( + `[d2-bot:lobby-idle] Entered realm "${accountsFile.realm}" (state=${flow.state}, chars=${chars.length}). Idling ${idleSec}s on character screen...`, + ) + const startMs = Date.now() + const endAt = startMs + idleSec * 1000 + let nextLogAt = startMs + 15_000 + while (Date.now() < endAt && flow.sessionLost === null) { + await sleep(250) + if (Date.now() >= nextLogAt) { + const elapsedSec = Math.round((Date.now() - startMs) / 1000) + const c2sNulls = tap.entries.filter(e => e.packetName === 'SID_NULL' && e.dir === 'c2s').length + const s2cNulls = tap.entries.filter(e => e.packetName === 'SID_NULL' && e.dir === 's2c').length + console.log( + `[d2-bot:lobby-idle] elapsed=${elapsedSec}s/${idleSec}s state=${flow.state} sessionLost=null c2s_SID_NULL=${c2sNulls} s2c_SID_NULL=${s2cNulls}`, + ) + nextLogAt = Date.now() + 15_000 + } + } + if (flow.sessionLost !== null) { + throw new Error(`[d2-bot:lobby-idle] Session lost during idle: ${flow.sessionLost.message}`) + } + const c2sNulls = tap.entries.filter(e => e.packetName === 'SID_NULL' && e.dir === 'c2s').length + const s2cNulls = tap.entries.filter(e => e.packetName === 'SID_NULL' && e.dir === 's2c').length + console.log( + `[d2-bot:lobby-idle] Idle complete (${idleSec}s): c2s_SID_NULL=${c2sNulls} s2c_SID_NULL=${s2cNulls}. Verifying listChars & createGame...`, + ) + const refreshedChars = await flow.listCharacters() + console.log( + `[d2-bot:lobby-idle] listCharacters() succeeded (${refreshedChars.length} chars):`, + refreshedChars.map(c => c.name), + ) + const hasChar = refreshedChars.some(c => c.name.toLowerCase() === charName.toLowerCase()) + if (!hasChar) { + await flow.createCharacter({ + name: charName, + classId: charClass, + expansion: true, + hardcore: false, + ladder: true, + }) + } + await flow.selectCharacter(charName) + await flow.enterChat() + const idleGameName = `wbi${Math.floor(Date.now() / 1000).toString(36).slice(-5)}` + await flow.createGame({ + name: idleGameName, + password: '', + description: 'lobby-idle verify', + difficulty: 0, + maxPlayers: 8, + }) + const activeGames = await flow.listGames('') + console.log( + `[d2-bot:lobby-idle] createGame("${idleGameName}") & listGames() succeeded (activeGames=${activeGames.length}, sessionLost=${flow.sessionLost})`, + ) + writeCaptureIfRequested('lobby-idle') + return + } + // Ensure target character exists and select it const hasTargetChar = chars.some(c => c.name.toLowerCase() === charName.toLowerCase()) if (!hasTargetChar) {