diff --git a/packages/bintools/src/index.ts b/packages/bintools/src/index.ts index a168ce2..936f441 100644 --- a/packages/bintools/src/index.ts +++ b/packages/bintools/src/index.ts @@ -1 +1,2 @@ export { LangReader, LangNode } from './lang/lang.reader'; +export { MonsterReader, MonsterNode } from './monster/monster.stat.reader'; diff --git a/packages/bintools/src/monster/monster.stat.reader.ts b/packages/bintools/src/monster/monster.stat.reader.ts index 88eb779..35e39c2 100644 --- a/packages/bintools/src/monster/monster.stat.reader.ts +++ b/packages/bintools/src/monster/monster.stat.reader.ts @@ -1,10 +1,12 @@ -import { bp } from 'binparse'; +import { bp, StrutInfer } from 'binparse'; -const Monster = bp.object('Monster', { +const MonsterParser = bp.object('Monster', { id: bp.lu16, baseId: bp.lu16, baseNextId: bp.lu16, + /** Name index, lookup inside of lang index */ name: bp.lu16, + /** Description lookup inside of lang index */ description: bp.lu16, unk1: bp.lu16, flags: bp.lu32, @@ -12,7 +14,9 @@ const Monster = bp.object('Monster', { unk100: bp.skip(424 - 20), }); +export type MonsterNode = StrutInfer; + export const MonsterReader = bp.object('Monsters', { count: bp.variable('count', bp.lu32), - monsters: bp.array('Monster', Monster, 'count'), + monsters: bp.array('Monster', MonsterParser, 'count'), }); diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index 1ae64dd..007fad8 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -2,10 +2,12 @@ import { Diablo2PacketFactory } from '@diablo2/packets'; import { PacketsPod } from '@diablo2/packets/build/packets-pod'; import { Diablo2GameSession } from './game.state'; import { Logger } from './log.interface'; -import { Diablo2Lang, loadLangFiles } from './mpq/mpq.loader'; +import { Diablo2MpqLang, Diablo2MpqMonsters, loadLangFiles, loadMonsterFiles } from './mpq/mpq.loader'; export class Diablo2Client { - lang: Diablo2Lang; + lang: Diablo2MpqLang; + monsters: Diablo2MpqMonsters; + clientToServer = new Diablo2PacketFactory(); serverToClient = new Diablo2PacketFactory(); @@ -17,6 +19,7 @@ export class Diablo2Client { async init(path: string, logger: Logger): Promise { logger.info({ path }, 'Reading game data'); this.lang = await loadLangFiles(path, logger); + this.monsters = await loadMonsterFiles(path, this.lang, logger); } startSession(log: Logger): Diablo2GameSession { diff --git a/packages/core/src/game.state.ts b/packages/core/src/game.state.ts index 9d71013..c3413c8 100644 --- a/packages/core/src/game.state.ts +++ b/packages/core/src/game.state.ts @@ -1,10 +1,7 @@ -import { ItemActionType, ItemQuality } from '@diablo2/data'; -import * as P from '@diablo2/packets/build/packets-pod/server'; import { ulid } from 'ulid'; import { Diablo2Client } from './client'; import { Logger } from './log.interface'; import { Diablo2PacketParser } from './packet.parser'; -import * as c from 'ansi-colors'; export class Diablo2GameSession { id: string = ulid().toLowerCase(); @@ -17,37 +14,6 @@ export class Diablo2GameSession { this.log = log; this.client = client; this.parser = new Diablo2PacketParser(client); - // this.parser.events.on('*', (pkt) => { - // // if (PacketIgnore.has(pkt.packet.id)) return; - // // if (PacketTrace.has(pkt.packet.id)) return console.log(pkt); - // // console.log(pkt, pkt.packet.name); - // }); - - this.parser.on(P.ItemActionWorld, (pkt) => { - if (pkt.action.id == ItemActionType.AddToGround) { - if (pkt.code == 'gld') return; - if (pkt.flags.isSimpleItem) return; - - const obj = JSON.parse(JSON.stringify({ sockets: pkt.sockets, quality: pkt.quality?.name, def: pkt.defense })); - switch (pkt.quality.id) { - case ItemQuality.Inferior: - case ItemQuality.Normal: - case ItemQuality.Superior: - case ItemQuality.Magic: - case ItemQuality.Rare: - console.log(pkt.code, client.lang.get(pkt.code), obj); - break; - case ItemQuality.Set: - console.log(c.green(pkt.code), client.lang.get(pkt.code), obj); - break; - case ItemQuality.Unique: - console.log(c.yellow(pkt.code), client.lang.get(pkt.code), obj); - break; - } - } else { - // console.log(pkt); - } - }); } onPacket(direction: 'in' | 'out', bytes: Buffer): void { diff --git a/packages/core/src/mpq/mpq.loader.ts b/packages/core/src/mpq/mpq.loader.ts index 08e378c..b3fff75 100644 --- a/packages/core/src/mpq/mpq.loader.ts +++ b/packages/core/src/mpq/mpq.loader.ts @@ -1,9 +1,9 @@ -import { LangNode, LangReader } from '@diablo2/bintools'; +import { LangNode, LangReader, MonsterNode, MonsterReader } from '@diablo2/bintools'; import { promises as fs } from 'fs'; import * as path from 'path'; import { Logger } from '../log.interface'; -export class Diablo2Lang { +export class Diablo2MpqLang { map: Map = new Map(); index: LangNode[] = []; @@ -20,9 +20,26 @@ export class Diablo2Lang { } } +export class Diablo2MpqMonsters { + lang: Diablo2MpqLang; + map: Map = new Map(); + + constructor(lang: Diablo2MpqLang) { + this.lang = lang; + } + + add(mon: MonsterNode): void { + this.map.set(mon.id, mon); + } + + get(monsterId: number): MonsterNode | undefined { + return this.map.get(monsterId); + } +} + /** Load all the `.tbl` files from a extracted MPQ to get item names */ -export async function loadLangFiles(basePath: string, logger: Logger): Promise { - const lang = new Diablo2Lang(); +export async function loadLangFiles(basePath: string, logger: Logger): Promise { + const lang = new Diablo2MpqLang(); if (basePath == null || basePath == '') return lang; // TODO these lang files need to be extracted, would be good to load from the MPQ directly @@ -30,7 +47,6 @@ export async function loadLangFiles(basePath: string, logger: Logger): Promise LangReader.LangFiles.find((lf) => f.toLowerCase().startsWith(lf))); - for (const langFile of langFiles) { const startTime = Date.now(); const bytes = await fs.readFile(path.join(LangPath, langFile)); @@ -38,7 +54,34 @@ export async function loadLangFiles(basePath: string, logger: Logger): Promise { + const mon = new Diablo2MpqMonsters(lang); + if (basePath == null || basePath == '') return mon; + + // TODO these lang files need to be extracted, would be good to load from the MPQ directly + const BinPath = `${basePath}/mpq/data/global/excel`; + const binFolderFiles = await fs.readdir(BinPath); + + const monStatFile = binFolderFiles.find((f) => f.toLowerCase() == 'monstats.bin'); + if (monStatFile) { + const startTime = Date.now(); + const bytes = await fs.readFile(path.join(BinPath, monStatFile)); + const monItems = MonsterReader.raw(bytes as any); + const duration = Date.now() - startTime; + + for (const monster of monItems.monsters) mon.add(monster); + logger.info({ file: monStatFile, records: monItems.monsters.length, duration }, 'MPQ:LoadMonster'); + } + + return mon; +} diff --git a/packages/packets/src/packets-pod/__tests__/npc.test.ts b/packages/packets/src/packets-pod/__tests__/npc.test.ts new file mode 100644 index 0000000..6db9cf7 --- /dev/null +++ b/packages/packets/src/packets-pod/__tests__/npc.test.ts @@ -0,0 +1,34 @@ +import o from 'ospec'; +import { NpcAssign } from '../server'; + +o.spec('NpcAssign', () => { + o('should load a hungry dead', () => { + const npc = NpcAssign.parse(Buffer.from('acc400000006005514fd13801131400400', 'hex'), { + startOffset: 0, + offset: 1, + }); + o(npc.unitId).equals(196); + o(npc.x).equals(5205); + o(npc.y).equals(5117); + o(npc.life).equals(128); + }); + + o('should parse a low health fallen', () => { + const npc = NpcAssign.parse(Buffer.from('ace70000001300b114e4102a1111c80000', 'hex'), { + startOffset: 0, + offset: 1, + }); + o(npc.life).equals(42); + }); + o('should parse super unique Rakanishu', () => { + const npc = NpcAssign.parse(Buffer.from('acc500000014003b14eb138019110480660020c2c002207219', 'hex'), { + startOffset: 0, + offset: 1, + }); + + o(npc.x).equals(5179); + o(npc.y).equals(5099); + o(npc.life).equals(128); + o(npc.code).equals(20); + }); +}); diff --git a/packages/packets/src/packets-pod/server.ts b/packages/packets/src/packets-pod/server.ts index c75766c..d3fb30d 100644 --- a/packages/packets/src/packets-pod/server.ts +++ b/packages/packets/src/packets-pod/server.ts @@ -396,9 +396,11 @@ export const StateEnd = Diablo2Packet.create(0xa9, 'StateEnd', { export const NpcHeal = Diablo2Packet.create(0xab, 'NpcHeal', { unitType: bp.u8, unitId: bp.lu32, unitLife: bp.u8 }); export const NpcAssign = Diablo2Packet.create(0xac, 'NpcAssign', { unitId: bp.lu32, + /** NpcId */ code: bp.lu16, x: bp.lu16, y: bp.lu16, + /** Health values are 0 - 128 */ life: bp.u8, packetLength: bp.variable('count', bp.u8), stateEffects: bp.array('StateEffects', bp.u8, 'count', true), diff --git a/packages/sniffer/src/example/item.tracker.ts b/packages/sniffer/src/example/item.tracker.ts new file mode 100644 index 0000000..e4b4c57 --- /dev/null +++ b/packages/sniffer/src/example/item.tracker.ts @@ -0,0 +1,37 @@ +import { Diablo2GameSession } from '@diablo2/core'; +import { ItemActionType, ItemQuality } from '@diablo2/data'; +import { ItemActionWorld } from '@diablo2/packets/build/packets-pod/server'; +import * as c from 'ansi-colors'; +import { Diablo2PacketSniffer } from '../sniffer'; + +/** Track all items dropped onto the ground */ +export function sniffItems(sniffer: Diablo2PacketSniffer): void { + sniffer.onNewGame((game: Diablo2GameSession) => { + console.log('NewSessionStarted'); + game.parser.on(ItemActionWorld, (pkt) => { + // Ignore items being moved around + if (pkt.action.id !== ItemActionType.AddToGround) return; + // Ignore gold + if (pkt.code == 'gld') return; + // Ignore things like scrolls/ health potion + if (pkt.flags.isSimpleItem) return; + + const obj = JSON.parse(JSON.stringify({ sockets: pkt.sockets, quality: pkt.quality?.name, def: pkt.defense })); + switch (pkt.quality.id) { + case ItemQuality.Inferior: + case ItemQuality.Normal: + case ItemQuality.Superior: + case ItemQuality.Magic: + case ItemQuality.Rare: + console.log(pkt.code, sniffer.client.lang.get(pkt.code), obj); + break; + case ItemQuality.Set: + console.log(c.green(pkt.code), sniffer.client.lang.get(pkt.code), obj); + break; + case ItemQuality.Unique: + console.log(c.yellow(pkt.code), sniffer.client.lang.get(pkt.code), obj); + break; + } + }); + }); +} diff --git a/packages/sniffer/src/example/npc.tracker.ts b/packages/sniffer/src/example/npc.tracker.ts new file mode 100644 index 0000000..04d5fff --- /dev/null +++ b/packages/sniffer/src/example/npc.tracker.ts @@ -0,0 +1,15 @@ +import { Diablo2GameSession } from '@diablo2/core'; +import { NpcAssign } from '@diablo2/packets/build/packets-pod/server'; +import * as c from 'ansi-colors'; +import { Diablo2PacketSniffer } from '../sniffer'; + +/** Track all NPCs that are being reported */ +export function sniffNpc(sniffer: Diablo2PacketSniffer): void { + sniffer.onNewGame((game: Diablo2GameSession) => { + game.parser.on(NpcAssign, (npc) => { + const npcInfo = game.client.monsters.get(npc.code); + const monsterName = npcInfo == null ? 'Unknown' : game.client.lang.get(npcInfo.name); + console.log(c.red('Npc'), monsterName, `@ ${npc.x},${npc.y}`); + }); + }); +} diff --git a/packages/sniffer/src/index.ts b/packages/sniffer/src/index.ts index 5d5a3b7..6cf63d6 100644 --- a/packages/sniffer/src/index.ts +++ b/packages/sniffer/src/index.ts @@ -1,5 +1,7 @@ import { existsSync } from 'fs'; import 'source-map-support/register'; +import { sniffItems } from './example/item.tracker'; +import { sniffNpc } from './example/npc.tracker'; import { Log } from './logger'; import { Diablo2PacketSniffer, findLocalIps } from './sniffer'; @@ -41,6 +43,11 @@ async function main(): Promise { const sniffer = new Diablo2PacketSniffer(networkAdapter, gamePath); sniffer.isWriteDump = isWriteDump > 0; + // Track items being dropped onto the ground + sniffItems(sniffer); + + sniffNpc(sniffer); + await sniffer.start(Log); } diff --git a/packages/sniffer/src/replay.tracker.ts b/packages/sniffer/src/replay.tracker.ts new file mode 100644 index 0000000..32c9cda --- /dev/null +++ b/packages/sniffer/src/replay.tracker.ts @@ -0,0 +1,30 @@ +import { createWriteStream, WriteStream } from 'fs'; + +/** Automatically close a write stream after 5 seconds of inactivity */ +export class AutoClosingStream { + _stream: WriteStream | null; + _streamClose: NodeJS.Timer | null; + fileName: string; + closeTimeout: number; + + constructor(fileName: string, closeTimeout = 5 * 1000) { + this.fileName = fileName; + this.closeTimeout = closeTimeout; + } + + closeStream = (): void => { + this._stream?.close(); + this._stream = null; + this._streamClose = null; + }; + + write(obj: unknown): void { + if (this._streamClose != null) clearTimeout(this._streamClose); + + if (this._stream == null) { + this._stream = createWriteStream(this.fileName, { flags: 'a' }); + } + this._streamClose = setTimeout(this.closeStream, 5 * 1000); + this._stream.write(JSON.stringify(obj) + '\n'); + } +} diff --git a/packages/sniffer/src/sniffer.ts b/packages/sniffer/src/sniffer.ts index bdf605e..7dbc277 100644 --- a/packages/sniffer/src/sniffer.ts +++ b/packages/sniffer/src/sniffer.ts @@ -1,8 +1,9 @@ import { Diablo2Client, Diablo2GameSession } from '@diablo2/core'; -import { createWriteStream, WriteStream } from 'fs'; +import { EventEmitter } from 'events'; import { networkInterfaces } from 'os'; import * as pcap from 'pcap'; import { LogType } from './logger'; +import { AutoClosingStream } from './replay.tracker'; export function findLocalIps(): { address: string; interface: string }[] { const output: { address: string; interface: string }[] = []; @@ -25,10 +26,11 @@ export interface PacketLine { bytes: string; } -export class Diablo2PacketSniffer { +export class Diablo2PacketSniffer extends EventEmitter { networkAdapter: string; localIps: { address: string; interface: string }[]; session: pcap.PcapSession; + // No typings exist for this? tcpTracker: any; /** Resolve the exit promise to exit the pcap loop */ @@ -40,6 +42,7 @@ export class Diablo2PacketSniffer { gamePath: string; constructor(networkAdapter: string, gamePath: string) { + super(); this.networkAdapter = networkAdapter; this.localIps = findLocalIps(); this.tcpTracker = new pcap.TCPTracker(); @@ -47,17 +50,27 @@ export class Diablo2PacketSniffer { this.gamePath = gamePath; } - onData( - direction: 'in' | 'out', - data: Buffer, - session: Diablo2GameSession, - stream: WriteStream | null, - log: LogType, - ): void { + /** Dump packets into a output stream if requested */ + fileStreams = new Map(); + private getTraceStream(sessionId: string): AutoClosingStream { + let existingStream = this.fileStreams.get(sessionId); + if (existingStream == null) { + existingStream = new AutoClosingStream(`./replay-${sessionId}.ndjson`); + this.fileStreams.set(sessionId, existingStream); + } + return existingStream; + } + + /** When a new game session is started */ + onNewGame(cb: (game: Diablo2GameSession) => void): EventEmitter { + return this.on('session', cb); + } + + private onData(direction: 'in' | 'out', data: Buffer, session: Diablo2GameSession, log: LogType): void { const inputId = session.parser.inPacketRawCount; - if (stream) { + if (this.isWriteDump) { const logLine: PacketLine = { direction, bytes: data.toString('hex'), time: Date.now() }; - stream.write(JSON.stringify(logLine) + '\n'); + this.getTraceStream(session.id).write(logLine); } try { session.onPacket(direction, data); @@ -81,9 +94,7 @@ export class Diablo2PacketSniffer { } const gameSession = this.client.startSession(log); - gameSession.parser.inputBuffer.reset(); - let stream: WriteStream | null = null; - if (this.isWriteDump) stream = createWriteStream(`./replay-${gameSession.id}.ndjson`, { flags: 'a' }); + this.emit('session', gameSession); const directions: { send: 'in' | 'out'; recv: 'in' | 'out' } = { send: 'in', recv: 'out' }; /** is session.src really the source? */ @@ -97,15 +108,14 @@ export class Diablo2PacketSniffer { // TODO why do I need to clone the buffer? // Sometime the data in the buffer is just wrong? session.on('data send', (session: any, bytes: Buffer) => { - this.onData(directions.send, Buffer.from(bytes.toJSON().data), gameSession, stream, log); + this.onData(directions.send, Buffer.from(bytes.toJSON().data), gameSession, log); }); session.on('data recv', (session: any, bytes: Buffer) => { - this.onData(directions.recv, Buffer.from(bytes.toJSON().data), gameSession, stream, log); + this.onData(directions.recv, Buffer.from(bytes.toJSON().data), gameSession, log); }); session.on('end', () => { log.info({ src: session.src, dst: session.dst }, 'Session Close'); - stream?.close(); }); });