diff --git a/src/core/commands/variableCommands.ts b/src/core/commands/variableCommands.ts index 29ff71ec..44229060 100644 --- a/src/core/commands/variableCommands.ts +++ b/src/core/commands/variableCommands.ts @@ -65,6 +65,48 @@ export class AddVariableCommand implements Command { } } +/** + * Declares variables of one behavior in another as they are declared there (#434): default, Keep + * and Private included, for copied nodes that name them. A key the target has already is left alone. + */ +export class CarryVariablesCommand implements Command { + readonly label = 'Carry variables'; + private added: string[] = []; + + constructor( + private readonly assetId: string, + private readonly fromId: string, + private readonly keys: readonly string[], + ) {} + + execute(project: Project): void { + const from = requireBehavior(project, this.fromId); + const to = requireBehavior(project, this.assetId); + this.added = []; + for (const key of this.keys) { + const def = from.variables.find((v) => v.key === key); + if (!def) throw new CommandError(`${from.name} has no variable "${key}"`); + if (to.variables.some((v) => v.key === key)) continue; + to.variables.push(structuredClone(def)); + to.props[key] = structuredClone(from.props[key] ?? def.default); + if (isKeptVariable(from, key)) setKeptVariable(to, key, true); + if (isPrivateVariable(from, key)) setPrivateVariable(to, key, true); + this.added.push(key); + } + } + + undo(project: Project): void { + const to = requireBehavior(project, this.assetId); + const added = new Set(this.added); + to.variables = to.variables.filter((v) => !added.has(v.key)); + for (const key of added) { + delete to.props[key]; + setKeptVariable(to, key, false); + setPrivateVariable(to, key, false); + } + } +} + /** One placed object's own value for a variable, remembered so that undo can put it back. */ interface TakenOverride { holderId: string; diff --git a/src/editor/agent/catalog/graphs.ts b/src/editor/agent/catalog/graphs.ts index 10ad8892..06e53215 100644 --- a/src/editor/agent/catalog/graphs.ts +++ b/src/editor/agent/catalog/graphs.ts @@ -19,6 +19,7 @@ import { ResizeNodeCommand, SetNodeParamCommand, } from '../../../core/commands/graphCommands'; +import { PasteGraphCommand } from '../../../core/commands/pasteCommands'; import { AddPortCommand, MovePortCommand, @@ -34,25 +35,33 @@ import { SetStructTypeCommand, SplitOutputCommand, } from '../../../core/commands/structTypeCommands'; +import { CarryVariablesCommand } from '../../../core/commands/variableCommands'; import { type AnnotationKind, type DeclaredPort, + declaresVariablePorts, findAnnotation, findNode, type Graph, + type GraphNode, + nodeDef, portKindOf, + variablesSourceOf, } from '../../../core/graph'; +import { copyGraph } from '../../../core/model/clipboard'; import type { PropertyBag, PropertyValue } from '../../../core/model/Properties'; -import { findGraphAsset, type Project } from '../../../core/model/Project'; +import { findGraphAsset, type GraphHolder, type Project } from '../../../core/model/Project'; import { DECLARED_PORT_KINDS, type DeclaredPortKind } from '../../../core/properties/kinds'; import { coerceValue } from '../../../core/properties/PropertySchema'; import { freeNoteBox, groupBoxAround } from '../../graph/annotationDrag'; +import { boxesAbout } from '../../graph/annotationFollow'; import { arrangeCommand, layoutOf } from '../../graph/graphLayout'; -import { type PaletteEntry, paletteEntries } from '../../graph/paletteEntries'; +import { fittingPart, type PaletteEntry, paletteEntries } from '../../graph/paletteEntries'; import { pickedPortChoices } from '../../graph/portChoices'; import { type Args, type BuildContext, + type Built, type CatalogCommand, flag, GRAPH, @@ -122,6 +131,32 @@ export const GRAPH_COMMANDS: readonly CatalogCommand[] = [ }, shows: (args, made) => ({ kind: 'nodes', asset: text(args, 'asset'), ids: Object.values(made) }), }, + { + name: 'copy_nodes', + description: + 'Copies nodes into the same graph or another one, as Ctrl+C and Ctrl+V do: with the links between them ' + + 'and the groups and comments about them alone. made names each copy by the id of its original ' + + '("$copy."; "$copy" is the copy of the first node given), so later steps can link it. Leave x and y ' + + 'out: the copies are then arranged like new nodes when the call has run. With x and y the copy keeps ' + + 'its layout, its top left there. Nodes the other graph cannot hold are refused by name; the variables ' + + 'a copied Variables node reads from its behavior come along into the other behavior.', + args: object( + { + from: id('Id of the asset holding the nodes.'), + nodes: ids('The nodes to copy.'), + to: id('Id of the asset to copy them into; leave out for the same one.'), + x: number('Only to place the copy by hand: its left.'), + y: number('Only to place the copy by hand: its top.'), + }, + ['from', 'nodes'], + ), + build: copyNodes, + shows: (args, made) => ({ + kind: 'nodes', + asset: (args.to as string | undefined) ?? text(args, 'from'), + ids: Object.values(made), + }), + }, { name: 'arrange_graph', description: @@ -588,6 +623,68 @@ function coercedParams(entry: PaletteEntry, params: PropertyBag): PropertyBag { return coerced; } +/** `copy_nodes` (#434): the copies, where they go, and the variables that go along with them. */ +function copyNodes(args: Args, context: BuildContext): Built { + const { project } = context; + const fromId = text(args, 'from'); + const toId = (args.to as string | undefined) ?? fromId; + const nodeIds = listOf(args, 'nodes'); + requireNodes(project, fromId, nodeIds); + const source = findGraphAsset(project, fromId)!; + const target = findGraphAsset(project, toId); + if (!target) throw new CommandError(`There is no graph asset ${toId}`); + const layout = layoutOf(project, fromId, context.measure(fromId))!; + const clip = copyGraph(source.graph, nodeIds, boxesAbout(layout, new Set(nodeIds)))!; + const { skipped } = fittingPart(project, target, clip); + if (skipped.length > 0) { + const named = skipped.map((node) => `${nodeDef(node.type)?.label ?? node.type} (${node.id})`); + throw new CommandError(`${target.name} cannot hold ${named.join(', ')} – leave them out`); + } + const carried = carriedVariables(source, target, clip.nodes); + const placed = typeof args.x === 'number' && typeof args.y === 'number'; + const at = placed ? { x: args.x as number, y: args.y as number } : context.spot(toId); + const corners = [...clip.nodes, ...clip.annotations.map((a) => a.annotation)]; + const shift = { + x: at.x - Math.min(...corners.map((c) => c.x)), + y: at.y - Math.min(...corners.map((c) => c.y)), + }; + const paste = new PasteGraphCommand(toId, clip, shift); + const command = + carried.length > 0 + ? new CompoundCommand(paste.label, [new CarryVariablesCommand(toId, fromId, carried), paste]) + : paste; + const copies = paste.nodeIds; + const made: Record = { id: copies[clip.nodes.findIndex((n) => n.id === nodeIds[0])]! }; + clip.nodes.forEach((node, i) => (made[node.id] = copies[i]!)); + clip.annotations.forEach(({ annotation }, i) => (made[annotation.id] = paste.annotationIds[i]!)); + return placed ? { command, made } : { command, made, unplaced: { asset: toId, nodes: copies } }; +} + +/** + * The variables of `source` that copied Variables nodes read from it, for a copy into another + * behavior that lacks them; one declared there in another kind is refused. + */ +function carriedVariables(source: GraphHolder, target: GraphHolder, nodes: readonly GraphNode[]): string[] { + if (source.type !== 'behavior' || target.type !== 'behavior' || source.id === target.id) return []; + const named = nodes.flatMap((node) => + declaresVariablePorts(node.type) && !variablesSourceOf(node.params) + ? (node.ports ?? []).map((port) => port.key) + : [], + ); + const carried: string[] = []; + for (const key of new Set(named)) { + const def = source.variables.find((v) => v.key === key); + const there = target.variables.find((v) => v.key === key); + if (!def) continue; + if (!there) carried.push(key); + else if (there.kind !== def.kind) + throw new CommandError( + `"${key}" is a ${def.kind} in ${source.name} but a ${there.kind} in ${target.name} – rename one first`, + ); + } + return carried; +} + function requireGraphOf(project: Project, assetId: string): Graph { const holder = findGraphAsset(project, assetId); if (!holder) throw new CommandError(`There is no graph asset ${assetId}`); diff --git a/src/editor/clipboard.ts b/src/editor/clipboard.ts index cfc07d30..5128b4b2 100644 --- a/src/editor/clipboard.ts +++ b/src/editor/clipboard.ts @@ -3,16 +3,7 @@ * copy names assets of this project, which mean nothing anywhere else. */ import { PasteGraphCommand, PasteObjectsCommand } from '../core/commands/pasteCommands'; -import { - BEHAVIOR_PARAM, - findAnnotation, - findNode, - FUNCTION_PARAM, - type GraphNode, - nodeDef, - namesBehavior, - SHADER_CALL, -} from '../core/graph'; +import { findAnnotation, findNode, nodeDef } from '../core/graph'; import { type Clip, copyGraph, copyObjects, type GraphClip, type ObjectClip } from '../core/model/clipboard'; import { findObject } from '../core/model/hierarchy'; import { isLiveInstance } from '../core/model/instance'; @@ -21,12 +12,11 @@ import { findAssetOf, findGraphAsset, findObjectHolder, - type GraphHolder, type ObjectHolder, type Project, } from '../core/model/Project'; import { GRID_STEP } from './graph/graphView'; -import { paletteEntries } from './graph/paletteEntries'; +import { fittingPart } from './graph/paletteEntries'; import { type EditorState, graphSelection, objectSelection } from './state'; import { SCENE_GRID_STEP } from './viewport/sceneViewHost'; @@ -194,34 +184,6 @@ export function pasteObjects( return true; } -/** Whether `asset`'s graph may hold `node`: the palette would offer its kind there. */ -function fitsGraph(project: Project, asset: GraphHolder, node: GraphNode): boolean { - return paletteEntries(project, asset).some( - (entry) => - entry.def.type === node.type && - (!namesBehavior(node.type) || entry.params[BEHAVIOR_PARAM] === node.params[BEHAVIOR_PARAM]) && - (node.type !== SHADER_CALL || entry.params[FUNCTION_PARAM] === node.params[FUNCTION_PARAM]), - ); -} - -/** The part of a graph copy `asset` can take, and the nodes it cannot. */ -function fittingPart( - project: Project, - asset: GraphHolder, - clip: GraphClip, -): { clip: GraphClip; skipped: GraphNode[] } { - const nodes = clip.nodes.filter((n) => fitsGraph(project, asset, n)); - const kept = new Set(nodes.map((n) => n.id)); - return { - clip: { - ...clip, - nodes, - edges: clip.edges.filter((e) => kept.has(e.from.node) && kept.has(e.to.node)), - }, - skipped: clip.nodes.filter((n) => !kept.has(n.id)), - }; -} - /** * Pastes the copied nodes and boxes into `assetId`'s graph, at `pointer` (graph units) or a grid * step beside the original. `snap` keeps a pointer paste on the grid. What the graph cannot take is diff --git a/src/editor/graph/annotationFollow.ts b/src/editor/graph/annotationFollow.ts index 03b29ba3..d83324e3 100644 --- a/src/editor/graph/annotationFollow.ts +++ b/src/editor/graph/annotationFollow.ts @@ -89,6 +89,28 @@ export function followedBoxes( return done; } +/** + * The groups and comments that are about these nodes alone (#434), in the graph's order: a group + * holding some of them and no other node, a comment going with such a group or with nodes all among + * them. + */ +export function boxesAbout(graph: LayoutGraph, ids: ReadonlySet): string[] { + const groups = new Set( + graph.boxes + .filter((box) => { + const members = box.kind === 'group' ? graph.nodes.filter((node) => holds(box, node)) : []; + return members.length > 0 && members.every((node) => ids.has(node.id)); + }) + .map((box) => box.id), + ); + const about = (note: LayoutBox) => { + const anchor = note.kind === 'note' ? anchorOf(graph, note) : null; + if (!anchor) return false; + return 'box' in anchor ? groups.has(anchor.box.id) : anchor.nodes.every((node) => ids.has(node.id)); + }; + return graph.boxes.filter((box) => groups.has(box.id) || about(box)).map((box) => box.id); +} + type Anchor = { box: LayoutBox; below: boolean } | { nodes: LayoutNode[]; below: boolean }; /** What a comment is about, by where it stands now. */ diff --git a/src/editor/graph/paletteEntries.ts b/src/editor/graph/paletteEntries.ts index 3bd8438b..ad58d7c8 100644 --- a/src/editor/graph/paletteEntries.ts +++ b/src/editor/graph/paletteEntries.ts @@ -6,6 +6,7 @@ import { callsFunction, createEmptyGraph, FUNCTION_PARAM, + type GraphNode, isTerminalType, namesBehavior, nextStateName, @@ -22,6 +23,7 @@ import { terminalNode, } from '../../core/graph'; import type { ShaderAsset, ShaderFunctionAsset } from '../../core/model/Asset'; +import type { GraphClip } from '../../core/model/clipboard'; import { FIND, FIND_MODE } from '../../core/graph/findBlock'; import { graphLookup } from '../../core/model/graphLookup'; import type { PropertyBag } from '../../core/model/Properties'; @@ -188,3 +190,32 @@ export function paletteEntries(project: Project, asset: GraphHolder): PaletteEnt } return entries; } + +/** Whether a graph may hold `node`: its palette `entries` offer the node's kind. */ +function fitsGraph(entries: readonly PaletteEntry[], node: GraphNode): boolean { + return entries.some( + (entry) => + entry.def.type === node.type && + (!namesBehavior(node.type) || entry.params[BEHAVIOR_PARAM] === node.params[BEHAVIOR_PARAM]) && + (node.type !== SHADER_CALL || entry.params[FUNCTION_PARAM] === node.params[FUNCTION_PARAM]), + ); +} + +/** The part of a graph copy `asset` can take, and the nodes it cannot (#240, #434). */ +export function fittingPart( + project: Project, + asset: GraphHolder, + clip: GraphClip, +): { clip: GraphClip; skipped: GraphNode[] } { + const entries = paletteEntries(project, asset); + const nodes = clip.nodes.filter((n) => fitsGraph(entries, n)); + const kept = new Set(nodes.map((n) => n.id)); + return { + clip: { + ...clip, + nodes, + edges: clip.edges.filter((e) => kept.has(e.from.node) && kept.has(e.to.node)), + }, + skipped: clip.nodes.filter((n) => !kept.has(n.id)), + }; +} diff --git a/tests/editor/agent-copy.test.ts b/tests/editor/agent-copy.test.ts new file mode 100644 index 00000000..fc88612e --- /dev/null +++ b/tests/editor/agent-copy.test.ts @@ -0,0 +1,185 @@ +import { describe, expect, it } from 'vitest'; +import { isKeptVariable } from '../../src/core/model/Asset'; +import { findAssetOf, findGraphAsset, type Project } from '../../src/core/model/Project'; +import { runTool, type ToolHost } from '../../src/editor/agent/agentTools'; +import { makeSpriteScene, type SceneFixture } from '../helpers'; +import { hostFor, text } from './agentTestHost'; + +/** Copying nodes through the agent's tools (#434). */ + +/** + * Behavior Walk, placed by hand so no group takes in a node not its own: a key held moving its owner, a Variables node reading `speed` (kept), a group round + * key and move, a comment above the Variables node, and a group round the owner alone. + */ +async function walkAndRun(fixture: SceneFixture, host: ToolHost) { + const steps = [ + { command: 'create_asset', args: { type: 'behavior', name: 'Walk' }, as: 'walk' }, + { command: 'create_asset', args: { type: 'behavior', name: 'Run' }, as: 'run' }, + { command: 'add_variable', args: { asset: '$walk', key: 'speed', kind: 'number' } }, + { command: 'set_variable_kept', args: { asset: '$walk', key: 'speed', kept: true } }, + { command: 'add_node', args: { asset: '$walk', node: 'OnKeyHeld', x: 0, y: 0 }, as: 'key' }, + { + command: 'add_node', + args: { asset: '$walk', node: 'Move', params: { dx: -200 }, x: 300, y: 0 }, + as: 'move', + }, + { command: 'add_node', args: { asset: '$walk', node: 'Owner', x: 0, y: 400 }, as: 'owner' }, + { command: 'add_node', args: { asset: '$walk', node: 'Variables', x: 0, y: 700 }, as: 'vars' }, + { command: 'add_port', args: { asset: '$walk', node: '$vars', key: 'speed' } }, + { + command: 'connect', + args: { asset: '$walk', from: { node: '$key', port: 'exec' }, to: { node: '$move', port: 'exec' } }, + }, + { + command: 'connect', + args: { asset: '$walk', from: { node: '$key', port: 'dt' }, to: { node: '$move', port: 'dt' } }, + }, + { + command: 'connect', + args: { + asset: '$walk', + from: { node: '$owner', port: 'object' }, + to: { node: '$move', port: 'object' }, + }, + }, + { command: 'add_group', args: { asset: '$walk', nodes: ['$key', '$move'], title: 'Walk' } }, + { command: 'add_group', args: { asset: '$walk', nodes: ['$owner'], title: 'Who' } }, + { command: 'add_comment', args: { asset: '$walk', nodes: ['$vars'], text: 'How fast' } }, + ]; + const result = await runTool('run_commands', { steps, label: 'walk' }, host); + expect(result.isError, text(result)).toBeFalsy(); + const walk = findAssetOf(fixture.project, assetNamed(fixture, 'Walk'), 'behavior')!; + const run = findAssetOf(fixture.project, assetNamed(fixture, 'Run'), 'behavior')!; + const id = (type: string) => walk.graph.nodes.find((node) => node.type === type)!.id; + return { walk, run, key: id('OnKeyHeld'), move: id('Move'), owner: id('Owner'), vars: id('Variables') }; +} + +/** The project as JSON, an empty list of groups or comments as none – undo leaves one, as for Add group. */ +function plain(project: Project): string { + return JSON.stringify(project, (key, value: unknown) => + (key === 'groups' || key === 'notes') && Array.isArray(value) && value.length === 0 ? undefined : value, + ); +} + +function assetNamed(fixture: SceneFixture, name: string): string { + return fixture.project.assets.find((asset) => asset.name === name)!.id; +} + +describe('copy_nodes', () => { + it('copies nodes into another behavior with their links, groups, comments and variables', async () => { + const fixture = makeSpriteScene('Hero'); + const { host } = hostFor(fixture); + const { walk, run, key, move, vars } = await walkAndRun(fixture, host); + const before = plain(fixture.project); + + const steps = [ + { command: 'copy_nodes', args: { from: walk.id, nodes: [key, move, vars], to: run.id }, as: 'copy' }, + { command: 'add_node', args: { asset: run.id, node: 'Owner' }, as: 'owner' }, + { + command: 'connect', + args: { + asset: run.id, + from: { node: '$owner', port: 'object' }, + to: { node: `$copy.${move}`, port: 'object' }, + }, + }, + ]; + const result = await runTool('run_commands', { steps, label: 'copy' }, host); + expect(result.isError, text(result)).toBeFalsy(); + + const graph = findGraphAsset(fixture.project, run.id)!.graph; + expect(graph.nodes.map((node) => node.type)).toEqual(['OnKeyHeld', 'Move', 'Variables', 'Owner']); + const [copiedKey, copiedMove, copiedVars] = graph.nodes; + expect(copiedMove!.params.dx).toBe(-200); + expect(copiedVars!.ports).toEqual([{ key: 'speed', kind: 'number' }]); + // The link from the Owner left behind stays behind; the one drawn after the copy is there. + expect(graph.edges.map((e) => `${e.from.node}.${e.from.port}>${e.to.node}.${e.to.port}`)).toEqual([ + `${copiedKey!.id}.exec>${copiedMove!.id}.exec`, + `${copiedKey!.id}.dt>${copiedMove!.id}.dt`, + `${graph.nodes[3]!.id}.object>${copiedMove!.id}.object`, + ]); + expect((graph.groups ?? []).map((group) => group.text)).toEqual(['Walk']); + expect((graph.notes ?? []).map((note) => note.text)).toEqual(['How fast']); + + const { step, ...made } = (result.structuredContent as { made: Record[] }).made[0]!; + expect(step).toBe(1); + expect(made).toMatchObject({ + id: copiedKey!.id, + [key]: copiedKey!.id, + [move]: copiedMove!.id, + [vars]: copiedVars!.id, + }); + // The first node's copy, three copies by their originals, the group and the comment. + expect(Object.keys(made)).toHaveLength(6); + + const runNow = findAssetOf(fixture.project, run.id, 'behavior')!; + expect(runNow.variables.map((v) => [v.key, v.kind])).toEqual([['speed', 'number']]); + expect(isKeptVariable(runNow, 'speed')).toBe(true); + // The originals are untouched. + expect(walk.graph.nodes).toHaveLength(4); + + await runTool('undo', {}, host); + expect(plain(fixture.project)).toBe(before); + }); + + it('keeps the layout of a copy placed by hand, its top left where it is told', async () => { + const fixture = makeSpriteScene('Hero'); + const { host } = hostFor(fixture); + const { walk, key, move } = await walkAndRun(fixture, host); + const [from, to] = [key, move].map((nodeId) => walk.graph.nodes.find((n) => n.id === nodeId)!); + const steps = [{ command: 'copy_nodes', args: { from: walk.id, nodes: [move, key], x: 2000, y: 900 } }]; + const result = await runTool('run_commands', { steps, label: 'copy' }, host); + expect(result.isError, text(result)).toBeFalsy(); + + const [copiedKey, copiedMove] = walk.graph.nodes.slice(4); + const group = walk.graph.groups!.find((g) => g.text === 'Walk' && g.x >= 2000)!; + expect(Math.min(copiedKey!.x, group.x)).toBe(2000); + expect(Math.min(copiedKey!.y, group.y)).toBe(900); + expect(copiedMove!.x - copiedKey!.x).toBe(to!.x - from!.x); + expect(copiedMove!.y - copiedKey!.y).toBe(to!.y - from!.y); + // The comment is about the Variables node left behind, the second group about the Owner. + expect(walk.graph.notes).toHaveLength(1); + expect(walk.graph.groups).toHaveLength(3); + // The copy of the first node given answers to "$copy" alone. + const made = (result.structuredContent as { made: Record[] }).made[0]!; + expect(made.id).toBe(copiedMove!.id); + }); + + it('refuses nodes the other graph cannot hold, and a variable of another kind there', async () => { + const fixture = makeSpriteScene('Hero'); + const { host } = hostFor(fixture); + const { walk, run, key, vars } = await walkAndRun(fixture, host); + const made = await runTool( + 'run_commands', + { + steps: [ + { command: 'create_asset', args: { type: 'shader', name: 'Glow' } }, + { command: 'add_variable', args: { asset: run.id, key: 'speed', kind: 'text' } }, + ], + label: 'more', + }, + host, + ); + expect(made.isError, text(made)).toBeFalsy(); + const before = JSON.stringify(fixture.project); + const shader = assetNamed(fixture, 'Glow'); + + const intoShader = await runTool( + 'run_commands', + { steps: [{ command: 'copy_nodes', args: { from: walk.id, nodes: [key], to: shader } }], label: 'x' }, + host, + ); + expect(intoShader.isError).toBe(true); + expect(text(intoShader)).toContain(`Glow cannot hold`); + expect(text(intoShader)).toContain(`(${key}) – leave them out`); + + const clash = await runTool( + 'run_commands', + { steps: [{ command: 'copy_nodes', args: { from: walk.id, nodes: [vars], to: run.id } }], label: 'x' }, + host, + ); + expect(clash.isError).toBe(true); + expect(text(clash)).toContain('"speed" is a number in Walk but a text in Run'); + expect(JSON.stringify(fixture.project)).toBe(before); + }); +}); diff --git a/tools/mcp/relay.mjs b/tools/mcp/relay.mjs index 2f29b1ed..49652313 100644 --- a/tools/mcp/relay.mjs +++ b/tools/mcp/relay.mjs @@ -45,9 +45,11 @@ const INSTRUCTIONS = [ 'over a long link. Check with lint_graph, the crossings too: when there are more than the links', 'need, arrange_graph orders the whole graph for the fewest. Put nodes that belong together into a', 'group with add_group, and say what they do with add_comment; a comment a node lies on', - '(coveredNotes) goes back above its nodes with fit_group. Music plays nothing without an', - 'instrument, and an instrument sounds only the notes its Notes nodes take in, C4 to B4 in a new', - 'one: get_asset lists under silent what of a pattern, song or composition is not heard.', + '(coveredNotes) goes back above its nodes with fit_group. A part already built elsewhere is', + 'copied with copy_nodes, into the same graph or another, rather than built again. Music plays', + 'nothing without an instrument, and an instrument sounds only the notes its Notes nodes take', + 'in, C4 to B4 in a new one: get_asset lists under silent what of a pattern, song or composition', + 'is not heard.', 'apply_preset gives an instrument that sounds every note in one step. Pictures, fonts, sounds', 'and videos are files: list_files lists those the server holds, add_file_asset makes an asset', 'of one. Before deleting or reworking an asset, get_asset says under usedBy what names it.',