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:
parent
33b1b0afce
commit
ff1f8e4cbe
|
|
@ -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<ChatEvent>()
|
||||
private readonly closeEmitter = new TypedEmitter<CloseReason>()
|
||||
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<BncsCreateAccountResponse> {
|
||||
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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -60,4 +60,5 @@ export interface D2OnlineConfig {
|
|||
readonly itemTables?: ItemDataTables | undefined
|
||||
readonly tap?: PacketTap | undefined
|
||||
readonly clock?: Clock | undefined
|
||||
readonly bncsKeepaliveIntervalMs?: number | undefined
|
||||
}
|
||||
|
|
|
|||
|
|
@ -333,6 +333,7 @@ export function createD2OnlineFlow(config: D2OnlineConfig): D2OnlineFlow {
|
|||
clock,
|
||||
textCodec,
|
||||
packetTap: config.tap,
|
||||
keepaliveIntervalMs: config.bncsKeepaliveIntervalMs,
|
||||
})
|
||||
bncs = session
|
||||
watchLobbyConnection('bncs', session)
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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<number>): void {
|
||||
function wireBncs(server: MemoryStream, silent: ReadonlySet<number>, onNull: () => void): void {
|
||||
const framer = new BncsFramer()
|
||||
let initSeen = false
|
||||
server.onData((chunk) => {
|
||||
|
|
@ -79,8 +80,13 @@ function wireBncs(server: MemoryStream, silent: ReadonlySet<number>): 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<number>): 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<ByteStream> => {
|
||||
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`)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -1350,12 +1350,18 @@ async function main(): Promise<void> {
|
|||
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<void> {
|
|||
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) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue