diablo2-web/src/netproto/index.ts

434 lines
12 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 {
BncsCreateAccountError,
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 {
BncsCreateAccountStatus,
BncsOpcode,
decodeBncsS2cPacket,
encodeBncsCreateAccount2,
encodeBncsNull,
} from './bncs/packets.ts'
export { BNCS_KEEPALIVE_INTERVAL_MS } from './bncs/session.ts'
export { McpFramer } from './mcp/framing.ts'
export { decodeMcpS2cPacket } from './mcp/packets.ts'
export { validateC2sPacket } from './d2gs/framing-c2s.ts'
export { encodeC2sChat, encodeC2sOverheadChat } from './d2gs/c2s/chat.ts'
export {
ClickButtonId,
encodeC2sClickButton,
encodeC2sInteract,
encodeC2sResurrect,
encodeC2sSwapWeapons,
encodeC2sWaypointGo,
} from './d2gs/c2s/interact.ts'
export {
encodeC2sBeltPut,
encodeC2sBeltRemove,
encodeC2sBeltSwap,
encodeC2sBeltUse,
encodeC2sDropGold,
encodeC2sIdentifyItem,
encodeC2sItemDrop,
encodeC2sItemEquip,
encodeC2sItemPickup,
encodeC2sItemPlace,
encodeC2sItemSwap,
encodeC2sItemToCursor,
encodeC2sItemUnequip,
encodeC2sItemUse,
encodeC2sMercItem,
encodeC2sScrollToBook,
encodeC2sSocketItem,
encodeC2sSwapBodyItem,
encodeC2sSwapContainerItem,
encodeC2sSwapEquippedItem,
} from './d2gs/c2s/items.ts'
export {
encodeC2sMove,
encodeC2sMoveToUnit,
encodeC2sWarpTo,
} from './d2gs/c2s/movement.ts'
export {
NPC_BUY_FLAG_GAMBLE,
NPC_BUY_FLAG_SHIFT_FILL,
NPC_REPAIR_ALL_FLAG,
NpcActionId,
encodeC2sHireMerc,
encodeC2sNpcBuy,
encodeC2sNpcCancel,
encodeC2sNpcEntityAction,
encodeC2sNpcIdentifyAll,
encodeC2sNpcInit,
encodeC2sNpcMercHire,
encodeC2sNpcMercResurrect,
encodeC2sNpcRepairAll,
encodeC2sNpcRepairItem,
encodeC2sNpcResurrectMerc,
encodeC2sNpcSell,
encodeC2sQuestMessage,
type NpcBuyParams,
type NpcRepairItemParams,
type NpcSellParams,
} from './d2gs/c2s/npc.ts'
export {
encodeC2sHostile,
encodeC2sParty,
encodeC2sPartyAction,
} from './d2gs/c2s/party.ts'
export {
encodeC2sEnterGame,
encodeC2sGameLogon,
encodeC2sLeaveGame,
encodeC2sPing,
type D2gsGameLogonParams,
} from './d2gs/c2s/session.ts'
export {
encodeC2sAddSkillPoint,
encodeC2sAddStatPoint,
encodeC2sSelectSkill,
encodeC2sSkillAt,
encodeC2sSkillOnUnit,
} from './d2gs/c2s/skills.ts'
export { encodeC2sTrade } from './d2gs/c2s/trade.ts'
export { C2S_PACKET_SIZES, S2C_PACKET_SIZES } from './d2gs/tables/s2c-sizes.ts'
export {
D2GS_S2C_REGISTRY,
decodeD2gsS2cPacket,
decodeD2gsServerPacket,
encodeClientCommand,
encodeD2gsClientCommand,
type D2gsDecodeContext,
type D2gsS2cRegistryEntry,
} from './d2gs/registry.ts'
export {
D2gsS2cFramer,
resolveS2cPacketSize,
} from './d2gs/framing-s2c.ts'
export {
DEFAULT_MONSTER_COMP_BIT_WIDTHS,
MONSTATS2_COMP_BIT_WIDTHS_BY_HCIDX,
MONSTATS_HCIDX_COUNT,
getMonsterAnimCompBitWidths,
} from './d2gs/s2c/monstats2-comp-bits.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 }
}