feat(core): packet capture per card after Java's Lauscher, measured card by card

The engine keeps Java's packet capture (capture.ts): per network card
every frame it sent (when it leaves on the cable) or received (also one
for another card), once, with time and number; switches log nothing;
frames the packet loss dropped are kept apart; the capture outlives a
stop and a new start, as in Java, until clearCapture. Per port instead
of per MAC, at most 10,000 frames a card.

expectSameCaptures compares each card's capture with Java's list of that
card in the ARP, WLAN, DNS, TCP, NAT, RIP and single-client DHCP runs.
It found that ScenarioNetwork ignored a switch's learning time (Java ran
300 s, the core the scenarios' 5 s); the oracle sets it now, and the TCP
and DHCP runs are recorded again. A core test found that a new start
numbers frames from 1 again and the capture took them as seen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-01 08:53:37 +00:00
co-authored by Claude Opus 5.5
parent 65ccd9ddd7
commit 088b62469f
24 changed files with 796 additions and 344 deletions
+19 -5
View File
@@ -22,7 +22,7 @@ import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
import type { Id, IPv4DeliveryResult, NetworkRuntimeEngine } from '../src/index.js';
import { GAP_TOLERANCE_MILLISECONDS, javaFrames, watchedEngine, type JavaFrame, type SeenFrame } from './javaRun.js';
import { expectSameCaptures, GAP_TOLERANCE_MILLISECONDS, javaFrames, watchedEngine, type JavaFrame, type SeenFrame } from './javaRun.js';
import type { Scenario } from './scenario.js';
const scenarioDirectory = join(dirname(fileURLToPath(import.meta.url)), 'scenarios');
@@ -144,6 +144,8 @@ const STORIES: Record<string, (engine: NetworkRuntimeEngine, call: Call) => void
* end; the second server gets the datagram from the entry's WAN port, but its
* answer finds no entry and stays at the router. The engine adds an entry for
* the second remote end: its answer goes on to the PC, a cable later.
* (`answer` is the frame on the home router's WAN card, the first list it
* shows in.)
*/
const JAVA_BUGS: Record<string, (java: JavaRun) => JavaRun> = {
udpTwoRemotes: (java) => {
@@ -162,7 +164,8 @@ const JAVA_BUGS: Record<string, (java: JavaRun) => JavaRun> = {
const wanPort = java.nat[0]!.match(/42\.0\.0\.20:(\d+)/)![1];
const received = java.events.find(([, event]) => event === 'other server received two')!;
return {
frames: { ...java.frames, [LAN_PC]: [...java.frames[LAN_PC]!, onLan] },
// The home router's LAN card sends it as it arrives on the WAN card; the PC's receives it a cable later.
frames: { ...java.frames, [`${GATEWAY}#1`]: [...java.frames[`${GATEWAY}#1`]!, { ...onLan, time: answer.time + 1 }], [LAN_PC]: [...java.frames[LAN_PC]!, onLan] },
events: java.events.map(([time, event], index) =>
index === java.events.length - 1 && event === 'empfangen returned null' ? [received[0] + 168, 'empfangen returned pong two'] : [time, event],
),
@@ -171,8 +174,8 @@ const JAVA_BUGS: Record<string, (java: JavaRun) => JavaRun> = {
},
};
/** Free ports as "port", initial sequence numbers by their order, ties sorted. */
function normalized(frames: readonly SeenFrame[]): SeenFrame[] {
/** Free ports as "port", initial sequence numbers by their order. */
function withPlaceholders(frames: readonly SeenFrame[]): SeenFrame[] {
const bases = new Map<number, number>();
const sequence = (value: string) => {
const number = Number(value);
@@ -180,10 +183,15 @@ function normalized(frames: readonly SeenFrame[]): SeenFrame[] {
if (!bases.has(base)) bases.set(base, bases.size + 1);
return `isn${bases.get(base)}+${number % 1_000_000}`;
};
const texts = frames.map((frame) => ({
return frames.map((frame) => ({
time: frame.time,
text: withPorts(frame.text).replace(/(seq|ack)=(\d+)/g, (whole, name: string, value: string) => (value === '0' ? whole : `${name}=${sequence(value)}`)),
}));
}
/** With placeholders, ties sorted: over all cards, Java's log writes frames that show together in any order. */
function normalized(frames: readonly SeenFrame[]): SeenFrame[] {
const texts = withPlaceholders(frames);
const result: SeenFrame[] = [];
for (let start = 0; start < texts.length; ) {
let end = start + 1;
@@ -248,6 +256,12 @@ describe('NAT as Java FILIUS runs it', () => {
}
}
// A card's own list keeps its order.
expectSameCaptures(engine, scenario, java.frames, {
normalized: withPlaceholders,
tolerance: TCP_STORIES.has(name) ? TCP_TOLERANCE_MILLISECONDS : GAP_TOLERANCE_MILLISECONDS,
});
// The client's calls: Java's server threads write events of their own, which the engine's story has not.
const javaCalls = java.events.filter(([, event]) => !event.startsWith('server') && !event.startsWith('other server'));
expect(events.map(([, event]) => event)).toEqual(javaCalls.map(([, event]) => event));