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,
|
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()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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'
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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`)
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue