Internal · SpaceMusic Engineering · Plan 033 · Revision 3

File loading without the patch-side machinery

One CSV row replaces three, the file dialog stops touching the patch, and the path becomes a regular channel value that scene save handles by default.

The least elegant corner of SpaceMusic

Every plugin we have loads a file from somewhere. There are about a dozen distinct load sites across the system — Plugin1D, 2D, 3D, Light, Stage, PostFX, Placement, Layer-Object, AudioPlayer, MidiPlayer, plus the scene save/load. For a system that loads as many files as we do, the way we declare a load site is the part of the codebase the user finds least elegant.

Declaring a load site today costs us three CSV rows and a sub-patch (FileNameManager) that exists only to keep dropdowns in sync with the disk. We've already replaced the OS file dialog with our own Avalonia browser; everything else around it is still patch glue.

How a load site looks today

For every plugin that loads a file, the parameter CSV carries three rows. Concretely, for a Plugin1D instance:

Plugin1DNFolder,Dropdown,5,...,Environment,Plugins 1D,Plugin 1D N,Files,...
Plugin1DNFile,Dropdown,5,...,Environment,Plugins 1D,Plugin 1D N,Files,rep:true,...
Plugin1DNLoad,Button,2,...,Environment,Plugins 1D,Plugin 1D N,Files,...

The first declares a Library subfolder dropdown. The second declares a file-within-folder dropdown. The third declares the load button. Each plugin family — twelve of them — repeats this triple. The two dropdowns are populated at runtime by a patch (FileNameManager in SpaceMusic_UI.vl) that enumerates the disk on each frame and writes the results to the dropdown channels.

The load button fires the Avalonia browser through SmBrowserDialog, which works well — that's not the problem. The problem is everything around it: the dropdown sync patch and the per-plugin three-row decoration.

This bookkeeping has worked, but it leaks. There is no single place in the codebase that says "a Plugin1D loads audio." The fact is implicit in the default folder name, the file-dropdown contents, and a constant in a helper sub-patch. Adding a new plugin family means writing the three rows by hand and wiring the file-dropdown into the plugin's own logic.

What's good, what isn't

The Avalonia browser itself is a finished story. Plan 021 replaced the OS dialog with an in-app picker that knows about content types (ImageFormat, AudioFormat, VideoFormat, SceneFormat, ObjectFormat, DocumentFormat). It opens through IBrowserService.OpenDialogAsync, accepts a root folder and a format filter, and returns a BrowserDialogResult { FilePath, Ok }. Single instance, modal, no surprises.

What hasn't followed the browser into C# is the wiring around it. The click-to-dialog path still goes through a patch-side bang. The dialog-to-channel path is a per-plugin sub-patch. Two different abstractions for what's logically one thing: a file got loaded — remember the path on this plugin.

The eight *Dropdown01..08 rows on each plugin look like they might be related — they aren't. Those are general-purpose parameter dropdowns each plugin owns for its own logic. This plan leaves them alone.

One row, one load site

The plan introduces a new CSV TypeID, FileLoader, whose job is to be the only thing a plugin author needs to write to declare that the plugin loads a file. One row replaces the existing three. The row carries everything — display label, default format, default subfolder, opt-in dropdown flag:

Plugin1DNFile,FileLoader,30,,Load File..,...,format:Audio;subfolder:Audio;dropdown:true,...,Plugin1D,...

From that single row, codegen produces a plain ParamString for the path plus two override channels (and one more for the optional dropdown), registers the site in a manifest the runtime consumes, and adds the plugin model to a small IPluginWithFileLoader interface. The Pro UI renderer learns one new control kind. A new LoadFileService in C# takes over the click-to-dialog-to-channel-write path. A small FileLoader ProcessNode accepts that interface and exposes the path to vvvv patches by reading the plugin model directly — the same model the patch is already passing into the plugin. The patch-side FileNameManager retires; scene save needs no new wiring because the path is just a regular channel value now.

Figure 1 · What one FileLoader CSV row becomes

CSV ROW FileLoader format:Audio · subfolder:Audio · dropdown:true SMCodeGen PATH ParamString .Value saved with the model OVERRIDE *FileFormat patch-overrideable empty → CSV default OVERRIDE *RootFolder patch-overrideable empty → last-folder OPT-IN *FolderContents Spread<string> dropdown:true only PRO UI CsvPageView FileLoaderRow RUNTIME LoadFileService click → browser → write VVVV NODE FileLoader takes plugin model

The dashed boxes are overrides — empty by default, populated only when a patch wants to deviate from what the CSV declared. The opt-in *FolderContents exists only for rows that opted into the file-flip dropdown. LoadFileService is coral because it's the load-bearing addition; everything else is either codegen plumbing or a renderer rename. Note that there is no registry box — the path lives in the same channel every other parameter uses, and scene-save serializes it as a normal channel value with no special handling.

From row to working load site

The plumbing is mostly extension points we already use, in the same shape. Five steps walk a CSV row to a working load.

Codegen

SMCodeGen's Models.cs:57 CSharpParamType switch already maps every existing CSV type to a leaf class (ParamFloat, ParamUnit, ParamOptions<TEnum>, etc.). Adding the FileLoader case is one new entry that returns the existing ParamString — no new hand-written type. UserData (format:, subfolder:, dropdown:true) is parsed on the same row and stored on the model entry so downstream emitters can read it.

Generated channels and the plugin interface

For each FileLoader row, codegen emits up to four sibling fields on the plugin model: the ParamString path itself, the two override channels (*FileFormat, *RootFolder), and — for dropdown:true rows — a bare Spread<string> *FolderContents field. The overrides are hidden from the Pro UI but visible to the patch: a patch that wants a Layer-Object loader to start at Library/3DObjects writes that string to LayerRootFolder and the next click respects it.

Codegen also emits a partial-class extension on every plugin type with a FileLoader row, implementing a small IPluginWithFileLoader interface that exposes the file fields under uniform names (FilePath, FileFormat, FileRootFolder, FolderContents, DefaultFormat, DefaultSubfolder). That interface is what the vvvv FileLoader node accepts as input — the patch hands the node the plugin model it already has on hand, and the node reads the path through the interface.

Renderer

The Pro UI renderer's single dispatch point is CsvPageView.BuildParamRow at :298. Adding a case UiControlKind.FileLoader there hands off to a new UiFactory.FileLoaderRow. Button label is the loaded file's name, or "Load File.." when the path is empty. The click handler is a direct C# call: _loadFileService.OpenFor(spec.OwnerKey). No channel mediates the click — the dialog is launched only from the UI, so the renderer talks to the service directly. When dropdown:true, a ComboBox sits above the button, bound to *FolderContents; selecting an entry writes the full path straight into the path channel.

Runtime

The new LoadFileService in SpaceMusic.UI.Stride/Channels has a small public surface: Task OpenFor(string ownerKey). On call it looks up the site in a codegen-emitted LoadSiteManifest, reads the override channels through PublicChannelHelper, resolves format and start folder via the override chain (override → last-folder → CSV default), opens IBrowserService.OpenDialogAsync, and on Ok writes the result back to the path channel. It also runs per-frame for dropdown:true sites only: watch the path channel, enumerate the parent folder filtered by format, write to *FolderContents. No bang counters, no top-level registry channel — paths live in the same channels every other parameter uses.

Scene save

Because the path is just a ParamString, scene save already handles it through normal channel-model serialization. The old vvvv-side ProjectFiles record and the FolderFileData sub-record are no longer relevant to the load pipeline; the per-owner folder copy logic is explicitly out of scope for this phase. A future pack project command in the Avalonia browser can enumerate file-loader sites from LoadSiteManifest and bundle source files when needed — but the runtime carries no state for that today.

The escape hatches

Three pieces keep this from being a one-trick design.

  1. Per-plugin override channels. *FileFormat and *RootFolder are siblings on every load site. CSV is the default; the channel is the override. A patch can pin a Layer-Object loader to Library/3DObjects without touching the CSV; empty override falls back to CSV declaration. Plugin1DFileFormat · Plugin1DRootFolder
  2. The dropdown:true flag. The file-flip workflow lives. Opt-in per row: the load button gets a ComboBox above it that lists siblings of the current file matching the resolved format. Picking an entry writes the path directly — no dialog. AudioPlayer1Prev/Next and MidiPlayer1Prev/Next iterate this channel to keep their semantics. UserData: format:Audio;subfolder:Audio;dropdown:true
  3. A vvvv FileLoader node, plugin-model input, read-only. Patches that want to read a plugin's current file path drop this node and feed it the plugin model — the same model the patch already passes to the plugin's Splitter. The node reads plugin.FilePath.Value through an IPluginWithFileLoader interface that codegen implements on every plugin type with a FileLoader CSV row. Plugins without that row don't satisfy the interface; the connection is rejected at edit time, not silently empty at runtime. No trigger input. Input: Plugin (IPluginWithFileLoader) · Output: Path

Why this matters

"The Avalonia browser stopped being the limiting factor a year ago. Everything around it is patch glue — and patch glue costs us every time we add a new plugin."

This is a small plan in code terms. One new TypeID. One hand-written interface (IPluginWithFileLoader) for the patch-side node to bind against. One service class with a single public method. One read-only ProcessNode. One renderer case. A patch deletion (FileNameManager). The codegen extension points were already there for every other parameter type; we're just adding one more entry to switches that already accept entries, plus a partial-class extension on the plugin classes that have a file row.

What it removes is a recurring tax. The cost of adding "this plugin loads a file" to a new plugin family drops from three CSV rows plus a sub-patch entry to one CSV row. The patch loses an entire sub-patch. Scene save gets simpler, not more complex, because the path is just another channel value. And the file node on the patch side no longer needs to know channel paths, owner keys, or even which plugin it's attached to — it accepts the plugin's own model and reads through a uniform interface.

The implementation plan ships in fourteen checkpoint-able steps starting with the FileFormatEnum dropdown and ending with the patch-side cleanup. Plugin1D is the pilot; the bulk migration follows once the codegen and renderer work is proven on one family. None of this is irreversible — the new TypeID and the old three-row pattern can coexist mid-migration if we want to stage the rollout — but the plan calls for full replacement in Phase 1 to avoid carrying two shapes of the same idea.

Settled

FileLoader TypeID + bind-only vvvv node

New CSV row replaces three. The row emits a plain ParamString — no new data type. *FileFormat and *RootFolder stay as per-row overrides. The vvvv node takes the plugin model as input via the codegen-generated IPluginWithFileLoader interface; read-only, no trigger. Full replacement in Phase 1, current branch.

Next step

Step 1 — FileFormatEnum dropdown

Add the six-entry FileFormatEnum (Image · Audio · Video · Object · Scene · Document) to the Dropdowns CSV. Regenerate and confirm the enum compiles. Steps 2–6 are pure codegen and renderer wiring on top of it.

Future

Phase 2 — Pack project command

A future pack project command in the Avalonia browser can enumerate file-loader sites from the codegen manifest and bundle source files into the scene folder. Cross-session last-folder persistence and the SaveScene-target browser dialog round out the file story.