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
+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),