diablo2-web/src/netproto/index.ts

336 lines
10 KiB
TypeScript

/**
* Public exports of `src/netproto/index.ts` (PROJECT.md § Interface Contracts).
* Exposes ONLY protocol-neutral domain types, flow orchestration, and transport resolvers.
*/
export type { ByteStream, CloseReason } from './transport/byte-stream.ts'
export type {
EndpointKind,
EndpointResolver,
EndpointRole,
MixedResolverOptions,
NetChannel,
TcpOpener,
TcpResolverOptions,
WsBridgeResolverOptions,
} from './transport/endpoint.ts'
export type {
D2OnlineConfig,
D2OnlineConfig as OnlineConfig,
D2OnlineSessionKind,
D2OnlineSessionLost,
D2OnlineSessionLostCause,
D2OnlineState,
} from './flow/config.ts'
export type { D2OnlineFlow } from './flow/online-flow.ts'
export type { GameServerAdapter, GameServerStats } from './domain/game-server-adapter.ts'
export type { ServerEvent } from './domain/server-event.ts'
export type { ClientCommand } from './domain/client-command.ts'
export type {
CharClassId,
Hand,
ItemActionId,
ItemBaseMeta,
ItemBaseStatEntry,
ItemContainer,
ItemData,
ItemDataTables,
ItemQuality,
ItemQualityLevel,
ItemStat,
ItemStatCostField,
ItemStatCostMeta,
Pt,
SkillLevel,
SubTile,
UnitId,
UnitKind,
UnitRef,
UnitSnapshot,
UnitType,
UnitTypeId,
UnitTypeName,
} from './domain/ids.ts'
export type {
CharCreateSpec,
CharSummary,
ChatEvent,
ChatEventKind,
GameCreateSpec,
GameInfoDetail,
GameListEntry,
GameSummary,
RealmInfo,
RealmSummary,
} from './domain/lobby.ts'
export type {
D2CapHeader,
D2CapRecord,
PacketConn,
PacketDirection,
PacketTap,
PacketTapConn,
PacketTapDir,
PacketTapEntry,
} from './core/packet-tap.ts'
export type { Clock } from './core/clock.ts'
export type { SupportedTextEncoding, TextCodec, TextEncoding } from './core/text-codec.ts'
export {
CHAR_CLASS_NAMES,
UNIT_TYPE_FROM_ID,
UNIT_TYPE_TO_ID,
asSubTile,
asUnitId,
unitTypeFromId,
unitTypeToId,
} from './domain/ids.ts'
export { createD2OnlineFlow } from './flow/online-flow.ts'
export {
createMixedResolver,
createTcpResolver,
createWsBridgeResolver,
directTcpResolver,
getActiveWssBnetConnections,
mixedResolver,
registerDefaultTcpOpener,
tcpResolver,
wsBridgeResolver,
} from './transport/endpoint.ts'
export { MemoryStream, createMemoryStreamPair } from './transport/memory-stream.ts'
export { BitReader } from './core/bit-reader.ts'
export { BitWriter } from './core/bit-writer.ts'
export { ByteReader } from './core/byte-reader.ts'
export { ByteWriter } from './core/byte-writer.ts'
export { TypedEmitter } from './core/emitter.ts'
export { ClosedError, NetprotoError, ProtocolError, TimeoutError, type NetprotoErrorOptions } from './core/errors.ts'
export {
InMemoryPacketTap,
MemoryPacketTap,
bytesToHex,
formatD2CapEntry,
formatD2CapHeader,
hexToBytes,
parseD2CapJsonl,
} from './core/packet-tap.ts'
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 { McpFramer } from './mcp/framing.ts'
export { decodeMcpS2cPacket } from './mcp/packets.ts'
export { validateC2sPacket } from './d2gs/framing-c2s.ts'
export { ClickButtonId } from './d2gs/c2s/interact.ts'
export { NpcActionId, NPC_BUY_FLAG_GAMBLE, NPC_BUY_FLAG_SHIFT_FILL, NPC_REPAIR_ALL_FLAG } from './d2gs/c2s/npc.ts'
export { C2S_PACKET_SIZES } from './d2gs/tables/s2c-sizes.ts'
export {
D2GS_S2C_REGISTRY,
decodeD2gsS2cPacket,
encodeD2gsClientCommand,
type D2gsDecodeContext,
type D2gsS2cRegistryEntry,
} from './d2gs/registry.ts'
export {
D2gsS2cFramer,
resolveS2cPacketSize,
} from './d2gs/framing-s2c.ts'
import { ByteWriter } from './core/byte-writer.ts'
import { resolveS2cPacketSize } from './d2gs/framing-s2c.ts'
import {
D2GS_S2C_REGISTRY,
decodeD2gsS2cPacket,
encodeD2gsClientCommand,
} from './d2gs/registry.ts'
import type { ClientCommand } from './domain/client-command.ts'
import { unitTypeToId, type ItemDataTables, type UnitType } from './domain/ids.ts'
import type { ServerEvent } from './domain/server-event.ts'
const EMPTY_ITEM_DATA_TABLES: ItemDataTables = {
itemStatCost: new Map(),
armor: new Map(),
weapons: new Map(),
misc: new Map(),
getStatMeta: () => undefined,
getStatCost: () => undefined,
getItemBaseMeta: () => undefined,
getItemMeta: () => undefined,
}
export function clientCommandToC2sPackets(cmd: ClientCommand | Record<string, any>): readonly Uint8Array[] {
if (!cmd || typeof cmd !== 'object') return []
const c = cmd as Record<string, any>
if (c.type === 'PlayerMove') {
return encodeD2gsClientCommand({
type: 'RunToLocation',
x: Number(c.targetX ?? c.x ?? 0),
y: Number(c.targetY ?? c.y ?? 0),
})
}
try {
return encodeD2gsClientCommand(cmd as ClientCommand) ?? []
} catch {
return []
}
}
export function serverEventToS2cPackets(ev: ServerEvent | Record<string, any>): readonly Uint8Array[] {
if (!ev || typeof ev !== 'object') return []
const e = ev as Record<string, any>
switch (e.type) {
case 'ActDataLoaded':
case 'LoadAct': {
const w = new ByteWriter(12)
w.u8(0x03)
w.u8(Number(e.act ?? 0) & 0xff)
w.u32LE(Number(e.mapSeed ?? 0) >>> 0)
w.u16LE(Number(e.townAreaId ?? e.areaId ?? 1) & 0xffff)
w.u32LE(Number(e.unknown ?? e.objectSeed ?? 0) >>> 0)
return [w.toUint8Array()]
}
case 'PlayerAssign':
case 'EntitySpawn':
case 'UnitAssign': {
const uType = e.unit ? unitTypeToId(e.unit.unitType) : Number(e.unitType ?? 0)
const uId = Number(e.unit?.id ?? e.unitId ?? 1) >>> 0
const classId = Number(e.unit?.classId ?? e.classId ?? 0)
const x = Number(e.unit?.x ?? e.x ?? 0) & 0xffff
const y = Number(e.unit?.y ?? e.y ?? 0) & 0xffff
if (uType === 0) {
const w = new ByteWriter(26)
w.u8(0x59)
w.u32LE(uId)
w.u8(classId & 0xff)
w.fixedString(String(e.unit?.name ?? e.name ?? 'Hero'), 16)
w.u16LE(x)
w.u16LE(y)
return [w.toUint8Array()]
}
const w = new ByteWriter(14)
w.u8(0x51)
w.u8(uType & 0xff)
w.u32LE(uId)
w.u16LE(classId & 0xffff)
w.u16LE(x)
w.u16LE(y)
w.u8(0)
w.u8(0)
return [w.toUint8Array()]
}
case 'UnitMove': {
const uType = typeof e.unitType === 'string' ? unitTypeToId(e.unitType as UnitType) : Number(e.unitType ?? 0)
const uId = Number(e.id ?? e.unitId ?? 1) >>> 0
const tx = Number(e.to?.x ?? e.targetX ?? e.x ?? 0) & 0xffff
const ty = Number(e.to?.y ?? e.targetY ?? e.y ?? 0) & 0xffff
const cx = Number(e.from?.x ?? e.currentX ?? e.x ?? tx) & 0xffff
const cy = Number(e.from?.y ?? e.currentY ?? e.y ?? ty) & 0xffff
const w = new ByteWriter(16)
w.u8(0x0f)
w.u8(uType & 0xff)
w.u32LE(uId)
w.u8(Number(e.moveType ?? 0x17) & 0xff)
w.u16LE(tx)
w.u16LE(ty)
w.u8(0)
w.u16LE(cx)
w.u16LE(cy)
return [w.toUint8Array()]
}
case 'EntityRemove':
case 'UnitRemove': {
const uType = typeof e.unitType === 'string' ? unitTypeToId(e.unitType as UnitType) : Number(e.unitType ?? 1)
const uId = Number(e.id ?? e.unitId ?? 0) >>> 0
const w = new ByteWriter(6)
w.u8(0x0a)
w.u8(uType & 0xff)
w.u32LE(uId)
return [w.toUint8Array()]
}
case 'ChatMessage':
case 'Chat': {
const speaker = String(e.speaker ?? e.from ?? 'Server')
const text = String(e.text ?? '')
const w = new ByteWriter(10 + speaker.length + 1 + text.length + 1)
w.u8(0x26)
w.u8(0x01)
w.u8(0x00)
w.u8(0x02)
w.u32LE(0)
w.u8(0x01)
w.u8(0x00)
w.cstring(speaker)
w.cstring(text)
return [w.toUint8Array()]
}
case 'SoundEvent':
case 'PlaySound': {
const uType = e.unit ? unitTypeToId(e.unit.unitType) : Number(e.unitType ?? 0)
const uId = Number(e.unit?.id ?? e.unitId ?? 1) >>> 0
const sound = Number(e.sound ?? e.soundId ?? 1) & 0xffff
const w = new ByteWriter(8)
w.u8(0x2c)
w.u8(uType & 0xff)
w.u32LE(uId)
w.u16LE(sound)
return [w.toUint8Array()]
}
default:
return [new Uint8Array([0x04])]
}
}
export function encodePacket(
pkt: Uint8Array | { readonly opcode?: number; readonly payload?: Uint8Array; readonly bytes?: Uint8Array },
): Uint8Array {
if (pkt instanceof Uint8Array) return pkt
if (pkt.bytes instanceof Uint8Array) return pkt.bytes
const payload = pkt.payload ?? new Uint8Array(0)
const out = new Uint8Array(1 + payload.byteLength)
out[0] = (pkt.opcode ?? 0) & 0xff
out.set(payload, 1)
return out
}
export function decodePacket(
raw: Uint8Array,
direction: 'S2C' | 'C2S' = 'S2C',
): { readonly ok: boolean; readonly opcode?: number; readonly event?: ServerEvent; readonly error?: string } {
if (!(raw instanceof Uint8Array) || raw.byteLength === 0) {
return { ok: false, error: 'Empty packet buffer' }
}
const opcode = raw[0]!
if (direction === 'S2C') {
const entry = D2GS_S2C_REGISTRY[opcode]
if (!entry) {
return { ok: false, error: `Unregistered S2C opcode 0x${opcode.toString(16).padStart(2, '0')}` }
}
try {
const expectedSize = resolveS2cPacketSize(raw, 0)
if (expectedSize === null || raw.byteLength < expectedSize) {
return { ok: false, error: `Truncated S2C packet 0x${opcode.toString(16).padStart(2, '0')}` }
}
const ev = decodeD2gsS2cPacket(raw.subarray(0, expectedSize), {
tables: EMPTY_ITEM_DATA_TABLES,
encoding: 'gbk',
rttMs: 0,
})
if (ev.type === 'Opaque') {
return { ok: false, error: `Opaque S2C packet 0x${opcode.toString(16).padStart(2, '0')}` }
}
return { ok: true, opcode, event: ev }
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : String(err) }
}
}
if (opcode === 0x00 || opcode > 0x6d) {
return { ok: false, error: `Unregistered C2S opcode 0x${opcode.toString(16).padStart(2, '0')}` }
}
return { ok: true, opcode }
}