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
+120
View File
@@ -0,0 +1,120 @@
// The packet capture as Java FILIUS keeps it (filius.rahmenprogramm.nachrichten
// .Lauscher, the data exchange window): per network card every frame it sent
// or received, once, with the time; and the frames the packet loss dropped.
//
// Java logs a frame on a card when the card hands it to the cable
// (Ethernet.senden, after the cable is free) and when a frame arrives at the
// card (EthernetThread, or before it the firewall or NAT thread of a router
// or home router) — every frame that arrives, also one addressed to another
// card, also one a firewall then drops. Switches have no cards and log
// nothing. A frame shows on a card at most once (EthernetFrame
// .readByLauscherForMac). The log is not cleared when the simulation stops
// or starts again, only for a new or loaded project and by the user.
//
// Departures from Java: a card's list is kept under its port, not its MAC
// address (Java merges two cards with the same MAC into one list), and it
// keeps at most `maximumFrameCount` frames, dropping the oldest
// `evictionBatchSize` at once (Java keeps all); a frame keeps its number.
import type { Id } from '../ids.js';
import type { EthernetFrame } from './packets.js';
export interface CapturedFrame {
/** Its row number in the card's list, from 1, kept when older frames are dropped (Java: "Nr."). */
readonly number: number;
readonly timeMilliseconds: number;
readonly frame: EthernetFrame;
}
/** How many frames a list keeps, and how many it drops at once when full. */
export interface CaptureRetentionPolicy {
readonly maximumFrameCount: number;
readonly evictionBatchSize: number;
}
export function captureRetentionPolicy(maximumFrameCount: number, evictionBatchSize: number): CaptureRetentionPolicy {
if (!(maximumFrameCount > 0)) throw new RangeError('Packet capture must keep at least one frame');
if (!(evictionBatchSize > 0)) throw new RangeError('Packet capture eviction must remove at least one frame');
if (evictionBatchSize > maximumFrameCount) throw new RangeError('Packet capture eviction cannot exceed the retention limit');
return { maximumFrameCount, evictionBatchSize };
}
export const PRODUCTION_CAPTURE_RETENTION = captureRetentionPolicy(10_000, 1_000);
class CaptureList {
readonly frames: CapturedFrame[] = [];
private readonly identities = new Set<number>();
private count = 0;
/** `once`: a frame shows at most once (a card's list; a switch floods one frame onto several cables, each may drop it). */
constructor(
private readonly policy: CaptureRetentionPolicy,
private readonly once: boolean,
) {}
/** Adds the frame unless it shows once only and the list has it already; false then. */
add(timeMilliseconds: number, frame: EthernetFrame): boolean {
if (this.once && this.identities.has(frame.identity)) return false;
if (this.frames.length >= this.policy.maximumFrameCount) {
for (const evicted of this.frames.splice(0, this.policy.evictionBatchSize)) this.identities.delete(evicted.frame.identity);
}
this.count += 1;
this.frames.push({ number: this.count, timeMilliseconds, frame });
this.identities.add(frame.identity);
return true;
}
/** A new run numbers its frames from 1 again: the identities of the last run no longer tell a frame apart. */
forgetIdentities(): void {
this.identities.clear();
}
}
export class PacketCapture {
private readonly listsByPortId = new Map<Id, CaptureList>();
private dropped: CaptureList;
constructor(private readonly policy: CaptureRetentionPolicy = PRODUCTION_CAPTURE_RETENTION) {
this.dropped = new CaptureList(policy, false);
}
/** Logs the frame on the card at `portId`, unless it shows there already. */
record(portId: Id, timeMilliseconds: number, frame: EthernetFrame): void {
let list = this.listsByPortId.get(portId);
if (list === undefined) {
list = new CaptureList(this.policy, true);
this.listsByPortId.set(portId, list);
}
list.add(timeMilliseconds, frame);
}
/** Logs a frame the packet loss dropped on its cable (Lauscher.addDroppedDataUnit). */
recordDropped(timeMilliseconds: number, frame: EthernetFrame): void {
this.dropped.add(timeMilliseconds, frame);
}
/** The card's frames, oldest first. */
frames(portId: Id): readonly CapturedFrame[] {
return this.listsByPortId.get(portId)?.frames ?? [];
}
/** The cards that have frames. */
portIds(): Id[] {
return [...this.listsByPortId.keys()];
}
droppedFrames(): readonly CapturedFrame[] {
return this.dropped.frames;
}
/** The simulation starts again: the lists stay, frames of the new run are new. */
beginRun(): void {
for (const list of this.listsByPortId.values()) list.forgetIdentities();
}
/** Empties every list (Lauscher.reset). */
clear(): void {
this.listsByPortId.clear();
this.dropped = new CaptureList(this.policy, false);
}
}
+37 -1
View File
@@ -39,6 +39,8 @@
// DNS after Java (filius.software.dns: DNSServer, DNSServerMitarbeiter,
// Resolver, DNSQueryAgent), not Swift's typed format; a recursive server
// asks on without asking itself (Java does, without end).
// The packet capture after Java's Lauscher (capture.ts): a card logs a frame
// when it leaves on the cable and when it arrives, a switch logs nothing.
// Java counterparts: filius.hardware.Verbindung, filius.software.netzzugangsschicht,
// filius.software.vermittlungsschicht (ARP, IP, ICMP), system.SwitchFirmware,
// filius.software.transportschicht (UDP, TCP), filius.software.dhcp.
@@ -103,6 +105,7 @@ import {
type DnsMessageRecord,
type DnsQuestion,
} from './dns.js';
import { PacketCapture, PRODUCTION_CAPTURE_RETENTION, type CaptureRetentionPolicy } from './capture.js';
import { JavaCompatibleRandom } from './random.js';
import { routeTableRows, type RouteRow } from './routeTable.js';
import { EMPTY_TOPOLOGY_SNAPSHOT, withSnapshot, type NodeSnapshot, type TopologySnapshot } from './snapshot.js';
@@ -845,6 +848,7 @@ export class NetworkRuntimeEngine {
private readonly cablesCarryOneFrameAtATime: boolean;
private readonly swiftArp: boolean;
private readonly swiftSends: boolean;
private readonly packetCapture: PacketCapture;
/**
* `swiftCables`: a cable carries all frames at once, as in the Swift
@@ -855,12 +859,20 @@ export class NetworkRuntimeEngine {
*
* `swiftArp`, `swiftSends`: Swift's ARP and Swift's send that returns
* when the network is quiet, likewise only for the comparison.
*
* `captureRetention`: how many frames each card's packet capture keeps.
*/
constructor(
seed: bigint | number = DEFAULT_SEED,
packetTraceRetentionPolicy: TraceRetentionPolicy = PRODUCTION_TRACE_RETENTION,
options: { readonly swiftCables?: boolean; readonly swiftArp?: boolean; readonly swiftSends?: boolean } = {},
options: {
readonly swiftCables?: boolean;
readonly swiftArp?: boolean;
readonly swiftSends?: boolean;
readonly captureRetention?: CaptureRetentionPolicy;
} = {},
) {
this.packetCapture = new PacketCapture(options.captureRetention ?? PRODUCTION_CAPTURE_RETENTION);
this.cablesCarryOneFrameAtATime = options.swiftCables !== true;
this.swiftArp = options.swiftArp === true;
this.swiftSends = options.swiftSends === true;
@@ -876,6 +888,20 @@ export class NetworkRuntimeEngine {
this.stateValue.simulationSpeedPercent = this.runtimeSpeed.percent;
}
/**
* The packet capture (Java's Lauscher): per card every frame it sent or
* received. It outlives a stop and a new start, as in Java; clearCapture
* empties it.
*/
get capture(): PacketCapture {
return this.packetCapture;
}
/** Empties the packet capture (Java: the data exchange window's reset, a new or loaded project). */
clearCapture(): void {
this.packetCapture.clear();
}
/** The runtime state. Read it; change it only through the engine's methods. */
get state(): RuntimeState {
return this.stateValue;
@@ -900,6 +926,7 @@ export class NetworkRuntimeEngine {
if (this.stateValue.phase !== 'stopped') return [this.output({ type: 'startIgnoredAlreadyRunning' })];
const seed = toSeed(input.seed);
this.stateValue = initialState(seed);
this.packetCapture.beginRun();
this.stateValue.phase = 'running';
this.stateValue.currentTimeMilliseconds = input.initialTimeMilliseconds;
this.stateValue.simulationSpeedPercent = this.runtimeSpeed.percent;
@@ -1143,6 +1170,7 @@ export class NetworkRuntimeEngine {
layer: TraceLayer,
): boolean {
if (!this.stateValue.globalPacketLossEnabled) return false;
this.packetCapture.recordDropped(this.stateValue.currentTimeMilliseconds, frame);
this.recordTrace({
frameIdentity: frame.identity,
packetIdentity: packetIdentityInFrame(frame),
@@ -3730,6 +3758,7 @@ export class NetworkRuntimeEngine {
/** The frame leaves on the cable (and shows in the packet log); it arrives one cable's delay later. */
private departEthernetFrame(fromNodeId: Id, outgoingPortId: Id, endpoint: { nodeId: Id; portId: Id } | undefined, frame: EthernetFrame): void {
this.captureOnCard(fromNodeId, outgoingPortId, frame);
this.recordTrace({
frameIdentity: frame.identity,
packetIdentity: packetIdentityInFrame(frame),
@@ -3804,7 +3833,14 @@ export class NetworkRuntimeEngine {
return this.stateValue.topologySnapshot.nodes.find((node) => node.id === nodeId);
}
/** Java logs a frame on a card; a switch has none. */
private captureOnCard(nodeId: Id, portId: Id, frame: EthernetFrame): void {
if (this.nodeSnapshot(nodeId)?.kind === 'networkSwitch') return;
this.packetCapture.record(portId, this.stateValue.currentTimeMilliseconds, frame);
}
private receiveEthernetFrame(frame: EthernetFrame, nodeId: Id, incomingPortId: Id): void {
this.captureOnCard(nodeId, incomingPortId, frame);
this.recordTrace({
frameIdentity: frame.identity,
packetIdentity: packetIdentityInFrame(frame),