tshark-based capture (no native modules) of the player's own UDP 5056 traffic decoded with photon-packet-parser; market orders detected by JSON shape (patch-proof) and stored as firsthand prices; event-code debug log to identify destiny-board codes; Sniffer page + README. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
291 lines
9.8 KiB
TypeScript
291 lines
9.8 KiB
TypeScript
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<string, string> = {
|
|
'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<string, number>;
|
|
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<number, number>(),
|
|
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<string, { sell: number | null; buy: number | null; item: string; city: string; quality: number }>();
|
|
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,
|
|
};
|
|
}
|