diablo2-web/tests/netproto/fake-realm-servers.ts

209 lines
7.2 KiB
TypeScript

/**
* In-memory BNCS / MCP / D2GS fake servers for `createD2OnlineFlow` session-loss tests.
*
* Every `resolver.open()` creates a fresh `MemoryStream` pair (so a second login gets new
* connections) and records both ends, letting tests force-close the server side of a connection and
* assert that the flow disposed its client side. Responders follow the tier4 S6 harness.
*/
import {
createMemoryStreamPair,
type ByteStream,
type EndpointResolver,
type EndpointRole,
} from '../../src/netproto/index.ts'
import { ByteReader } from '../../src/netproto/core/byte-reader.ts'
import { ByteWriter } from '../../src/netproto/core/byte-writer.ts'
import { BncsFramer, encodeBncsFrame } from '../../src/netproto/bncs/framing.ts'
import { BncsOpcode, encodeBncsPing } from '../../src/netproto/bncs/packets.ts'
import { encodeMcpFrame, McpFramer } from '../../src/netproto/mcp/framing.ts'
import { McpOpcode } from '../../src/netproto/mcp/packets.ts'
import {
defaultD2gsHuffmanCodec,
wrapD2gsCompressedBlock,
} from '../../src/netproto/d2gs/compression.ts'
import type { MemoryStream } from '../../src/netproto/transport/memory-stream.ts'
export interface FakeConnection {
readonly role: EndpointRole
readonly client: MemoryStream
readonly server: MemoryStream
}
export interface FakeRealmOptions {
/** BNCS request opcodes the fake server never answers (to provoke request timeouts). */
readonly silentBncs?: readonly number[] | undefined
/** MCP request opcodes the fake server never answers. */
readonly silentMcp?: readonly number[] | undefined
/** Characters in MCP_CHARLIST2 (0 = empty roster, i.e. the char_create screen). */
readonly characters?: number | undefined
}
export interface FakeRealm {
readonly resolver: EndpointResolver
readonly connections: FakeConnection[]
/** Most recent connection for `role`. */
latest(role: EndpointRole): FakeConnection
/** Every client stream the resolver handed out is closed (nothing leaked). */
allClientStreamsClosed(): boolean
}
function charList(count: number): Uint8Array {
const w = new ByteWriter()
w.u16LE(8)
w.u32LE(count)
w.u16LE(count)
for (let i = 0; i < count; i++) {
w.u32LE(0x7fffffff)
w.cstring(`d2webbot_sorc${i === 0 ? '' : String(i)}`)
const stat = new Uint8Array(33).fill(0xff)
stat[0] = 0x84
stat[1] = 0x80
stat[13] = 0x02
stat[25] = 80
stat[26] = 0xa0
stat[27] = 0x80
w.bytes(stat)
w.u8(0)
}
return w.toUint8Array()
}
function wireBncs(server: MemoryStream, silent: ReadonlySet<number>): void {
const framer = new BncsFramer()
let initSeen = false
server.onData((chunk) => {
let data = chunk
if (!initSeen && data[0] === 0x01) {
initSeen = true
data = data.subarray(1)
}
for (const pkt of framer.push(data)) {
if (silent.has(pkt.id) || server.isClosed) continue
if (pkt.id === BncsOpcode.SID_AUTH_INFO) {
server.write(encodeBncsPing(0x11223344))
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')
server.write(encodeBncsFrame(BncsOpcode.SID_AUTH_INFO, w.toUint8Array()))
} else if (pkt.id === BncsOpcode.SID_AUTH_CHECK) {
server.write(encodeBncsFrame(BncsOpcode.SID_AUTH_CHECK, new Uint8Array([0, 0, 0, 0, 0])))
} else if (pkt.id === BncsOpcode.SID_LOGONRESPONSE2) {
server.write(encodeBncsFrame(BncsOpcode.SID_LOGONRESPONSE2, new Uint8Array([0, 0, 0, 0])))
} else if (pkt.id === BncsOpcode.SID_QUERYREALMS2) {
const w = new ByteWriter()
w.u32LE(0)
w.u32LE(1)
w.u32LE(1)
w.cstring('D2CS')
w.cstring('Local Test Realm')
server.write(encodeBncsFrame(BncsOpcode.SID_QUERYREALMS2, w.toUint8Array()))
} else if (pkt.id === BncsOpcode.SID_LOGONREALMEX) {
const w = new ByteWriter()
w.u32LE(0x11223344)
w.u32LE(0)
w.bytes(new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]))
w.bytes(new Uint8Array([127, 0, 0, 1]))
w.u16BE(6113)
w.u16LE(0)
w.bytes(new Uint8Array(48).fill(0x55))
w.cstring('d2webbot_sorc')
server.write(encodeBncsFrame(BncsOpcode.SID_LOGONREALMEX, w.toUint8Array()))
}
}
})
}
function wireMcp(server: MemoryStream, silent: ReadonlySet<number>, characters: number): void {
const framer = new McpFramer()
let initSeen = false
server.onData((chunk) => {
let data = chunk
if (!initSeen && data[0] === 0x01) {
initSeen = true
data = data.subarray(1)
}
for (const pkt of framer.push(data)) {
if (silent.has(pkt.id) || server.isClosed) continue
if (pkt.id === McpOpcode.MCP_STARTUP) {
server.write(encodeMcpFrame(McpOpcode.MCP_STARTUP, new Uint8Array([0, 0, 0, 0])))
} else if (pkt.id === McpOpcode.MCP_CHARLIST2) {
server.write(encodeMcpFrame(McpOpcode.MCP_CHARLIST2, charList(characters)))
} else if (pkt.id === McpOpcode.MCP_CHARLOGON) {
server.write(encodeMcpFrame(McpOpcode.MCP_CHARLOGON, new Uint8Array([0, 0, 0, 0])))
} else if (pkt.id === McpOpcode.MCP_CREATEGAME) {
const reqId = new ByteReader(pkt.payload).u16LE()
const w = new ByteWriter()
w.u16LE(reqId)
w.u16LE(1)
w.u16LE(0)
w.u32LE(0)
server.write(encodeMcpFrame(McpOpcode.MCP_CREATEGAME, w.toUint8Array()))
} else if (pkt.id === McpOpcode.MCP_JOINGAME) {
const reqId = new ByteReader(pkt.payload).u16LE()
const w = new ByteWriter()
w.u16LE(reqId)
w.u16LE(0x0042)
w.u16LE(0)
w.bytes(new Uint8Array([127, 0, 0, 1]))
w.u32LE(0xdeadbeef)
w.u32LE(0)
server.write(encodeMcpFrame(McpOpcode.MCP_JOINGAME, w.toUint8Array()))
}
}
})
}
function wireGame(server: MemoryStream): void {
server.onData((chunk) => {
if (chunk[0] === 0x68 && !server.isClosed) {
server.write(
wrapD2gsCompressedBlock(
defaultD2gsHuffmanCodec.compress(new Uint8Array([0x01, 0, 0, 0, 0, 0, 1, 1, 0x02, 0x04])),
),
)
}
})
}
export function createFakeRealm(options: FakeRealmOptions = {}): FakeRealm {
const silentBncs = new Set(options.silentBncs ?? [])
const silentMcp = new Set(options.silentMcp ?? [])
const characters = options.characters ?? 1
const connections: FakeConnection[] = []
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)
else {
wireGame(server)
setTimeout(() => {
if (!server.isClosed) server.write(new Uint8Array([0xaf, 0x01]))
}, 5)
}
return client
},
}
return {
resolver,
connections,
latest(role: EndpointRole): FakeConnection {
const found = [...connections].reverse().find((c) => c.role === role)
if (!found) throw new Error(`no ${role} connection was opened`)
return found
},
allClientStreamsClosed(): boolean {
return connections.every((c) => c.client.isClosed)
},
}
}