import { spawn, spawnSync, type ChildProcess } from 'node:child_process'; import fs from 'node:fs'; import { getDb } from '../db/index.js'; import { PhotonPacketParser } from 'photon-packet-parser'; /** * Passive sniffer for the user's OWN Albion client traffic (UDP 5056). * Capture is done by spawning tshark (Wireshark CLI, which bundles Npcap on * Windows) so no native Node modules are needed; payloads are decoded with the * pure-JS Photon protocol parser. Read-only observation — the SBI-tolerated * category (same technique as the Albion Data Project client). No memory * reading, no injection, no automation. * * Consumers: * 1. Market orders: whenever the player opens a marketplace in game, the * auction responses contain order JSON — we detect them by shape (any * string parameter parsing as JSON with UnitPriceSilver), which survives * patches, and feed fresh firsthand prices into market_prices. * 2. Event-code debug log: counts photon event codes seen, so destiny-board * related codes can be identified in a live session and added later. */ const TSHARK_CANDIDATES = [ 'C:\\Program Files\\Wireshark\\tshark.exe', 'C:\\Program Files (x86)\\Wireshark\\tshark.exe', ]; /** Market LocationId -> city (protocol ids, not cluster ids). Editable if a patch changes them. */ const MARKET_LOCATIONS: Record = { '0007': 'Thetford', '7': 'Thetford', '1002': 'Lymhurst', '2004': 'Bridgewatch', '3005': 'Caerleon', '3008': 'Martlock', '4002': 'Fort Sterling', '5003': 'Brecilien', '3003': 'Black Market', }; export function findTshark(): string | null { for (const p of TSHARK_CANDIDATES) if (fs.existsSync(p)) return p; const which = spawnSync('where', ['tshark'], { encoding: 'utf8' }); const found = which.stdout?.split(/\r?\n/).find((l) => l.trim().endsWith('tshark.exe')); return found?.trim() ?? null; } export interface SnifferStatus { tsharkPath: string | null; running: boolean; packets: number; photonEvents: number; marketOrdersSeen: number; pricesStored: number; startedAt: string | null; lastError: string | null; eventCodeCounts: Record; recentOrders: CapturedOrder[]; } export interface CapturedOrder { item: string; city: string; quality: number; auctionType: string; unitPriceSilver: number; amount: number; seenAt: string; } interface AlbionOrder { UnitPriceSilver: number; Amount: number; AuctionType: string; ItemTypeId: string; EnchantmentLevel: number; QualityLevel: number; LocationId: string | number; } let proc: ChildProcess | null = null; const state = { packets: 0, photonEvents: 0, marketOrdersSeen: 0, pricesStored: 0, startedAt: null as string | null, lastError: null as string | null, eventCodeCounts: new Map(), recentOrders: [] as CapturedOrder[], }; export function listInterfaces(): { id: string; name: string }[] { const tshark = findTshark(); if (!tshark) return []; const res = spawnSync(tshark, ['-D'], { encoding: 'utf8', timeout: 15000 }); return (res.stdout ?? '') .split(/\r?\n/) .filter(Boolean) .map((line) => { const m = line.match(/^(\d+)\.\s+(.*)$/); return m ? { id: m[1], name: m[2] } : null; }) .filter((x): x is { id: string; name: string } => x != null); } function toItemId(o: AlbionOrder): string { if (o.ItemTypeId.includes('@')) return o.ItemTypeId; return o.EnchantmentLevel > 0 ? `${o.ItemTypeId}@${o.EnchantmentLevel}` : o.ItemTypeId; } /** Group captured orders into best sell (min) / best buy (max) and store them as fresh prices. */ function storeOrders(orders: AlbionOrder[]): void { const best = new Map(); for (const o of orders) { const city = MARKET_LOCATIONS[String(o.LocationId)]; if (!city) continue; const item = toItemId(o); const quality = Math.max(1, o.QualityLevel || 1); const price = o.UnitPriceSilver / 10000; // protocol prices are silver * 10000 const key = `${item}|${city}|${quality}`; const cur = best.get(key) ?? { sell: null, buy: null, item, city, quality }; if (o.AuctionType === 'offer') cur.sell = cur.sell == null ? price : Math.min(cur.sell, price); else if (o.AuctionType === 'request') cur.buy = cur.buy == null ? price : Math.max(cur.buy, price); best.set(key, cur); state.recentOrders.unshift({ item, city, quality, auctionType: o.AuctionType, unitPriceSilver: price, amount: o.Amount, seenAt: new Date().toISOString(), }); } state.recentOrders = state.recentOrders.slice(0, 50); const db = getDb(); const now = new Date().toISOString(); const upsert = db.prepare(` INSERT INTO market_prices (item, city, quality, sell_price_min, sell_min_date, buy_price_max, buy_max_date, fetched_at) VALUES (@item, @city, @quality, @sell, @sellDate, @buy, @buyDate, @now) ON CONFLICT(item, city, quality) DO UPDATE SET sell_price_min = COALESCE(excluded.sell_price_min, market_prices.sell_price_min), sell_min_date = CASE WHEN excluded.sell_price_min IS NOT NULL THEN excluded.sell_min_date ELSE market_prices.sell_min_date END, buy_price_max = COALESCE(excluded.buy_price_max, market_prices.buy_price_max), buy_max_date = CASE WHEN excluded.buy_price_max IS NOT NULL THEN excluded.buy_max_date ELSE market_prices.buy_max_date END, fetched_at = excluded.fetched_at `); db.transaction(() => { for (const b of best.values()) { upsert.run({ item: b.item, city: b.city, quality: b.quality, sell: b.sell, sellDate: b.sell != null ? now : null, buy: b.buy, buyDate: b.buy != null ? now : null, now, }); state.pricesStored++; } })(); } /** Recursively hunt for market-order JSON strings in photon parameters. */ function extractOrders(value: unknown, out: AlbionOrder[], depth = 0): void { if (depth > 4 || value == null) return; if (typeof value === 'string') { if (value.length > 20 && value.includes('UnitPriceSilver')) { try { const parsed = JSON.parse(value); if (parsed && typeof parsed === 'object' && 'UnitPriceSilver' in parsed) out.push(parsed as AlbionOrder); } catch { /* not JSON */ } } return; } if (Array.isArray(value)) { for (const v of value) extractOrders(v, out, depth + 1); return; } if (typeof value === 'object') { for (const v of Object.values(value)) extractOrders(v, out, depth + 1); } } function handlePhotonMessage(data: any, isEvent: boolean): void { if (isEvent) { state.photonEvents++; const code = data?.code ?? data?.Code; if (typeof code === 'number') { state.eventCodeCounts.set(code, (state.eventCodeCounts.get(code) ?? 0) + 1); } } const orders: AlbionOrder[] = []; extractOrders(data?.parameters ?? data?.Parameters ?? data, orders); if (orders.length > 0) { state.marketOrdersSeen += orders.length; try { storeOrders(orders); } catch (e) { state.lastError = `storing sniffed orders failed: ${e instanceof Error ? e.message : e}`; } } } export function startSniffer(interfaceIds?: string[]): { started: boolean; error?: string } { if (proc) return { started: false, error: 'sniffer already running' }; const tshark = findTshark(); if (!tshark) { return { started: false, error: 'tshark.exe not found. Install Wireshark (https://www.wireshark.org) with the bundled Npcap driver — the sniffer uses it to read your own game traffic passively.', }; } const parser = new PhotonPacketParser(); parser.on('event', (d: any) => handlePhotonMessage(d, true)); parser.on('response', (d: any) => handlePhotonMessage(d, false)); parser.on('request', () => undefined); parser.on('error', () => undefined); const ifaces = interfaceIds && interfaceIds.length > 0 ? interfaceIds : listInterfaces().map((i) => i.id); const ifaceArgs = ifaces.flatMap((i) => ['-i', i]); const args = [...ifaceArgs, '-l', '-n', '-q', '-f', 'udp port 5056', '-T', 'fields', '-e', 'data']; proc = spawn(tshark, args, { windowsHide: true }); state.packets = 0; state.photonEvents = 0; state.marketOrdersSeen = 0; state.pricesStored = 0; state.startedAt = new Date().toISOString(); state.lastError = null; state.eventCodeCounts.clear(); state.recentOrders = []; let pending = ''; proc.stdout!.on('data', (chunk: Buffer) => { pending += chunk.toString('ascii'); const lines = pending.split('\n'); pending = lines.pop() ?? ''; for (const line of lines) { const hex = line.trim().replace(/:/g, ''); if (!hex || hex.length < 24) continue; state.packets++; try { parser.handle(Buffer.from(hex, 'hex')); } catch { /* malformed packet — ignore */ } } }); proc.stderr!.on('data', (chunk: Buffer) => { const msg = chunk.toString('utf8').trim(); // tshark prints capture stats to stderr; only keep real errors if (msg && !/^Capturing on/i.test(msg) && !/^\d+ packets?/i.test(msg)) state.lastError = msg.slice(0, 400); }); proc.on('exit', (code) => { if (code !== 0 && code != null) state.lastError = state.lastError ?? `tshark exited with code ${code}`; proc = null; }); return { started: true }; } export function stopSniffer(): void { if (proc) { proc.kill(); proc = null; } } export function snifferStatus(): SnifferStatus { return { tsharkPath: findTshark(), running: proc != null, packets: state.packets, photonEvents: state.photonEvents, marketOrdersSeen: state.marketOrdersSeen, pricesStored: state.pricesStored, startedAt: state.startedAt, lastError: state.lastError, eventCodeCounts: Object.fromEntries([...state.eventCodeCounts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 40)), recentOrders: state.recentOrders, }; }