fix(netproto): send 1.13c 180s SID_NULL (0x00) BNCS keepalive (#541)

- Verify 1.13c Bnclient.dll (0x6ff2ed41: cmp ecx, 0x2bf20) sends 4-byte
  SID_NULL (FF 00 04 00) every 180,000 ms (3 minutes) once the BNCS
  handshake starts until the BNCS socket closes, and confirm D2MCPClient.dll
  has no client-side MCP keepalive packet.
- Start a 180,000 ms (BNCS_KEEPALIVE_INTERVAL_MS) periodic SID_NULL timer in
  BncsSession.handshake() and cancel it on close() or stream disconnect.
- Echo SID_NULL in FakeRealm wireBncs (matching PvPGN bnetd _client_pingreq)
  and add FakeClock unit tests for 180s SID_NULL keepalive and >6m idle
  persistence on character select and lobby screens.
- Add lobby-idle subcommand to tools/d2-bot.ts for live WSS idle verification.

Fixes #541
This commit is contained in:
troytt 2026-10-02 04:11:07 +00:00
parent 33b1b0afce
commit ff1f8e4cbe
8 changed files with 235 additions and 6 deletions

View File

@ -31,6 +31,7 @@ import {
encodeBncsJoinChannel, encodeBncsJoinChannel,
encodeBncsLogonRealmEx, encodeBncsLogonRealmEx,
encodeBncsLogonResponse2, encodeBncsLogonResponse2,
encodeBncsNull,
encodeBncsPing, encodeBncsPing,
encodeBncsQueryRealms2, encodeBncsQueryRealms2,
type BncsAuthCheckResponse, type BncsAuthCheckResponse,
@ -42,6 +43,12 @@ import {
type DecodedBncsS2cPacket, type DecodedBncsS2cPacket,
} from './packets.ts' } 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 = export type BncsSessionState =
| 'connecting' | 'connecting'
| 'authInfo' | 'authInfo'
@ -64,6 +71,7 @@ export interface BncsSessionOptions {
readonly ownerName?: string | undefined readonly ownerName?: string | undefined
readonly timeoutMs?: number | undefined readonly timeoutMs?: number | undefined
readonly requestTimeoutMs?: number | undefined readonly requestTimeoutMs?: number | undefined
readonly keepaliveIntervalMs?: number | undefined
} }
export class BncsSession { export class BncsSession {
@ -76,6 +84,7 @@ export class BncsSession {
private readonly chatEmitter = new TypedEmitter<ChatEvent>() private readonly chatEmitter = new TypedEmitter<ChatEvent>()
private readonly closeEmitter = new TypedEmitter<CloseReason>() private readonly closeEmitter = new TypedEmitter<CloseReason>()
private readonly timeoutMs: number private readonly timeoutMs: number
private readonly keepaliveIntervalMs: number
private readonly versionCheck: VersionCheckResult private readonly versionCheck: VersionCheckResult
private readonly cdKeys: { readonly classic: DecodedCdKey; readonly expansion: DecodedCdKey } private readonly cdKeys: { readonly classic: DecodedCdKey; readonly expansion: DecodedCdKey }
private readonly ownerName: string private readonly ownerName: string
@ -83,6 +92,7 @@ export class BncsSession {
readonly clientToken: number readonly clientToken: number
private serverToken = 0 private serverToken = 0
private _state: BncsSessionState = 'connecting' private _state: BncsSessionState = 'connecting'
private stopKeepaliveTimer: (() => void) | undefined
constructor(stream: ByteStream, options: BncsSessionOptions = {}) { constructor(stream: ByteStream, options: BncsSessionOptions = {}) {
this.stream = stream this.stream = stream
@ -90,6 +100,7 @@ export class BncsSession {
this.codec = options.textCodec ?? defaultTextCodec this.codec = options.textCodec ?? defaultTextCodec
this.tap = options.packetTap this.tap = options.packetTap
this.timeoutMs = options.requestTimeoutMs ?? options.timeoutMs ?? 10_000 this.timeoutMs = options.requestTimeoutMs ?? options.timeoutMs ?? 10_000
this.keepaliveIntervalMs = options.keepaliveIntervalMs ?? BNCS_KEEPALIVE_INTERVAL_MS
this.versionCheck = getCheckRevision113c(options.versionCheck) this.versionCheck = getCheckRevision113c(options.versionCheck)
this.cdKeys = options.cdKeys ?? createSyntheticCdKeyPair(1) this.cdKeys = options.cdKeys ?? createSyntheticCdKeyPair(1)
this.ownerName = options.ownerName ?? 'WebPlayer' this.ownerName = options.ownerName ?? 'WebPlayer'
@ -98,6 +109,7 @@ export class BncsSession {
this.stream.onData(chunk => this.handleChunk(chunk)) this.stream.onData(chunk => this.handleChunk(chunk))
this.stream.onClose(reason => { this.stream.onClose(reason => {
this.stopKeepalive()
this._state = 'closed' this._state = 'closed'
this.closeEmitter.emit(reason) this.closeEmitter.emit(reason)
}) })
@ -189,7 +201,7 @@ export class BncsSession {
/** /**
* Executes the BNCS initial handshake: * 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`) * 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 * 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` * 4. Awaits `SID_AUTH_CHECK (0x51)` and verifies `result === 0`
@ -206,6 +218,7 @@ export class BncsSession {
this.codec, this.codec,
) )
this.sendRaw(authInfoPkt, 0x50, 'SID_AUTH_INFO') this.sendRaw(authInfoPkt, 0x50, 'SID_AUTH_INFO')
this.startKeepalive()
const { data: authInfo } = await authInfoWait const { data: authInfo } = await authInfoWait
this.serverToken = authInfo.serverToken this.serverToken = authInfo.serverToken
@ -241,6 +254,28 @@ export class BncsSession {
return { authInfo, authCheck } 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<BncsCreateAccountResponse> { async createAccount(username: string, password: string): Promise<BncsCreateAccountResponse> {
const wait = this.waitForPacket('SID_CREATEACCOUNT2', 'SID_CREATEACCOUNT2') const wait = this.waitForPacket('SID_CREATEACCOUNT2', 'SID_CREATEACCOUNT2')
const pwHash = hashBnetPassword(password) const pwHash = hashBnetPassword(password)
@ -327,6 +362,7 @@ export class BncsSession {
} }
close(): void { close(): void {
this.stopKeepalive()
this._state = 'closed' this._state = 'closed'
this.stream.close() this.stream.close()
} }

View File

@ -60,4 +60,5 @@ export interface D2OnlineConfig {
readonly itemTables?: ItemDataTables | undefined readonly itemTables?: ItemDataTables | undefined
readonly tap?: PacketTap | undefined readonly tap?: PacketTap | undefined
readonly clock?: Clock | undefined readonly clock?: Clock | undefined
readonly bncsKeepaliveIntervalMs?: number | undefined
} }

View File

@ -333,6 +333,7 @@ export function createD2OnlineFlow(config: D2OnlineConfig): D2OnlineFlow {
clock, clock,
textCodec, textCodec,
packetTap: config.tap, packetTap: config.tap,
keepaliveIntervalMs: config.bncsKeepaliveIntervalMs,
}) })
bncs = session bncs = session
watchLobbyConnection('bncs', session) watchLobbyConnection('bncs', session)

View File

@ -116,7 +116,8 @@ 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 } 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 { McpFramer } from './mcp/framing.ts'
export { decodeMcpS2cPacket } from './mcp/packets.ts' export { decodeMcpS2cPacket } from './mcp/packets.ts'
export { validateC2sPacket } from './d2gs/framing-c2s.ts' export { validateC2sPacket } from './d2gs/framing-c2s.ts'

View File

@ -247,4 +247,74 @@ describe('BNCS & MCP framing, packets, and sessions', () => {
const [rawPkt] = framer.push(setEmail) const [rawPkt] = framer.push(setEmail)
expect(decodeBncsS2cPacket(rawPkt!).type).toBe('SID_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)
})
})

View File

@ -42,6 +42,7 @@ export interface FakeRealmOptions {
export interface FakeRealm { export interface FakeRealm {
readonly resolver: EndpointResolver readonly resolver: EndpointResolver
readonly connections: FakeConnection[] readonly connections: FakeConnection[]
readonly bncsNullCount: number
/** Most recent connection for `role`. */ /** Most recent connection for `role`. */
latest(role: EndpointRole): FakeConnection latest(role: EndpointRole): FakeConnection
/** Every client stream the resolver handed out is closed (nothing leaked). */ /** Every client stream the resolver handed out is closed (nothing leaked). */
@ -69,7 +70,7 @@ function charList(count: number): Uint8Array {
return w.toUint8Array() return w.toUint8Array()
} }
function wireBncs(server: MemoryStream, silent: ReadonlySet<number>): void { function wireBncs(server: MemoryStream, silent: ReadonlySet<number>, onNull: () => void): void {
const framer = new BncsFramer() const framer = new BncsFramer()
let initSeen = false let initSeen = false
server.onData((chunk) => { server.onData((chunk) => {
@ -79,8 +80,13 @@ function wireBncs(server: MemoryStream, silent: ReadonlySet<number>): void {
data = data.subarray(1) data = data.subarray(1)
} }
for (const pkt of framer.push(data)) { for (const pkt of framer.push(data)) {
if (pkt.id === BncsOpcode.SID_NULL) {
onNull()
}
if (silent.has(pkt.id) || server.isClosed) continue 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)) server.write(encodeBncsPing(0x11223344))
const w = new ByteWriter() const w = new ByteWriter()
w.u32LE(0) w.u32LE(0)
@ -114,6 +120,12 @@ function wireBncs(server: MemoryStream, silent: ReadonlySet<number>): void {
w.bytes(new Uint8Array(48).fill(0x55)) w.bytes(new Uint8Array(48).fill(0x55))
w.cstring('d2webbot_sorc') w.cstring('d2webbot_sorc')
server.write(encodeBncsFrame(BncsOpcode.SID_LOGONREALMEX, w.toUint8Array())) 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 silentMcp = new Set(options.silentMcp ?? [])
const characters = options.characters ?? 1 const characters = options.characters ?? 1
const connections: FakeConnection[] = [] const connections: FakeConnection[] = []
let nullCount = 0
const resolver: EndpointResolver = { const resolver: EndpointResolver = {
open: async (role: EndpointRole): Promise<ByteStream> => { open: async (role: EndpointRole): Promise<ByteStream> => {
const [client, server] = createMemoryStreamPair(role) const [client, server] = createMemoryStreamPair(role)
connections.push({ role, client, server }) connections.push({ role, client, server })
if (role === 'bnet') wireBncs(server, silentBncs) if (role === 'bnet') {
else if (role === 'realm') wireMcp(server, silentMcp, characters) wireBncs(server, silentBncs, () => {
nullCount++
})
} else if (role === 'realm') wireMcp(server, silentMcp, characters)
else { else {
wireGame(server) wireGame(server)
setTimeout(() => { setTimeout(() => {
@ -196,6 +212,9 @@ export function createFakeRealm(options: FakeRealmOptions = {}): FakeRealm {
return { return {
resolver, resolver,
connections, connections,
get bncsNullCount(): number {
return nullCount
},
latest(role: EndpointRole): FakeConnection { latest(role: EndpointRole): FakeConnection {
const found = [...connections].reverse().find((c) => c.role === role) const found = [...connections].reverse().find((c) => c.role === role)
if (!found) throw new Error(`no ${role} connection was opened`) if (!found) throw new Error(`no ${role} connection was opened`)

View File

@ -192,4 +192,38 @@ describe('D2OnlineFlow session loss', () => {
expect(h.flow.state).toBe('closed') expect(h.flow.state).toBe('closed')
expect(h.realm.allClientStreamsClosed()).toBe(true) 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()
}) })
})

View File

@ -1350,12 +1350,18 @@ async function main(): Promise<void> {
return return
} }
const bncsKeepaliveIntervalMs =
typeof flags['bncs-keepalive-ms'] === 'string'
? Number(flags['bncs-keepalive-ms'])
: undefined
const flow = createD2OnlineFlow({ const flow = createD2OnlineFlow({
resolver: buildResolver(via, bnetHost, wsHost), resolver: buildResolver(via, bnetHost, wsHost),
bnetHost, bnetHost,
defaultRealm: accountsFile.realm, defaultRealm: accountsFile.realm,
itemTables: tables, itemTables: tables,
tap, tap,
...(bncsKeepaliveIntervalMs !== undefined ? { bncsKeepaliveIntervalMs } : {}),
}) })
try { try {
@ -1407,6 +1413,67 @@ async function main(): Promise<void> {
return 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 // Ensure target character exists and select it
const hasTargetChar = chars.some(c => c.name.toLowerCase() === charName.toLowerCase()) const hasTargetChar = chars.some(c => c.name.toLowerCase() === charName.toLowerCase())
if (!hasTargetChar) { if (!hasTargetChar) {