218 lines
5.9 KiB
TypeScript
218 lines
5.9 KiB
TypeScript
/**
|
|
* D2GS Session State Machine (1.13c):
|
|
* 1. Waits for uncompressed S->C [0xAF, 0x01] CompressionInfo
|
|
* 2. Sends 37-byte C->S 0x68 GameLogon
|
|
* 3. Receives compressed S->C 0x01 GameFlags and 0x02 GameLogonSuccess
|
|
* 4. Sends 1-byte C->S 0x6B EnterGame
|
|
* 5. Starts 5000ms C->S 0x6D Ping loop and tracks 0x8F Pong RTT
|
|
*/
|
|
|
|
import { type Clock, defaultClock } from '../core/clock.ts'
|
|
import { Emitter } from '../core/emitter.ts'
|
|
import type { PacketTap } from '../core/packet-tap.ts'
|
|
import type { ByteStream, CloseReason } from '../transport/byte-stream.ts'
|
|
import {
|
|
type D2gsGameLogonParams,
|
|
encodeC2sEnterGame,
|
|
encodeC2sGameLogon,
|
|
encodeC2sLeaveGame,
|
|
encodeC2sPing,
|
|
} from './c2s/session.ts'
|
|
import { D2gsS2cFramer } from './framing-s2c.ts'
|
|
|
|
export type D2gsSessionState =
|
|
| 'connecting'
|
|
| 'logonSent'
|
|
| 'entering'
|
|
| 'ingame'
|
|
| 'closed'
|
|
|
|
export interface D2gsSessionOptions {
|
|
readonly stream: ByteStream
|
|
readonly logon: D2gsGameLogonParams
|
|
readonly clock?: Clock | undefined
|
|
readonly tap?: PacketTap | undefined
|
|
readonly pingIntervalMs?: number | undefined
|
|
}
|
|
|
|
export class D2gsSession {
|
|
private readonly stream: ByteStream
|
|
private readonly logon: D2gsGameLogonParams
|
|
private readonly clock: Clock
|
|
private readonly tap: PacketTap | undefined
|
|
private readonly pingIntervalMs: number
|
|
private readonly framer = new D2gsS2cFramer()
|
|
private readonly packetEmitter = new Emitter<Uint8Array>()
|
|
private readonly stateEmitter = new Emitter<D2gsSessionState>()
|
|
private readonly errorEmitter = new Emitter<Error>()
|
|
private readonly closeEmitter = new Emitter<CloseReason>()
|
|
|
|
private stateValue: D2gsSessionState = 'connecting'
|
|
private stopPingTimer: (() => void) | undefined
|
|
private lastPingSentMs = 0
|
|
private rttMsValue = 0
|
|
private bytesInValue = 0
|
|
private bytesOutValue = 0
|
|
private packetsInValue = 0
|
|
private packetsOutValue = 0
|
|
private enterGameSent = false
|
|
|
|
constructor(options: D2gsSessionOptions) {
|
|
this.stream = options.stream
|
|
this.logon = options.logon
|
|
this.clock = options.clock ?? defaultClock
|
|
this.tap = options.tap
|
|
this.pingIntervalMs = options.pingIntervalMs ?? 5000
|
|
|
|
this.stream.onData((chunk) => {
|
|
this.bytesInValue += chunk.byteLength
|
|
try {
|
|
const packets = this.framer.push(chunk)
|
|
for (const pkt of packets) {
|
|
this.packetsInValue++
|
|
this.tap?.record({
|
|
t: this.clock.now(),
|
|
conn: 'game',
|
|
dir: 's2c',
|
|
raw: pkt.bytes,
|
|
packetId: pkt.packetId,
|
|
})
|
|
this.handleIncomingPacket(pkt.bytes)
|
|
}
|
|
} catch (err) {
|
|
this.errorEmitter.emit(err instanceof Error ? err : new Error(String(err)))
|
|
}
|
|
})
|
|
|
|
this.stream.onClose((reason) => {
|
|
this.stopPing()
|
|
this.setState('closed')
|
|
this.closeEmitter.emit(reason)
|
|
if (reason.kind === 'error') {
|
|
this.errorEmitter.emit(reason.error)
|
|
}
|
|
})
|
|
}
|
|
|
|
get state(): D2gsSessionState {
|
|
return this.stateValue
|
|
}
|
|
|
|
get rttMs(): number {
|
|
return this.rttMsValue
|
|
}
|
|
|
|
get stats(): {
|
|
readonly rttMs: number
|
|
readonly bytesIn: number
|
|
readonly bytesOut: number
|
|
readonly packetsIn: number
|
|
readonly packetsOut: number
|
|
} {
|
|
return {
|
|
rttMs: this.rttMsValue,
|
|
bytesIn: this.bytesInValue,
|
|
bytesOut: this.bytesOutValue,
|
|
packetsIn: this.packetsInValue,
|
|
packetsOut: this.packetsOutValue,
|
|
}
|
|
}
|
|
|
|
onPacket(cb: (pkt: Uint8Array) => void): () => void {
|
|
return this.packetEmitter.on(cb)
|
|
}
|
|
|
|
onState(cb: (state: D2gsSessionState) => void): () => void {
|
|
return this.stateEmitter.on(cb)
|
|
}
|
|
|
|
onError(cb: (err: Error) => void): () => void {
|
|
return this.errorEmitter.on(cb)
|
|
}
|
|
|
|
onClose(cb: (reason: CloseReason) => void): () => void {
|
|
return this.closeEmitter.on(cb)
|
|
}
|
|
|
|
sendRawPacket(pkt: Uint8Array): void {
|
|
if (this.stateValue === 'closed') return
|
|
this.bytesOutValue += pkt.byteLength
|
|
this.packetsOutValue++
|
|
this.tap?.record({
|
|
t: this.clock.now(),
|
|
conn: 'game',
|
|
dir: 'c2s',
|
|
raw: pkt,
|
|
packetId: pkt[0],
|
|
})
|
|
this.stream.write(pkt)
|
|
}
|
|
|
|
sendPing(): void {
|
|
const now = Math.floor(this.clock.now())
|
|
this.lastPingSentMs = now
|
|
this.sendRawPacket(encodeC2sPing(now >>> 0, 0))
|
|
}
|
|
|
|
leaveAndClose(): void {
|
|
this.stopPing()
|
|
if (this.stateValue !== 'closed') {
|
|
try {
|
|
this.sendRawPacket(encodeC2sLeaveGame())
|
|
} catch {
|
|
// Ignore write error on leave
|
|
}
|
|
}
|
|
this.setState('closed')
|
|
this.stream.close()
|
|
}
|
|
|
|
private handleIncomingPacket(pkt: Uint8Array): void {
|
|
const op = pkt[0]!
|
|
if (op === 0xaf && this.stateValue === 'connecting') {
|
|
// Server sent [0xAF, 0x01]; reply with 37-byte 0x68 GameLogon
|
|
this.setState('logonSent')
|
|
this.sendRawPacket(encodeC2sGameLogon(this.logon))
|
|
} else if (op === 0x02 && !this.enterGameSent) {
|
|
// Server sent 0x02 GameLogonSuccess; send 0x6B EnterGame and start 5s Ping loop
|
|
this.enterGameSent = true
|
|
this.setState('entering')
|
|
this.sendRawPacket(encodeC2sEnterGame())
|
|
this.sendPing()
|
|
this.startPing()
|
|
} else if (op === 0x04 || (op === 0x03 && this.stateValue === 'entering')) {
|
|
if (this.stateValue !== 'ingame') {
|
|
this.setState('ingame')
|
|
}
|
|
} else if (op === 0x8f) {
|
|
if (this.lastPingSentMs > 0) {
|
|
this.rttMsValue = Math.max(0, Math.floor(this.clock.now()) - this.lastPingSentMs)
|
|
}
|
|
}
|
|
this.packetEmitter.emit(pkt)
|
|
}
|
|
|
|
private startPing(): void {
|
|
this.stopPing()
|
|
this.stopPingTimer = this.clock.setInterval(() => {
|
|
if (this.stateValue !== 'closed') {
|
|
this.sendPing()
|
|
}
|
|
}, this.pingIntervalMs)
|
|
}
|
|
|
|
private stopPing(): void {
|
|
if (this.stopPingTimer) {
|
|
this.stopPingTimer()
|
|
this.stopPingTimer = undefined
|
|
}
|
|
}
|
|
|
|
private setState(next: D2gsSessionState): void {
|
|
if (this.stateValue !== next) {
|
|
this.stateValue = next
|
|
this.stateEmitter.emit(next)
|
|
}
|
|
}
|
|
}
|