Files
mdg-filius/packages/fls/src/topology.ts
T
julian.kiedaischandClaude Opus 5.5 a02bb62413 feat(fls): topology of a .fls file as Java loads it
readTopology maps the decoded JavaBeans graph to devices, cables and
documentation items the way SzenarioVerwaltung.netzwerkLaden does: the
kind from the Java class (the palette label is not file data), devices
without bounds at 0,0, interface values through Java's setters. All 23
corpus files match Java field by field.

Found on the way:
- listItems mixed a constructor's elements with added ones; a router's
  second interface was lost. Lists now replay add/set in order after the
  elements the constructor made.
- The first 0 of a file may be SwingConstants.CENTER; measured like the
  getter defaults.
- IP.ipCheck (javaIp.ts) measured on 46 cases: empty tokens skipped,
  signs, leading zeros and BMP digits of any script accepted.
- The Java oracle took lower-case MACs in a file for random ones (8
  interfaces); it now compares case-insensitively and reports the host
  name flags.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 20:33:02 +00:00

409 lines
16 KiB
TypeScript

// The network drawn in a .fls file — devices, cables, documentation items —
// read from the decoded JavaBeans graph (javabeans.ts) the way Java FILIUS
// loads it (SzenarioVerwaltung.netzwerkLaden). Only the topology: system
// software, file systems and applications are later layers, which start
// from `FlsNode.source`.
//
// The result is Java's view, indexed like Java's lists, and is checked
// against Java itself (corpus.java.json). The editor model (node kinds, UUIDs,
// ports as Swift's TopologyPortMetadata, runtime configurations) is built
// from it in a separate step.
//
// Where this departs from filius-ipad's reader, Java is right (plan §13, M2):
// - the device kind comes from the Java class; the palette label ("Heimrouter")
// is not file data — Java overwrites it with the class's type name;
// - a device without coordinates stands at 0,0, as in Java, instead of
// being dropped;
// - interface values go through Java's setters (IP.ipCheck for address and
// mask, upper-cased MAC).
import { isJavaObject, isUnresolved, listItems, simpleClassName, type JavaObject, type JavaValue } from './javabeans.js';
import { javaIpCheck } from './javaIp.js';
export type FlsNodeClass = 'Rechner' | 'Notebook' | 'Switch' | 'Vermittlungsrechner' | 'Gateway' | 'Modem';
export interface FlsInterface {
/** After IP.ipCheck: a canonical dotted quad, or null for text that is not one. */
ip: string | null;
mask: string | null;
/** Upper-cased, as getMac() returns it; undefined when the file has none (Java rolls a random one). */
mac: string | undefined;
gateway: string | null;
dns: string | null;
}
export interface FlsNode {
javaClass: FlsNodeClass;
name: string | null;
x: number;
y: number;
/** Hosts only (Rechner, Notebook); false for the others. */
useIpAsName: boolean;
useMacAsName: boolean;
/** The interfaces of a Rechner, Notebook, Vermittlungsrechner or Gateway; empty for the others. */
interfaces: FlsInterface[];
/** Interfaces, or the ports of a Switch or Modem. */
portCount: number;
/** The node's JavaBean (`filius.hardware.knoten.*`), for the software layers. */
source: JavaObject;
}
/** A cable end: the node's index and the port's index on it, as in Java's lists. */
export interface FlsPortReference {
node: number;
port: number;
}
export interface FlsLink {
ends: [FlsPortReference, FlsPortReference];
wireless: boolean;
}
export type FlsColor = [red: number, green: number, blue: number, alpha: number];
export interface FlsFont {
name: string;
size: number;
style: number;
}
export interface FlsDocumentationItem {
/** GUIDocuItem.RECT = 1, TEXT = 2; 0 when the file sets none. */
type: number;
text: string | null;
x: number;
y: number;
width: number;
height: number;
color: FlsColor | null;
font: FlsFont | null;
}
export interface FlsTopology {
/** The version line newer files start with ("Filius version: 1.7.4 (…)"), else null. */
versionLine: string | null;
nodes: FlsNode[];
links: FlsLink[];
documentation: FlsDocumentationItem[];
/** What Java would read and this reader could not, and what it left out. */
warnings: string[];
}
interface NodeClassInfo {
/** Elements the constructor creates: interfaces (InternetKnoten) or ports (LokalerKnoten). */
constructed: number;
local: boolean;
host: boolean;
/**
* The name the constructor gives, in German — the locale Java FILIUS starts
* in (and the one corpus.java.json was measured with). An English FILIUS
* would say "Computer" or "Router" here.
*/
defaultName: string;
}
const NODE_CLASSES: Record<FlsNodeClass, NodeClassInfo> = {
Rechner: { constructed: 1, local: false, host: true, defaultName: 'Rechner' },
Notebook: { constructed: 1, local: false, host: true, defaultName: 'Notebook' },
Vermittlungsrechner: {
constructed: 1,
local: false,
host: false,
defaultName: 'Vermittlungsrechner',
},
Gateway: { constructed: 2, local: false, host: false, defaultName: 'Heimrouter' },
Switch: { constructed: 24, local: true, host: false, defaultName: 'Switch / WLAN' },
Modem: { constructed: 1, local: true, host: false, defaultName: 'Modem' },
};
const DEVICE_PACKAGE = 'filius.hardware.knoten.';
function isFlsNodeClass(name: string): name is FlsNodeClass {
return Object.hasOwn(NODE_CLASSES, name);
}
/** Maps the top-level objects of `konfiguration.xml` to the topology. */
export function readTopology(values: readonly JavaValue[]): FlsTopology {
const warnings: string[] = [];
const context = new Reader(warnings);
// netzwerkLaden: an optional version string, then the node list, the
// cable list and the documentation list, each by position. A list counts
// only if its first element has the expected class.
let position = 0;
let versionLine: string | null = null;
if (typeof values[0] === 'string') {
versionLine = values[0];
position = 1;
}
const nodeItems = listOf(values[position], 'filius.gui.netzwerksicht.GUIKnotenItem');
const cableItems = listOf(values[position + 1], 'filius.gui.netzwerksicht.GUIKabelItem');
const documentationItems = listOf(values[position + 2], 'filius.gui.netzwerksicht.GUIDocuItem');
const nodes: FlsNode[] = [];
for (const [index, item] of nodeItems.entries()) {
const node = context.node(item, index, nodes.length);
if (node !== undefined) nodes.push(node);
}
const links: FlsLink[] = [];
for (const [index, item] of cableItems.entries()) {
const link = context.link(item, index);
if (link !== undefined) links.push(link);
}
const documentation = documentationItems.flatMap((item) => (isJavaObject(item) ? [context.documentation(item)] : []));
return { versionLine, nodes, links, documentation, warnings };
}
/** The elements of `value` if it is a list whose first element is an object of `className`. */
function listOf(value: JavaValue | undefined, className: string): JavaValue[] {
const items = listItems(value);
const first = items[0];
return isJavaObject(first) && first.className === className ? items : [];
}
class Reader {
/** Every port object a node owns, to resolve cable ends by identity as Java does. */
private readonly portOwners = new Map<JavaObject, FlsPortReference>();
constructor(private readonly warnings: string[]) {}
node(item: JavaValue, itemIndex: number, nodeIndex: number): FlsNode | undefined {
const device = isJavaObject(item) ? item.properties.get('knoten') : undefined;
if (!isJavaObject(device)) {
this.warnings.push(`node ${itemIndex + 1}: no device (knoten), left out`);
return undefined;
}
const simpleName = simpleClassName(device.className);
if (!device.className.startsWith(DEVICE_PACKAGE) || !isFlsNodeClass(simpleName)) {
this.warnings.push(`node ${itemIndex + 1}: unknown device class ${device.className}, left out`);
return undefined;
}
const info = NODE_CLASSES[simpleName];
const [x, y] = this.position(item as JavaObject, itemIndex);
const node: FlsNode = {
javaClass: simpleName,
name: this.stringProperty(device, 'name', info.defaultName, `node ${itemIndex + 1}`),
x,
y,
useIpAsName: info.host && this.booleanProperty(device, 'useIPAsName', `node ${itemIndex + 1}`),
useMacAsName: info.host && this.booleanProperty(device, 'useMACAsName', `node ${itemIndex + 1}`),
interfaces: [],
portCount: 0,
source: device,
};
if (info.local) {
const ports = this.constructedList(device, 'anschluesse', info.constructed);
node.portCount = ports.length;
ports.forEach((port, portIndex) => this.ownPort(port, nodeIndex, portIndex));
} else {
const networkInterfaces = this.constructedList(device, 'netzwerkInterfaces', info.constructed);
node.interfaces = networkInterfaces.map((nic, nicIndex) =>
this.networkInterface(nic, simpleName === 'Gateway' && nicIndex === 0, `node ${itemIndex + 1}`),
);
node.portCount = networkInterfaces.length;
networkInterfaces.forEach((nic, nicIndex) => {
if (isJavaObject(nic)) this.ownPort(nic.properties.get('port'), nodeIndex, nicIndex);
});
}
return node;
}
/**
* A list property the constructor filled: a getter's result keeps the
* constructed elements, a list the file sets replaces them.
*/
private constructedList(device: JavaObject, property: string, constructed: number): JavaValue[] {
const list = device.properties.get(property);
// Untouched: the constructed elements, which carry nothing but defaults.
if (list === undefined) return Array.from({ length: constructed }, () => null);
return listItems(list, isJavaObject(list) && list.fromGetter ? constructed : 0);
}
private ownPort(port: JavaValue | undefined, node: number, index: number): void {
if (isJavaObject(port) && !this.portOwners.has(port)) this.portOwners.set(port, { node, port: index });
}
/**
* The imageLabel's bounds: fields set one by one in newer files,
* `new Rectangle(x, y, width, height)` in older ones. Without either, Java's
* label sits at 0,0.
*/
private position(item: JavaObject, itemIndex: number): [number, number] {
const label = item.properties.get('imageLabel');
const bounds = isJavaObject(label) ? label.properties.get('bounds') : undefined;
if (!isJavaObject(bounds)) return [0, 0];
return (['x', 'y'] as const).map((field, index) => {
const value = bounds.properties.get(field) ?? bounds.args[index] ?? 0;
if (typeof value === 'number') return value;
this.warnings.push(`node ${itemIndex + 1}: position ${field} is ${describe(value)}, used 0`);
return 0;
}) as [number, number];
}
/**
* NetzwerkInterface as its constructor leaves it, then the file's setters.
* A Gateway's constructor gives its first interface (WAN) its own address.
*/
private networkInterface(nic: JavaValue, gatewayWan: boolean, where: string): FlsInterface {
const result: FlsInterface = {
ip: gatewayWan ? '42.0.0.10' : '192.168.0.10',
mask: gatewayWan ? '255.0.0.0' : '255.255.255.0',
mac: undefined,
gateway: '',
dns: '',
};
if (!isJavaObject(nic)) return result;
// Only the constructor's own interface gets the WAN address; one the file
// creates with `new NetzwerkInterface()` starts like any other.
if (gatewayWan && !nic.fromGetter) {
result.ip = '192.168.0.10';
result.mask = '255.255.255.0';
}
const at = `${where}, interface`;
for (const [property, field] of [
['ip', 'ip'],
['subnetzMaske', 'mask'],
] as const) {
const value = nic.properties.get(property);
if (value === undefined) continue;
// setIp(null) throws inside Java's setter; XMLDecoder swallows that and
// the constructor's value stays.
if (typeof value === 'string') result[field] = javaIpCheck(value);
else this.unexpected(at, property, value);
}
const mac = nic.properties.get('mac');
if (typeof mac === 'string') result.mac = mac.toUpperCase();
else if (mac !== undefined && mac !== null) this.unexpected(at, 'mac', mac);
for (const [property, field] of [
['gateway', 'gateway'],
['dns', 'dns'],
] as const) {
const value = nic.properties.get(property);
if (value === undefined) continue;
if (typeof value === 'string' || value === null) result[field] = value;
else this.unexpected(at, property, value);
}
return result;
}
link(item: JavaValue, itemIndex: number): FlsLink | undefined {
const cable = isJavaObject(item) ? item.properties.get('dasKabel') : undefined;
if (!isJavaObject(cable)) {
this.warnings.push(`cable ${itemIndex + 1}: no cable (dasKabel), left out`);
return undefined;
}
// Kabel.setAnschluesse(Port[]) in newer files, new Kabel(port, port) in older ones.
const declared = cable.properties.get('anschluesse');
const ports = declared === undefined ? cable.args : listItems(declared);
const ends = ports.map((port) => (isJavaObject(port) ? this.portOwners.get(port) : undefined));
const [first, second] = ends;
if (ends.length !== 2 || first === undefined || second === undefined) {
this.warnings.push(`cable ${itemIndex + 1}: an end is not a port of a device, left out`);
return undefined;
}
return {
ends: [first, second],
wireless: this.booleanProperty(cable, 'wireless', `cable ${itemIndex + 1}`),
};
}
documentation(item: JavaObject): FlsDocumentationItem {
const where = 'documentation item';
const integer = (property: string): number => {
const value = item.properties.get(property);
if (value === undefined) return 0;
if (typeof value === 'number' && Number.isInteger(value)) return value;
this.unexpected(where, property, value);
return 0;
};
const text = item.properties.get('text');
return {
type: integer('type'),
text: typeof text === 'string' ? text : null,
x: integer('x'),
y: integer('y'),
width: integer('width'),
height: integer('height'),
color: this.color(item.properties.get('color'), where),
font: this.font(item.properties.get('font'), where),
};
}
/**
* `java.awt.Color` as XMLEncoder writes it: `new Color(r, g, b, a)`. Also
* `new Color(rgb)` and `new Color(r, g, b)` (ColorUIResource). A component
* outside 0…255 makes Java's constructor throw — the color stays unset.
*/
private color(value: JavaValue | undefined, where: string): FlsColor | null {
if (value === undefined || value === null) return null;
if (!isJavaObject(value)) {
this.unexpected(where, 'color', value);
return null;
}
const args = value.args;
if (!args.every((arg) => typeof arg === 'number' && Number.isInteger(arg))) {
this.warnings.push(`${where}: color ${value.className} with arguments this reader does not know, left unset`);
return null;
}
const numbers = args as number[];
if (numbers.length === 1) {
const rgb = numbers[0]!;
return [(rgb >> 16) & 0xff, (rgb >> 8) & 0xff, rgb & 0xff, 255];
}
if (numbers.length === 3 || numbers.length === 4) {
const rgba = numbers.length === 3 ? [...numbers, 255] : numbers;
if (rgba.every((component) => component >= 0 && component <= 255)) return rgba as FlsColor;
this.warnings.push(`${where}: color component outside 0…255, left unset (as in Java)`);
return null;
}
this.warnings.push(`${where}: color with ${numbers.length} arguments, left unset`);
return null;
}
/** `new Font(name, style, size)`, also as FontUIResource. Java keeps style 0…3 and makes others 0. */
private font(value: JavaValue | undefined, where: string): FlsFont | null {
if (value === undefined || value === null) return null;
const [name, style, size] = isJavaObject(value) ? value.args : [];
if (
!isJavaObject(value) ||
value.args.length !== 3 ||
(typeof name !== 'string' && name !== null) ||
typeof style !== 'number' ||
typeof size !== 'number'
) {
this.unexpected(where, 'font', value);
return null;
}
return { name: name ?? 'Default', size, style: (style & ~0x03) === 0 ? style : 0 };
}
private stringProperty(target: JavaObject, property: string, fallback: string, where: string): string | null {
const value = target.properties.get(property);
if (value === undefined) return fallback;
if (typeof value === 'string' || value === null) return value;
this.unexpected(where, property, value);
return fallback;
}
private booleanProperty(target: JavaObject, property: string, where: string): boolean {
const value = target.properties.get(property);
if (value === undefined) return false;
if (typeof value === 'boolean') return value;
this.unexpected(where, property, value);
return false;
}
private unexpected(where: string, property: string, value: JavaValue): void {
this.warnings.push(`${where}: ${property} is ${describe(value)}, ignored`);
}
}
function describe(value: JavaValue): string {
if (isUnresolved(value)) return `unresolved (${value.reason})`;
if (isJavaObject(value)) return `an object of ${value.className}`;
return JSON.stringify(typeof value === 'bigint' ? String(value) : value) ?? String(value);
}