336 lines
10 KiB
TypeScript
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 }
|
|
}
|