feat(core): the packet capture's table after Java's Lauscher, measured against Java's own tables

captureRows.ts builds Java's rows per frame (Lauscher.datenVorbereiten):
Ethernet; ARP, ICMP or IP; TCP or UDP; the data with Java's protocol
recognition; with addresses and ports or ports only. The comment stays
structured; captureCommentText says it with given words and numbers.

ScenarioNetwork writes Java's finished tables per card (Lauscher.getDaten,
German locale); all runtime runs are recorded again, NatRun has ttlOut.
expectSameCaptures compares both views for every frame. Fixed where the
engine differed: an ARP request's target MAC is the broadcast address,
an ICMP error's identifier and sequence number are 0 (RFC 792), and host
unreachable from a router carries the packet as forwarded. Not taken:
Java's lower-case broadcast address in a reply to 0.0.0.0, and the typo
'Exeeded'. RIP keeps its recordings: Java's staggered router start now
gives a different startup on this machine.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-01 15:11:05 +00:00
co-authored by Claude Opus 5.5
parent 0a34f3a4c4
commit 21a404693c
55 changed files with 37423 additions and 1321 deletions
+1
View File
@@ -16,6 +16,7 @@ export * from './runtime/snapshot.js';
export * from './runtime/speed.js';
export * from './runtime/trace.js';
export * from './runtime/capture.js';
export * from './runtime/captureRows.js';
export * from './util/equal.js';
export * from './util/javaIp.js';
export * from './util/swiftString.js';
+214
View File
@@ -0,0 +1,214 @@
// The packet capture's table as Java FILIUS shows it
// (filius.rahmenprogramm.nachrichten.Lauscher.datenVorbereiten): per frame a
// row for each layer it carries — Ethernet; then ARP, ICMP or IP; for IP the
// TCP or UDP segment; and its data, when there are any. Seven columns: number,
// time, source, destination, protocol, layer, comment.
//
// The comment stays structured, so that the editor can say it in the
// viewer's language and format its numbers (Java: the words of its message
// bundle, the numbers with the locale's grouping); captureCommentText says it
// with given words, as Java does. The time is the frame's simulation time;
// Java shows its wall clock.
//
// Departures from Java: "ICMP Time Exceeded (poof)", where Java misspells
// "Exeeded"; an IP packet carries only TCP, UDP or ICMP here, so Java's row
// 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';
/** Java's four layers (Lauscher.PROTOKOLL_SCHICHTEN): network access, internet, transport, application. */
export type CaptureLayer = 'networkAccess' | 'internet' | 'transport' | 'application';
export type CaptureComment =
/** The Ethernet frame's type (EthernetFrame.IP, .ARP). */
| { readonly kind: 'ethernetType'; readonly type: '0x800' | '0x806' }
/** "Protocol: 17, TTL: 64". */
| { readonly kind: 'ipProtocol'; readonly protocol: number; readonly timeToLive: number }
/** "Search for MAC <target IP>" or "MAC is <sender MAC>", then the packet. */
| { readonly kind: 'arp'; readonly operation: 'request' | 'reply'; readonly address: string; readonly packet: string }
/** "SYN" or "FIN", then "SEQ: …" and, with the ACK flag, "ACK: …". */
| { readonly kind: 'tcp'; readonly flag: 'SYN' | 'FIN' | undefined; readonly sequence: number; readonly acknowledgement: number | undefined }
| { readonly kind: 'udp' }
/** The ICMP message, its TTL, identifier, sequence number and the packet it carries. */
| {
readonly kind: 'icmp';
readonly message: string;
readonly timeToLive: number;
readonly identifier: number;
readonly sequenceNumber: number;
readonly payload: string | undefined;
}
/** A segment's data, as text. */
| { readonly kind: 'data'; readonly text: string };
export interface CaptureRow {
readonly number: number;
readonly timeMilliseconds: number;
/** Null where Java leaves the cell empty (the data row without addresses). */
readonly source: string | null;
readonly destination: string | null;
/** "" for Ethernet (Lauscher.ETHERNET) and data Java does not recognise. */
readonly protocol: string;
readonly layer: CaptureLayer;
readonly comment: CaptureComment;
}
/**
* The rows of a card's frames. `withAddresses`: a segment's ends as
* address:port, also on its data row (the data exchange window); else ports
* only, and the data row without ends (the layered view).
*/
export function captureRows(frames: readonly CapturedFrame[], options: { withAddresses: boolean }): CaptureRow[] {
const rows: CaptureRow[] = [];
for (const { number, timeMilliseconds, frame } of frames) {
const row = (source: string | null, destination: string | null, protocol: string, layer: CaptureLayer, comment: CaptureComment) =>
rows.push({ number, timeMilliseconds, source, destination, protocol, layer, comment });
const payload = frame.payload;
row(frame.sourceMacAddress, frame.destinationMacAddress, '', 'networkAccess', { kind: 'ethernetType', type: payload.kind === 'arp' ? '0x806' : '0x800' });
if (payload.kind === 'arp') {
const arp = payload.packet;
row(arp.senderIPAddress, arp.targetIPAddress, 'ARP', 'internet', {
kind: 'arp',
operation: arp.operation,
address: arp.operation === 'request' ? arp.targetIPAddress : arp.senderMacAddress,
packet: arpText(arp),
});
continue;
}
const packet = payload.packet;
if (packet.payload.kind === 'icmp') {
const message = packet.payload.message;
row(packet.senderIPAddress, packet.receiverIPAddress, 'ICMP', 'internet', {
kind: 'icmp',
message: ICMP_MESSAGES[message.kind],
timeToLive: packet.timeToLive,
identifier: message.identifier,
sequenceNumber: message.sequenceNumber,
payload: message.embeddedOriginalPacket === undefined ? undefined : packetText(message.embeddedOriginalPacket),
});
continue;
}
row(packet.senderIPAddress, packet.receiverIPAddress, 'IP', 'internet', { kind: 'ipProtocol', protocol: IPV4_PROTOCOL[packet.protocol], timeToLive: packet.timeToLive });
const segment = packet.payload.kind === 'tcp' ? packet.payload.segment : packet.payload.datagram;
const end = (address: string, port: number) => (options.withAddresses ? `${address}:${port}` : String(port));
const source = end(packet.senderIPAddress, segment.sourcePort);
const destination = end(packet.receiverIPAddress, segment.destinationPort);
if (packet.payload.kind === 'tcp') {
const tcp = packet.payload.segment;
const flag = (tcp.flags & TCP_FLAG.synchronize) !== 0 ? 'SYN' : (tcp.flags & TCP_FLAG.finish) !== 0 ? 'FIN' : undefined;
const acknowledgement = (tcp.flags & TCP_FLAG.acknowledgement) !== 0 ? tcp.acknowledgementNumber : undefined;
row(source, destination, 'TCP', 'transport', { kind: 'tcp', flag, sequence: tcp.sequenceNumber, acknowledgement });
} else {
row(source, destination, 'UDP', 'transport', { kind: 'udp' });
}
const text = new TextDecoder().decode(segment.payload);
if (javaTrim(text) === '') continue;
const protocol = applicationProtocol(text, segment.sourcePort, segment.destinationPort);
row(options.withAddresses ? source : null, options.withAddresses ? destination : null, protocol, 'application', { kind: 'data', text });
}
return rows;
}
/** The words Java's message bundle has for the comments (rp_lauscher_msg12–14). */
export interface CaptureCommentWords {
/** rp_lauscher_msg12 (English bundle: "Protocol") */
readonly protocol: string;
/** rp_lauscher_msg13 (English bundle: "Search for MAC") */
readonly searchForMac: string;
/** rp_lauscher_msg14 (English bundle: "MAC is") */
readonly macIs: string;
}
/** The comment as Java writes it, with these words and this number format (Java: the locale's grouping). */
export function captureCommentText(comment: CaptureComment, words: CaptureCommentWords, formatNumber: (value: number) => string): string {
switch (comment.kind) {
case 'ethernetType':
return comment.type;
case 'ipProtocol':
return `${words.protocol}: ${comment.protocol}, TTL: ${comment.timeToLive}`;
case 'arp':
return `${comment.operation === 'request' ? words.searchForMac : words.macIs} ${comment.address} ${comment.packet}`;
case 'tcp': {
const sequence = `SEQ: ${formatNumber(comment.sequence)}`;
const acknowledgement = comment.acknowledgement === undefined ? '' : `, ACK: ${formatNumber(comment.acknowledgement)}`;
return `${comment.flag === undefined ? '' : `${comment.flag}, `}${sequence}${acknowledgement}`;
}
case 'udp':
return '';
case 'icmp':
return (
`${comment.message}, TTL: ${comment.timeToLive}, Identifier: ${comment.identifier}, Seq.-No.: ${comment.sequenceNumber}` +
(comment.payload === undefined ? '' : `, Payload: ${comment.payload}`)
);
case 'data':
return comment.text;
}
}
const ICMP_MESSAGES: Record<IcmpMessage['kind'], string> = {
echoReply: 'ICMP Echo Reply (pong)',
destinationNetworkUnreachable: 'ICMP Network Unreachable',
destinationHostUnreachable: 'ICMP Host Unreachable',
echoRequest: 'ICMP Echo Request (ping)',
timeExceeded: 'ICMP Time Exceeded (poof)',
};
const ICMP_TYPE_CODE: Record<IcmpMessage['kind'], [number, number]> = {
echoReply: [0, 0],
destinationNetworkUnreachable: [3, 0],
destinationHostUnreachable: [3, 1],
echoRequest: [8, 0],
timeExceeded: [11, 0],
};
/** ArpPaket.toString. */
function arpText(arp: ArpPacket): string {
return `[op=${arp.operation === 'request' ? 'REQUEST' : 'REPLY'}, sender=${arp.senderMacAddress}|${arp.senderIPAddress}, target=${arp.targetMacAddress}|${arp.targetIPAddress}]`;
}
/** IpPaket.toString, IcmpPaket.toString for an ICMP packet: the packet an ICMP error carries. */
function packetText(packet: IPv4Packet): string {
if (packet.payload.kind !== 'icmp') {
return `[id=${packet.identity}, ttl=${packet.timeToLive}, protocol=${IPV4_PROTOCOL[packet.protocol]}, dest=${packet.receiverIPAddress}, src=${packet.senderIPAddress}]`;
}
const message = packet.payload.message;
const [type, code] = ICMP_TYPE_CODE[message.kind];
const carried = message.embeddedOriginalPacket === undefined ? '' : `, payload:${packetText(message.embeddedOriginalPacket)}`;
return (
`[id=${packet.identity}; ttl=${packet.timeToLive}; protocol=1; sender=${packet.senderIPAddress}; target=${packet.receiverIPAddress}; ` +
`icmpType=${type}; icmpCode=${code}; identifier=${message.identifier}; seqNr=${message.sequenceNumber}${carried}]`
);
}
/** Java's String.trim: cuts characters up to U+0020 at both ends. */
function javaTrim(text: string): string {
let start = 0;
let end = text.length;
while (start < end && text.charCodeAt(start) <= 0x20) start += 1;
while (end > start && text.charCodeAt(end - 1) <= 0x20) end -= 1;
return text.slice(start, end);
}
function startsWithIgnoringCase(text: string, prefix: string): boolean {
return text.slice(0, prefix.length).toLowerCase() === prefix.toLowerCase();
}
const POP3_COMMANDS = ['USER ', 'PASS ', 'STAT ', 'LIST ', 'RETR ', 'DELE ', 'NOOP', 'RSET', 'QUIT', 'TOP'];
/** Lauscher.classifyApplicationLayerProtocol: the protocol a segment's data belong to, "" when Java does not know. */
function applicationProtocol(text: string, sourcePort: number, destinationPort: number): string {
if (['//0x00//', '//0x01//', '//0x80//', '//0x81//', '//0x40//'].some((marker) => text.includes(marker))) return 'GNT';
if (
(sourcePort === 110 && (startsWithIgnoringCase(text, '+OK') || startsWithIgnoringCase(text, '-ERR'))) ||
(destinationPort === 110 && POP3_COMMANDS.some((command) => startsWithIgnoringCase(text, command)))
) {
return 'POP3';
}
if ((sourcePort === 25 && ['1', '2', '3', '4', '5'].some((digit) => text.startsWith(digit))) || destinationPort === 25) return 'SMTP';
if (text.includes('ID=') && text.includes('QR=') && text.includes('RCODE=')) return 'DNS';
if (text.includes('DHCP')) return 'DHCP';
if ((sourcePort === 521 && destinationPort === 520) || (sourcePort === 520 && destinationPort === 521)) return 'RIP';
if ((destinationPort === 80 && text.toLowerCase().includes('http/')) || sourcePort === 80) return 'HTTP';
return '';
}
+15 -23
View File
@@ -3716,13 +3716,13 @@ export class NetworkRuntimeEngine {
return this.stateValue.arpCachesByNodeId.get(nodeId)?.get(ipAddress)?.macAddress;
}
/** One ARP request for `targetIPAddress`, broadcast from the interface. */
/** One ARP request for `targetIPAddress`, broadcast from the interface; its target MAC the broadcast address, as Java's (ARP.sendeARPBroadcast; Swift, and swiftArp: zeros). */
private sendArpRequest(nodeId: Id, networkInterface: NetworkInterface, targetIPAddress: string): void {
const request: ArpPacket = {
operation: 'request',
senderMacAddress: networkInterface.macAddress,
senderIPAddress: networkInterface.ipAddress,
targetMacAddress: UNSPECIFIED_MAC_ADDRESS,
targetMacAddress: this.swiftArp ? UNSPECIFIED_MAC_ADDRESS : ETHERNET_BROADCAST_MAC_ADDRESS,
targetIPAddress,
};
const frame: EthernetFrame = {
@@ -4210,7 +4210,7 @@ export class NetworkRuntimeEngine {
const answered = () =>
(this.stateValue.icmpObservationsByNodeId.get(fromNodeId) ?? [])
.slice(observationCount)
.some((observation) => observation.message.identifier === identifier && observation.message.sequenceNumber === sequenceNumber);
.some((observation) => answersEcho(observation.message, identifier, sequenceNumber));
let sent: IPv4DeliveryResult | undefined;
let sentAt = this.stateValue.currentTimeMilliseconds;
const handedOn = this.sendIPv4PacketWithOptions(fromNodeId, packet, sourceInterface.portId, true, 'sent', {
@@ -4229,9 +4229,7 @@ export class NetworkRuntimeEngine {
}
const result = sent ?? handedOn;
const newObservations = (this.stateValue.icmpObservationsByNodeId.get(fromNodeId) ?? []).slice(observationCount);
const observation = newObservations.findLast(
(candidate) => candidate.message.identifier === identifier && candidate.message.sequenceNumber === sequenceNumber,
);
const observation = newObservations.findLast((candidate) => answersEcho(candidate.message, identifier, sequenceNumber));
if (observation !== undefined) {
switch (observation.message.kind) {
case 'echoReply':
@@ -4533,11 +4531,12 @@ export class NetworkRuntimeEngine {
detail: 'TTL decremented',
});
// Java: a router whose ARP finds no one answers host unreachable; for
// ICMP only an echo request (ICMP.sendUnicastToNextHop).
// ICMP only an echo request (ICMP.sendUnicastToNextHop). The error carries
// the packet as the router forwarded it, its TTL lowered (measured).
const echoOrNotIcmp = packet.payload.kind !== 'icmp' || packet.payload.message.kind === 'echoRequest';
const result = this.sendIPv4PacketWithOptions(nodeId, forwarded, undefined, false, 'forwarded', {
onUnresolved: () => {
if (echoOrNotIcmp) this.emitIcmpError(nodeId, networkInterface, 'destinationHostUnreachable', packet);
if (echoOrNotIcmp) this.emitIcmpError(nodeId, networkInterface, 'destinationHostUnreachable', forwarded);
},
onFinished: finished,
});
@@ -4646,11 +4645,8 @@ export class NetworkRuntimeEngine {
protocol: 'icmp',
payload: {
kind: 'icmp',
message: icmpMessage(kind, {
identifier: icmpIdentifier(originalPacket),
sequenceNumber: icmpSequenceNumber(originalPacket),
embeddedOriginalPacket: embeddedOriginal(originalPacket),
}),
// Identifier and sequence number unused, 0 (RFC 792, Java's sendeICMP for errors).
message: icmpMessage(kind, { embeddedOriginalPacket: embeddedOriginal(originalPacket) }),
},
};
this.recordTrace({
@@ -4672,11 +4668,7 @@ export class NetworkRuntimeEngine {
kind: IcmpMessageKind,
originalPacket: IPv4Packet,
): void {
const message = icmpMessage(kind, {
identifier: icmpIdentifier(originalPacket),
sequenceNumber: icmpSequenceNumber(originalPacket),
embeddedOriginalPacket: embeddedOriginal(originalPacket),
});
const message = icmpMessage(kind, { embeddedOriginalPacket: embeddedOriginal(originalPacket) });
const packet: IPv4Packet = {
identity: this.allocatePacketIdentity(),
senderIPAddress: this.networkInterfaces(nodeId)[0]?.ipAddress ?? UNSPECIFIED_IP_ADDRESS,
@@ -5010,13 +5002,13 @@ function embeddedOriginal(packet: IPv4Packet): IPv4Packet {
return packet;
}
function icmpIdentifier(packet: IPv4Packet): number {
return packet.payload.kind === 'icmp' ? packet.payload.message.identifier : 0;
/** The echo reply to an echo request, or an error about it (it carries the request). */
function answersEcho(message: IcmpMessage, identifier: number, sequenceNumber: number): boolean {
if (message.kind === 'echoReply') return message.identifier === identifier && message.sequenceNumber === sequenceNumber;
const carried = message.embeddedOriginalPacket?.payload;
return carried?.kind === 'icmp' && carried.message.kind === 'echoRequest' && carried.message.identifier === identifier && carried.message.sequenceNumber === sequenceNumber;
}
function icmpSequenceNumber(packet: IPv4Packet): number {
return packet.payload.kind === 'icmp' ? packet.payload.message.sequenceNumber : 0;
}
export function ethernetHeaders(frame: EthernetFrame): HeaderField[] {
return [