feat: F4.6g5 the switch and NAT tables and "Pakete verwerfen", measured against Java's

ViewersRun takes the rows of Java's viewers from the firmware: a switch's
table, a home router's NAT table, each after its reset, and the frames
"Pakete verwerfen" drops while it is held. The core writes a frame as Java
does and resets the NAT's dynamic entries; the room sends both tables as
views, drops while any client holds the button, and sends the dropped
frames, begun anew with each start, with the clock their times of day are
written from; the editor opens a table with a tap on a switch or home
router and holds "Pakete verwerfen" in its toolbar, which now wraps on a
phone. This completes F4.6.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-03 22:13:48 +00:00
co-authored by Claude Opus 5.5
parent 5224e1a28b
commit 6809a71300
31 changed files with 1370 additions and 31 deletions
+1
View File
@@ -17,6 +17,7 @@ export * from './runtime/speed.js';
export * from './runtime/trace.js';
export * from './runtime/capture.js';
export * from './runtime/captureRows.js';
export * from './runtime/viewers.js';
export * from './runtime/threads.js';
export * from './runtime/apps/program.js';
export * from './runtime/apps/installation.js';
+10
View File
@@ -68,6 +68,11 @@ class CaptureList {
forgetIdentities(): void {
this.identities.clear();
}
/** Every frame forgotten, the numbers going on (the dropped frames' list, which keeps no identities). */
clear(): void {
this.frames.length = 0;
}
}
export class PacketCapture {
@@ -107,6 +112,11 @@ export class PacketCapture {
return this.dropped.frames;
}
/** The dropped frames forgotten (SimulationControl begins its list anew), their numbers going on. */
clearDropped(): void {
this.dropped.clear();
}
/** 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();
+12 -1
View File
@@ -15,7 +15,7 @@
// for an unknown transport protocol (all empty) does not occur.
import type { CapturedFrame } from './capture.js';
import { IPV4_PROTOCOL, TCP_FLAG, type ArpPacket, type IcmpMessage, type IPv4Packet } from './packets.js';
import { IPV4_PROTOCOL, TCP_FLAG, type ArpPacket, type EthernetFrame, type IcmpMessage, type IPv4Packet } from './packets.js';
/** Java's four layers (Lauscher.PROTOKOLL_SCHICHTEN): network access, internet, transport, application. */
export type CaptureLayer = 'networkAccess' | 'internet' | 'transport' | 'application';
@@ -182,6 +182,17 @@ function packetText(packet: IPv4Packet): string {
);
}
/**
* EthernetFrame.toString: addresses, Java's type (0x800 for IP, 0x806 for
* ARP) and what the frame carries — an IP packet without its segment, an
* ICMP packet whole, an ARP packet. SimulationControl lists dropped frames so.
*/
export function javaFrameText(frame: EthernetFrame): string {
const payload = frame.payload;
const data = payload.kind === 'arp' ? arpText(payload.packet) : packetText(payload.packet);
return `[src=${frame.sourceMacAddress}, dest=${frame.destinationMacAddress}, type=${payload.kind === 'arp' ? '0x806' : '0x800'} | ${data}]`;
}
/** Java's String.trim: cuts characters up to U+0020 at both ends. */
function javaTrim(text: string): string {
let start = 0;
+10
View File
@@ -276,6 +276,8 @@ export interface SwitchSatEntry {
readonly macAddress: string;
readonly portId: Id;
readonly portLabel: string;
/** The port's place among the switch's, from 1 (SwitchFirmware.holeSAT: "Port <n>"). */
readonly portNumber: number;
readonly updatedAtMilliseconds: number;
}
@@ -4060,6 +4062,12 @@ export class NetworkRuntimeEngine {
}
}
/** GatewayFirmware.loescheNAT (the NAT viewer's "reset"): the dynamic entries go, the port forwarding's stay. */
clearDynamicNatEntries(nodeId: Id): void {
const table = this.stateValue.natTablesByNodeId.get(nodeId);
if (table !== undefined) this.stateValue.natTablesByNodeId.set(nodeId, table.filter((entry) => entry.type === 'static'));
}
/** The home router's NAT table as it stands: static entries, then the dynamic ones that have not expired. */
natEntries(nodeId: Id): readonly NatEntry[] {
this.expireNatEntries(nodeId);
@@ -4729,6 +4737,7 @@ export class NetworkRuntimeEngine {
const node = this.nodeSnapshot(nodeId);
if (node === undefined || node.kind !== 'networkSwitch') return [];
const labelsByPortId = new Map(node.ports.map((port) => [port.id, port.label]));
const numbersByPortId = new Map(node.ports.map((port, index) => [port.id, index + 1]));
const updatedAtByMac = this.stateValue.switchForwardingUpdatedAtMillisecondsByNodeId.get(nodeId) ?? new Map();
const table = this.stateValue.switchForwardingTablesByNodeId.get(nodeId) ?? new Map<string, Id>();
return [...table]
@@ -4736,6 +4745,7 @@ export class NetworkRuntimeEngine {
macAddress,
portId,
portLabel: labelsByPortId.get(portId) ?? '?',
portNumber: numbersByPortId.get(portId) ?? 0,
updatedAtMilliseconds: updatedAtByMac.get(macAddress) ?? 0,
}))
.sort((lhs, rhs) =>
+22
View File
@@ -0,0 +1,22 @@
// The rows of FILIUS' viewers of a running simulation, as Java writes them:
// a home router's NAT table (GatewayFirmware.holeNAT, createEintrag, shown by
// NatViewer). A switch's table (SwitchFirmware.holeSAT) is the engine's
// switchSatEntries with the port's number; the time of an entry is the
// viewer's to write, in its clock.
import type { NatEntry } from './engine.js';
/** A NAT entry's cells: protocol, WAN port, remote address, LAN address, LAN port; and its last update, none for the port forwarding's. */
export interface NatViewRow {
readonly cells: readonly [string, string, string, string, string];
readonly updatedAtMilliseconds?: number;
}
const PROTOCOL_NAMES: Readonly<Record<NatEntry['protocol'], string>> = { tcp: 'TCP', udp: 'UDP', icmp: 'ICMP' };
/** createEintrag: an ICMP entry's "port" is its identifier ("0 (Identifier: 1)"); the port forwarding's remote end is "*". */
export function natViewRow(entry: NatEntry): NatViewRow {
const port = (value: number) => (entry.protocol === 'icmp' ? `0 (Identifier: ${value})` : String(value));
const cells = [PROTOCOL_NAMES[entry.protocol], port(entry.port), entry.type === 'static' ? '*' : entry.remoteIPAddress, entry.lan.ipAddress, port(entry.lan.port)] as const;
return entry.type === 'static' ? { cells } : { cells, updatedAtMilliseconds: entry.updatedAtMilliseconds };
}
+67
View File
@@ -0,0 +1,67 @@
{
"startEpoch": 1791063964929,
"steps": [
{
"step": "start",
"at": 720,
"rows": [
[
"TCP",
"8080",
"*",
"192.168.0.10",
"80",
"-"
]
]
},
{
"step": "after ping and datagram",
"at": 2926,
"rows": [
[
"TCP",
"8080",
"*",
"192.168.0.10",
"80",
"-"
],
[
"ICMP",
"0 (Identifier: 1)",
"42.0.0.20",
"192.168.0.10",
"0 (Identifier: 1)",
"21:46:06"
],
[
"UDP",
"61570",
"42.0.0.20",
"192.168.0.10",
"51179",
"21:46:06"
]
],
"answers": {
"ping 42.0.0.20": "ICMP 0/0",
"udp port": 51179
}
},
{
"step": "reset",
"at": 2926,
"rows": [
[
"TCP",
"8080",
"*",
"192.168.0.10",
"80",
"-"
]
]
}
]
}
@@ -0,0 +1,90 @@
{
"startEpoch": 1791063953143,
"steps": [
{
"step": "after pings",
"at": 1632,
"rows": [
[
"02:00:00:00:01:11",
"Port 1",
"21:45:54"
],
[
"02:00:00:00:01:13",
"Port 3",
"21:45:54"
],
[
"02:00:00:00:01:12",
"Port 2",
"21:45:54"
]
],
"updated": {
"02:00:00:00:01:11": 1464,
"02:00:00:00:01:13": 1575,
"02:00:00:00:01:12": 1129
},
"ports": 24,
"answers": {
"ping .20": "ICMP 0/0",
"ping .30": "ICMP 0/0"
}
},
{
"step": "reset",
"at": 1632,
"rows": [],
"updated": {},
"ports": 24
},
{
"step": "ping .20 again",
"at": 1855,
"rows": [
[
"02:00:00:00:01:11",
"Port 1",
"21:45:54"
],
[
"02:00:00:00:01:12",
"Port 2",
"21:45:54"
]
],
"updated": {
"02:00:00:00:01:11": 1688,
"02:00:00:00:01:12": 1799
},
"ports": 24,
"answers": {
"ping .20": "ICMP 0/0"
}
},
{
"step": "after the retention",
"at": 7855,
"rows": [],
"updated": {},
"ports": 24
},
{
"step": "dropped while held",
"at": 10606,
"answer": "null",
"dropped": [
"21:46:01.054 : [src=02:00:00:00:01:11, dest=02:00:00:00:01:13, type=0x800 | [id=7; ttl=64; protocol=1; sender=192.168.0.10; target=192.168.0.30; icmpType=8; icmpCode=0; identifier=1; seqNr=4]]"
]
},
{
"step": "released",
"at": 10829,
"answer": "ICMP 0/0",
"dropped": [
"21:46:01.054 : [src=02:00:00:00:01:11, dest=02:00:00:00:01:13, type=0x800 | [id=7; ttl=64; protocol=1; sender=192.168.0.10; target=192.168.0.30; icmpType=8; icmpCode=0; identifier=1; seqNr=4]]"
]
}
]
}
+128
View File
@@ -0,0 +1,128 @@
// What FILIUS' viewers of a running simulation show, against Java's
// (viewers-switch.java.json, viewers-nat.java.json; ViewersRun): a switch's
// source address table after pings, after its reset and after the retention;
// a home router's NAT table after a ping and a datagram, and after its reset
// (the port forwarding's entry stays); the frames "Pakete verwerfen" drops
// while it is held, in Java's words. Java's time of day is the viewer's to
// write (the editor's), and its table of a switch comes in the order of
// Java's hash map, here by address.
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
import { javaFrameText, natViewRow, NetworkRuntimeEngine, type Steps } from '../src/index.js';
import { snapshotOf, type Scenario } from './scenario.js';
interface JavaStep {
readonly step: string;
readonly at: number;
readonly rows?: readonly (readonly string[])[];
readonly updated?: Readonly<Record<string, number>>;
readonly answer?: string;
readonly answers?: Readonly<Record<string, string | number>>;
readonly dropped?: readonly string[];
}
const here = dirname(fileURLToPath(import.meta.url));
const javaRun = (name: string) => JSON.parse(readFileSync(join(here, `viewers-${name}.java.json`), 'utf8')) as { readonly steps: readonly JavaStep[] };
const scenarioOf = (name: string) => JSON.parse(readFileSync(join(here, `scenarios/${name}.scenario.json`), 'utf8')) as Scenario;
function started(scenario: Scenario) {
const engine = new NetworkRuntimeEngine(scenario.seed);
engine.handle({ type: 'start', snapshot: snapshotOf(scenario), seed: scenario.seed, initialTimeMilliseconds: 0 });
const run = (milliseconds: number) => engine.handle({ type: 'advance', toMilliseconds: engine.state.currentTimeMilliseconds + milliseconds });
/** A ping, returning when its answer came or its wait ended, as Java's sendProbe blocks. */
const ping = (from: string, target: string, sequenceNumber: number): string => {
let answer: string | undefined;
engine.startThread(
(function* (): Steps {
const reply = yield* engine.icmpEchoSteps(from, target, { identifier: 1, sequenceNumber });
answer = reply === undefined ? 'null' : `ICMP ${reply.message.kind === 'echoReply' ? '0/0' : reply.message.kind}`;
})(),
);
for (let waited = 0; answer === undefined && waited < 30_000; waited += 10) run(10);
return answer ?? 'null';
};
return { engine, run, ping };
}
describe("a switch's table and the dropped frames, against Java's", () => {
const java = javaRun('switch');
const at = (name: string) => java.steps.find((step) => step.step === name)!;
const SWITCH = '00000000-0000-0000-0000-000000000104';
const PC10 = '00000000-0000-0000-0000-000000000101';
it('learns, is reset, ages, and lists what is dropped while it is held', () => {
const { engine, run, ping } = started(scenarioOf('switch-lan'));
run(500);
/** The table as Java's viewer shows it, without the time: address in capitals, "Port <n>". */
const table = () => engine.switchSatEntries(SWITCH).map((entry) => [entry.macAddress.toUpperCase(), `Port ${entry.portNumber}`]);
const javaTable = (name: string) => at(name).rows!.map(([mac = '', port = '']) => [mac, port]);
const sorted = (rows: string[][]) => [...rows].sort(([lhs], [rhs]) => (lhs! < rhs! ? -1 : 1));
expect([ping(PC10, '192.168.0.20', 1), ping(PC10, '192.168.0.30', 2)]).toEqual(Object.values(at('after pings').answers!));
// By address (Java's in its hash map's order).
expect(table()).toEqual(sorted(javaTable('after pings')));
expect(table()).not.toEqual(javaTable('after pings'));
engine.clearSwitchSat(SWITCH);
expect(table()).toEqual(javaTable('reset'));
expect(ping(PC10, '192.168.0.20', 3)).toBe(at('ping .20 again').answers!['ping .20']);
expect(table()).toEqual(sorted(javaTable('ping .20 again')));
run(6000);
expect(table()).toEqual(javaTable('after the retention'));
// "Pakete verwerfen" held while .10 pings .30: the request is dropped on its first cable; released, the ping is answered.
engine.setGlobalPacketLossEnabled(true);
const dropped = ping(PC10, '192.168.0.30', 4);
engine.setGlobalPacketLossEnabled(false);
const text = (step: JavaStep) => step.dropped!.map((line) => line.slice(line.indexOf(' : ') + 3));
const ours = () => engine.capture.droppedFrames().map((each) => javaFrameText(each.frame));
const sameButIdentity = (lines: readonly string[]) => lines.map((line) => line.replace(/\[id=\d+;/, '[id=ID;'));
expect(dropped).toBe(at('dropped while held').answer);
expect(sameButIdentity(ours())).toEqual(sameButIdentity(text(at('dropped while held'))));
expect(ping(PC10, '192.168.0.30', 5)).toBe(at('released').answer);
expect(sameButIdentity(ours())).toEqual(sameButIdentity(text(at('released'))));
// Java's time of the drop is the time of day with milliseconds.
expect(at('released').dropped![0]).toMatch(/^\d\d:\d\d:\d\d\.\d\d\d : \[src=/);
});
});
describe("a home router's NAT table, against Java's", () => {
const java = javaRun('nat');
const at = (name: string) => java.steps.find((step) => step.step === name)!;
const GATEWAY = '00000000-0000-0000-0000-000000000502';
const LAN_PC = '00000000-0000-0000-0000-000000000501';
it('shows the port forwarding, a ping and a datagram, and keeps the port forwarding when reset', () => {
const { engine, run, ping } = started(scenarioOf('nat-home'));
run(500);
const rows = () =>
engine.natEntries(GATEWAY).map((entry) => {
const row = natViewRow(entry);
return [...row.cells, row.updatedAtMilliseconds === undefined ? '-' : 'TIME'];
});
/** Java's rows with a time of day as TIME, and the datagram's ports (drawn) as PORT. */
const javaRows = (name: string) =>
at(name).rows!.map((row) => (row[0] === 'UDP' ? [row[0], 'PORT', row[2], row[3], 'PORT', 'TIME'] : row.map((cell, index) => (index === 5 && cell !== '-' ? 'TIME' : cell))));
expect(rows()).toEqual(javaRows('start'));
expect(ping(LAN_PC, '42.0.0.20', 1)).toBe(at('after ping and datagram').answers!['ping 42.0.0.20']);
const socket = engine.bindUdpSocket(LAN_PC, { remoteIPAddress: '42.0.0.20', remotePort: 7 })!;
engine.sendUdp(socket, new TextEncoder().encode('one'));
run(1500);
expect(rows().map((row) => (row[0] === 'UDP' ? [row[0], 'PORT', row[2], row[3], 'PORT', row[5]] : row))).toEqual(javaRows('after ping and datagram'));
engine.clearDynamicNatEntries(GATEWAY);
expect(rows()).toEqual(javaRows('reset'));
});
it('writes an entry as Java’s createEintrag does', () => {
expect([
natViewRow({ protocol: 'tcp', remoteIPAddress: '', port: 8080, lan: { ipAddress: '192.168.0.10', port: 80 }, type: 'static', updatedAtMilliseconds: 5 }),
natViewRow({ protocol: 'icmp', remoteIPAddress: '42.0.0.20', port: 3, lan: { ipAddress: '192.168.0.10', port: 4 }, type: 'dynamic', updatedAtMilliseconds: 7 }),
natViewRow({ protocol: 'udp', remoteIPAddress: '42.0.0.30', port: 8080, lan: { ipAddress: '192.168.0.10', port: 80 }, type: 'dynamicFromStatic', updatedAtMilliseconds: 9 }),
]).toEqual([
{ cells: ['TCP', '8080', '*', '192.168.0.10', '80'] },
{ cells: ['ICMP', '0 (Identifier: 3)', '42.0.0.20', '192.168.0.10', '0 (Identifier: 4)'], updatedAtMilliseconds: 7 },
{ cells: ['UDP', '8080', '42.0.0.30', '192.168.0.10', '80'], updatedAtMilliseconds: 9 },
]);
});
});