Split a struct output into one pin per field (#256)
game-maker / gate (push) Successful in 3m32s
game-maker / suite (push) Successful in 10m45s

Reading one field of a struct needed its own Expand node, even where
the graph already knows the struct type. A split output shows the
fields directly on the node, so a Find … Match test becomes a single
Equals and the wires keep running left to right.

- Right-clicking an output pin offers "Split into <Type> fields" when
  the type is known and "Split as …" for every other struct asset.
  The output is replaced by one pin per field with the field's kind.
- The type is known for typed struct variables, typed Pack/Expand,
  fields of another split and item of For each, Find, Match and Item
  at over a typed list (portSplits.ts).
- Right-clicking a field pin offers "Recombine". Splitting drops the
  edges of the whole output, recombining drops the field edges. One
  undo step brings them back.
- The split is stored as GraphNode.splits and saved with the graph.
  Copy and paste carry it along.
- Field pins follow the struct asset like a typed Expand. A new field
  gets a pin, a removed field loses pin and edge, a renamed field keeps
  its edge. Deleting the asset keeps the last fields. The delete dialog
  counts splits, so it now says "node(s)" instead of "Pack/Expand
  node(s)".
- Renaming a variable carries its split and the field edges along.
- At run time the node still produces the whole value. A field pin
  reads its field like Expand, a missing field reads as empty.
- Limits: outputs only, one level, behavior graphs only.

Tests:
- ./check.sh: 185 files, 2706 tests, CHECK EXIT 0
- e2e GM_STEPS=1-6,54,55: ALL PASSED, 303 checks
- New port-splits.test.ts with 12 tests. e2e 6z-vi with 3 checks
  using a real mouse: split Find.item, wire item.Name into the test
  and Match.item.Amount into the log (logs 7), recombine.
- Counter-checks: without field reads in the interpreter, without
  rename in the split, without moving edges on rename and without
  cleanup on field removal one unit test fails each. In the browser,
  without field reads nothing is logged and without type inference
  the split entry is missing.
- Check total in README: 1520.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-09-28 11:33:38 +02:00
1 parent 2aaca01929
commit 38e0f656be
17 files changed
+705 -22

No files matched your search

+13
View File
@@ -31,10 +31,23 @@ export interface GraphNode {
* behavior its signature; an Expand/Pack node is what gives a struct its fields.
*/
ports?: DeclaredPort[];
/** Struct outputs shown as one output per field instead of a whole (see `portSplits.ts`). */
splits?: PortSplit[];
/** Drawn as its title bar alone, every link meeting at one point per side. Purely visual. */
collapsed?: boolean;
}
/**
* One output taken apart: `port` stands in the node as `port.field` for each of `fields`, a copy of
* the fields of the Struct asset `structType`, kept in step by `syncStructTypes` like the ports of a
* typed `Expand`. A type that is gone leaves the copy as it was.
*/
export interface PortSplit {
port: string;
structType: string;
fields: DeclaredPort[];
}
/** A port a start/end node declares. `kind` travels as the kind table says (`text` → string). */
export interface DeclaredPort {
key: string;
+12 -4
View File
@@ -1,7 +1,8 @@
import { blockProblem, MATCH_INPUT, matchOf } from './findBlock';
import { edgesInto, edgesOutOf, type Graph, type GraphNode } from './Graph';
import { type GraphLookup, NO_LOOKUP } from './lookup';
import { BEHAVIOR_END, callEntries, resolveDef, terminalNode } from './nested';
import { BEHAVIOR_END, callEntries, runDef, terminalNode } from './nested';
import { fieldValue, splitField, withSplitValues } from './portSplits';
import type { PropertyBag, PropertyValue } from '../model/Properties';
import type { NodeContext } from './NodeContext';
import {
@@ -294,7 +295,11 @@ export class GraphInterpreter {
private inputValue(node: GraphNode, port: PortDef, ctx: NodeContext): Value {
const edge = edgesInto(this.frame.graph, node.id, port.key)[0];
return edge ? this.readOutput(edge.from.node, edge.from.port, ctx) : inlineValue(node, port);
if (!edge) return inlineValue(node, port);
const source = this.node(edge.from.node);
const split = source && splitField(source, edge.from.port);
if (!split) return this.readOutput(edge.from.node, edge.from.port, ctx);
return fieldValue(this.readOutput(edge.from.node, split.split.port, ctx), split.field);
}
/** Value of one output port: from the pulse cache, or by evaluating a pure data node. */
@@ -338,11 +343,14 @@ export class GraphInterpreter {
}
private record(nodeId: string, values: Record<string, Value>): void {
this.trace?.onValues?.(this.frame.graph, nodeId, values);
if (!this.trace?.onValues) return;
const node = this.node(nodeId);
this.trace.onValues(this.frame.graph, nodeId, node ? withSplitValues(node, values) : values);
}
/** Without split field ports: a node produces its wholes, and `inputValue` picks the fields. */
private def(node: GraphNode) {
return resolveDef(node, this.frame.graph, this.lookup);
return runDef(node, this.frame.graph, this.lookup);
}
private node(id: string): GraphNode | undefined {
+1
View File
@@ -8,6 +8,7 @@ export * from './findBlock';
export * from './nested';
export * from './NodeContext';
export * from './NodeRegistry';
export * from './portSplits';
export * from './portTypes';
export * from './propertyPorts';
export * from './savedValues';
+15 -1
View File
@@ -14,6 +14,7 @@ import {
import type { GraphLookup } from './lookup';
import { nodeDef, type NodeDef, type PortDef } from './NodeRegistry';
import { lerpProblem } from './nodes/data/Lerp';
import { withSplits } from './portSplits';
import { agreedType, type SharedPortGroup } from './portTypes';
export const BEHAVIOR_START = 'BehaviorStart';
@@ -144,6 +145,19 @@ export function declaredPorts(graph: Graph | undefined, type: string): PortDef[]
* type declares ports per node (`declaresPorts`) or computes them (`derivePorts`).
*/
export function resolveDef(node: GraphNode, graph: Graph, lookup: GraphLookup): NodeDef | undefined {
const def = shapedDef(node, graph, lookup, true);
return def && splitDef(def, node);
}
function splitDef(def: NodeDef, node: GraphNode): NodeDef {
return withSplits(def, node, (key, field) => ({ ...portDefOf(field), key, label: key }));
}
/**
* The definition a run works with: `resolveDef` without the field ports of split outputs. The node
* produces its wholes as always; reading a field port picks the field out of one (`splitField`).
*/
export function runDef(node: GraphNode, graph: Graph, lookup: GraphLookup): NodeDef | undefined {
return shapedDef(node, graph, lookup, true);
}
@@ -209,7 +223,7 @@ function feedingType(
if (!edge) return undefined;
const from = findNode(graph, edge.from.node);
const def = from && shapedDef(from, graph, lookup, inherit);
return def?.outputs.find((p) => p.key === edge.from.port)?.type;
return def && splitDef(def, from).outputs.find((p) => p.key === edge.from.port)?.type;
}
/**
+62
View File
@@ -0,0 +1,62 @@
/**
* Split outputs: a struct output shown as one output per field, so reading a field needs no `Expand`
* of its own. Only the drawing and the wiring see the field ports – `resolveDef` puts them in place of
* the whole. The node itself still produces the whole value, and the interpreter picks the field out
* of it when a field port is read (`fieldOfSplit`).
*/
import type { GraphNode, PortSplit } from './Graph';
import type { NodeDef, PortDef } from './NodeRegistry';
import { asStruct, unplaced, type Value } from './values';
/** Declared keys are identifiers, so the dot never clashes with a port of the node's own. */
export function splitPortKey(port: string, field: string): string {
return `${port}.${field}`;
}
export function splitOf(node: GraphNode, port: string): PortSplit | undefined {
return node.splits?.find((s) => s.port === port);
}
/** Which split output a key names, and which field of it – undefined for any other key. */
export function splitField(node: GraphNode, key: string): { split: PortSplit; field: string } | undefined {
const dot = key.indexOf('.');
if (dot < 0) return undefined;
const split = splitOf(node, key.slice(0, dot));
const field = key.slice(dot + 1);
return split?.fields.some((f) => f.key === field) ? { split, field } : undefined;
}
/** Whether an output can be taken apart: a value that may hold a struct, and not already split. */
export function canSplit(node: GraphNode, port: PortDef): boolean {
return (port.type === 'struct' || port.type === 'any') && !port.ghost && !splitOf(node, port.key);
}
/** The outputs as drawn and wired: each split one replaced, where it stood, by its field ports. */
export function withSplits(
def: NodeDef,
node: GraphNode,
portOf: (key: string, kind: PortSplit['fields'][number]) => PortDef,
): NodeDef {
if (!node.splits?.length) return def;
const outputs = def.outputs.flatMap((port) => {
const split = port.type === 'exec' ? undefined : splitOf(node, port.key);
return split ? split.fields.map((f) => portOf(splitPortKey(port.key, f.key), f)) : [port];
});
return { ...def, outputs };
}
/** One field of a whole output, read the way `Expand` reads it: a field the value lacks is empty. */
export function fieldValue(whole: Value | undefined, field: string): Value {
return unplaced(asStruct(whole)[field]) ?? null;
}
/** What a node produced, with the value of every field port of its splits beside the wholes. */
export function withSplitValues(node: GraphNode, values: Record<string, Value>): Record<string, Value> {
if (!node.splits?.length) return values;
const out = { ...values };
for (const split of node.splits)
if (split.port in values)
for (const f of split.fields)
out[splitPortKey(split.port, f.key)] = fieldValue(values[split.port], f.key);
return out;
}