# Plan 033 — File handling: new `FileLoader` CSV type + LoadFileService

## Context

Today every plugin in SpaceMusic that loads a file (1D / 2D / 3D / Light / Stage / PostFX / Placement / Layer-Object / AudioPlayer / MidiPlayer / SaveScene / LoadScene) uses **three CSV rows** — `*Folder` (Dropdown), `*File` (Dropdown), `*Load` (Button). The actual dialog routes through the Avalonia browser via [`SmBrowserDialog`](SpaceMusic/csharp/SpaceMusic.UI.Stride/SmBrowserDialog.cs) (rising-edge bang on `open`, returns `filePath`), but the open-on-Load wiring and the per-plugin Folder/File dropdown population live in vvvv (`SpaceMusic_UI.vl` `FileNameManager`).

The per-plugin `*Dropdown01..08` (UpdateDropdown / TypeID 22) rows are **unrelated** to file loading — they're general-purpose plugin parameter dropdowns each plugin owns for its own purposes. Out of scope for this plan.

This is the part of the codebase the user finds least elegant. The Avalonia browser already replaces the system file dialog functionally; the goal of this plan is to:

1. Collapse the three-row pattern into **one new CSV TypeID `FileLoader`**, so a plugin author writes one row instead of three.
2. Move the **Load flow** into C# so it fits the rest of the channel-driven, code-generated architecture.
3. Expose a **vvvv `FileLoader` ProcessNode** so patches can subscribe to a load site's path by owner key, without poking at raw channel paths.

Saving a scene already serializes every published channel — the file paths come along with the rest of the model. Per-owner copy of source files into the scene folder is **explicitly out of scope** for this plan (Tebjan: a "pack project" command in the browser is the right place for that later).

## Scope decisions (chosen)

- **New CSV TypeID `FileLoader`** — full replacement of `*Folder` + `*File` + `*Load` rows in Phase 1. `*Dropdown01..08` plugin parameter dropdowns are unrelated and untouched.
- **No new data type.** The CSV row generates a plain `ParamString` for the path — same shape as the existing `EditField` rows. No `ParamFile` composite, no `Load` bang sub-channel. The dialog is always launched from the UI button, so a direct C# method call on `LoadFileService` is sufficient; nothing in the patch needs to be able to fire the dialog.
- **Per-row override channels stay** — every FileLoader row also generates `*FileFormat` (`ParamOptions<FileFormatEnum>`) and `*RootFolder` (`ParamString`) so the patch can override format and start folder at runtime. CSV declares defaults; empty override = use default.
- **vvvv `FileLoader` node is bind-only and read-only** — takes an Owner key (`"Plugin1D[2]"` etc.), outputs the current path. No trigger input. Patches subscribe; they don't fire the dialog.
- **Last-folder-used** start folder, falling back to the per-row `subfolder:` UserData on first use.
- **File-flip dropdown** included in Phase 1 (opt-in via `dropdown:true` UserData) so deleting the legacy `*Folder` / `*File` dropdowns and `FileNameManager` doesn't regress the workflow.
- **No project-files registry channel** — scene-save already captures paths via normal model serialization. A future "pack project" command can enumerate file-loader sites from the codegen manifest when needed.

## What already works (inventory)

| Piece | Where | Notes |
|---|---|---|
| Modal browser pick dialog | [`BrowserDialogService.OpenAsync`](SpaceMusic/csharp/SpaceMusic.UI.Core/Controls/BrowserDialogService.cs#L23) | TaskCompletionSource pattern; returns `BrowserDialogResult{FilePath, Ok}`. Single instance reused. |
| `IBrowserService` | [`IBrowserService.cs`](SpaceMusic/csharp/SpaceMusic.UI.Core/Controls/IBrowserService.cs) | Registered as a vvvv app service from `SmShellUI`. `OpenDialogAsync(BrowserDialogRequest)` is the entry point. |
| Format registry | [`AppShell.EnsureBrowserInitialized`](SpaceMusic/csharp/SpaceMusic.UI.Core/Controls/AppShell.axaml.cs#L1140) | `SceneFormat`, `ImageFormat`, `AudioFormat`, `VideoFormat`, `DocumentFormat` registered today. Pick-mode uses `IContentFormat.Extensions` to filter the OK button. |
| Channel infrastructure | [`DirectChannelProvider`](SpaceMusic/csharp/SpaceMusic.UI.Stride/Channels/DirectChannelProvider.cs), [`DynamicEnumCoordinator`](SpaceMusic/csharp/SpaceMusicMeta/DynamicEnumCoordinator.cs) | Per-frame `Update()` retry-bind pattern is the template for `LoadFileService` and the vvvv `FileLoader` node. |
| `[CanBePublished]` model + codegen | [`SMCodeGen/CodeGenerator.cs`](SpaceMusic/csharp/SMCodeGen/CodeGenerator.cs) | Adding the path field + override channels per FileLoader row is a CSV change + regenerate; no new types needed. |
| `BuildParamRow` dispatch | [`CsvPageView.cs:298`](SpaceMusic/csharp/SpaceMusic.UI.Pro/CsvPageView.cs#L298) | Single switch on `UiControlKind` for every rendered row. Adding a new `FileLoader` case is the only render-side change. |

## Design — the moving parts

### 1. New CSV TypeID `FileLoader` — emits a `ParamString` for the path

CSV row pattern (replaces today's three rows per plugin):

```
Plugin1DFile,FileLoader,30,,Load File..,,0,0,1,0,,0,-,0,,Input,Plugins 1D,Plugin 1D N,Files,format:Image;dropdown:true;subfolder:Audio,4,,Plugin1D,,,,
```

Key UserData keys parsed by `CsvParser`:
- `format:Image|Audio|Video|Object|Scene|Document` — default format filter (name maps to `IContentFormat` registered in `AppShell`).
- `subfolder:Audio` — default Library subfolder when no last-folder is remembered.
- `dropdown:true` — also render a file-flip dropdown above the load button (otherwise: button only).

Generated leaf in `ParameterHierarchy.g.cs` — the row produces an ordinary `ParamString` whose `.Value` is the file path:

```csharp
public ParamString Plugin1DFile { get; set; } = new ParamString("", "Load File..");
```

Same shape as today's `EditField` rows; no new C# type. Path is the only saved state per load site. The codegen distinction between `EditField` and `FileLoader` lives in `UiControlKind` (which routes the renderer to a different factory) and in UserData parsing (`format:` / `subfolder:` / `dropdown:true` carried through to `UiParamSpec`).

### 2. Per-row override channels — `*FileFormat` and `*RootFolder`

Each `FileLoader` CSV row generates **three** model fields (or **four** when `dropdown:true`):

```csharp
public ParamString                   Plugin1DFile        { get; set; } = new ParamString("", "Load File..");
public ParamOptions<FileFormatEnum>  Plugin1DFileFormat  { get; set; } = new ParamOptions<FileFormatEnum>(new FileFormatEnum("Image"), "Format");
public ParamString                   Plugin1DRootFolder  { get; set; } = new ParamString("", "Root Folder");
public Spread<string>                Plugin1DFolderContents { get; set; } = Spread<string>.Empty;  // dropdown:true only
```

The override channels are emitted by codegen automatically for every `FileLoader` row — they don't need separate CSV rows. They're hidden from the Pro UI (no Pro UI CSV entry) and exposed only to the patch.

Empty override = `LoadFileService` falls back to the CSV-declared `format:` / `subfolder:` defaults. Non-empty override wins.

`FileFormatEnum` is a static dropdown (`SpaceMusic Setup - Dropdowns.csv`) with one entry per registered `IContentFormat` (`Image`, `Audio`, `Video`, `Object`, `Scene`, `Document`). The registered name is the canonical key.

### 3. `LoadFileService` — the click→browser→write-back loop + dropdown enumeration

New internal class in `SpaceMusic.UI.Stride.Channels`, constructed once in `SmShellUI`. Called **directly** from the renderer (no Load bang channel mediates the click), and runs per-frame to watch path-change events for the dropdown sites.

**Manifest.** Generated by SMCodeGen: one `LoadSite` per FileLoader CSV row, expanded by instance for spread plugins.

```csharp
public record LoadSite(
    string OwnerKey,                  // "Plugin1D[N]", "Light[N]", "AudioPlayer1"
    string FilePath,                  // "...Plugin1DFile" — the ParamString path channel
    string FileFormatPath,            // "...Plugin1DFileFormat"
    string RootFolderPath,            // "...Plugin1DRootFolder"
    string FolderContentsPath,        // "...Plugin1DFolderContents" — empty when dropdown:false
    string DefaultFormat,             // from UserData "format:Image"
    string DefaultSubfolder,          // from UserData "subfolder:Audio"
    bool EnableFolderDropdown         // from UserData "dropdown:true"
);
```

**Public API — called by the Pro UI button:**

```csharp
public Task OpenFor(string ownerKey);
```

The button's click handler calls this; `LoadFileService` looks up the matching `LoadSite`, reads the override channels, resolves format + start folder, opens `IBrowserService.OpenDialogAsync`, and on `Ok` writes `result.FilePath` to the site's `*File.Value` channel. No bang counter; the dialog is launched only from the UI, so a direct call is enough.

Resolution chain on click:
- Format: `FileFormatChannel.Value` → if empty, `DefaultFormat`. Look up matching `IContentFormat` in the registry.
- Start folder: `RootFolderChannel.Value` → `_lastFolders[OwnerKey]` → `Library/{DefaultSubfolder}`.
- On Ok: write `result.FilePath` to `*File.Value`; remember `Path.GetDirectoryName(result.FilePath)` in `_lastFolders[OwnerKey]`.

**Per-frame `Update(hub)` — for dropdown sites only:**
- Retry-bind each `dropdown:true` site's `*File.Value` channel using `PublicChannelHelper.TryBind`.
- Watch `ValueChanged`. On change: enumerate `Path.GetDirectoryName(newPath)` filtered by the resolved format's extensions; write the result to `*FolderContents`.

That's the whole service. No subscription to `Load` counters because there are none.

### 4. vvvv `FileLoader` ProcessNode — bind-only, read-only

```csharp
[ProcessNode]
public class FileLoader : IDisposable
{
    private string _lastOwner = "";
    private string _path = "";
    private IChannelHub? _hub;
    private IChannel<object>? _fileChannel;

    public FileLoader(NodeContext nodeContext) { /* keep AppHost / hub reference */ }

    /// <summary>Bind-only, read-only mirror of a CSV-declared FileLoader site's path channel.</summary>
    public void Update(
        out string path,
        string owner = "Plugin1D[0]")
    {
        if (owner != _lastOwner) { Rebind(owner); _lastOwner = owner; }
        path = _path;
    }
}
```

Internally uses `PublicChannelHelper.TryBind` on `Project.Input.Plugins1D.Plugin1D[N].Files.Plugin1DFile` for the resolved Owner, subscribes to `.Value` changes, mirrors them to `_path`.

The node deliberately has **no trigger input**. The dialog is launched only from the UI; patches that need to react to a file change subscribe via `Path`. Patches that need to *change* the file write directly to the channel (with a path string), bypassing the dialog — the channel is the source of truth, and writing a string to it is a legitimate way to change the loaded file.

Cannot create new load sites — passing an unknown Owner outputs the empty string and logs a warning once.

### 5. Rendering — `CsvPageView.BuildParamRow` new case

Single switch case at [`CsvPageView.cs:298`](SpaceMusic/csharp/SpaceMusic.UI.Pro/CsvPageView.cs#L298):

```csharp
case UiControlKind.FileLoader:
{
    IChannelBinding<string>? pathBinding = null;
    Action? onLoad = null;
    IChannelBinding<Spread<string>>? folderContentsBinding = null;
    if (provider != null && spec.ChannelPath != null)
    {
        pathBinding = provider.GetChannel<string>(spec.ChannelPath + ".Value");
        var ownerKey = spec.OwnerKey;                       // carried from manifest
        onLoad = () => _loadFileService.OpenFor(ownerKey);  // direct C# call
        if (spec.HasFolderDropdown)
            folderContentsBinding = provider.GetChannel<Spread<string>>(spec.ChannelPath + "FolderContents");
    }
    return UiFactory.FileLoaderRow(
        spec.DisplayName, pathBinding, onLoad,
        folderContentsBinding,            // null when dropdown:true not set
        labelBinding: lbl);
}
```

`UiFactory.FileLoaderRow` lives in `SpaceMusic.UI.Core/Controls/UiFactory.cs` next to `ButtonRow`/`DropdownRow`. Draws a load button with the file name as its label (or "Load File.." when path is empty); when `folderContentsBinding != null`, draws a `ComboBox` above the button bound to the folder contents. Selecting an entry writes the new path to `*File.Value` directly.

### 6. Codegen plumbing

| Touch point | Change |
|---|---|
| [`Models.cs:57`](SpaceMusic/csharp/SMCodeGen/Models.cs#L57) `CSharpParamType` switch | Add `"FileLoader" => "ParamString"` |
| [`Models.cs:88`](SpaceMusic/csharp/SMCodeGen/Models.cs#L88) `DefaultInitializer()` | Add `"FileLoader" => $"new ParamString(\"\", \"{name}\")"` |
| `Models.cs` `CsvParameter` | Add parsed UserData fields: `FileFormatDefault`, `FileSubfolderDefault`, `FileFolderDropdown` |
| [`UiModels.cs:7`](SpaceMusic/csharp/SMCodeGen/UiModels.cs#L7) `UiControlKind` | Add `FileLoader` |
| [`CsvUiSpec.cs:5`](SpaceMusic/csharp/SpaceMusic.UI.Pro/CsvUiSpec.cs#L5) `UiControlKind` | Add `FileLoader` (mirror copy) |
| `UiHierarchyBuilder.MapControlKind` | Map `"FileLoader"` → `UiControlKind.FileLoader` (overrides the `ParamString → EditField` default for FileLoader rows). |
| `UiParamSpec` | Add `HasFolderDropdown` (bool), `OwnerKey` (string), `DefaultFormat` (string), `DefaultSubfolder` (string). |
| `CodeGenerator.cs` model emission | For each `FileLoader` row, also emit `*FileFormat` (`ParamOptions<FileFormatEnum>`) and `*RootFolder` (`ParamString`) fields. For `dropdown:true` rows, also emit a bare `*FolderContents` (`Spread<string>`) field — no `Param*` wrapper since it has no UI exposure. |
| `CodeGenerator.cs` — new generated file | `LoadSiteManifest.g.cs` containing the static `LoadSite[]` array. Lightweight; just channel paths + defaults for `LoadFileService` to read on construction. |

No new hand-written types — `FileLoader` rows produce `ParamString`, which already exists.

## Critical files

| File | Change |
|---|---|
| [`SpaceMusic Setup - Parameters V1.csv`](SpaceMusic/Settings/SpaceMusic%20Setup%20-%20Parameters%20V1.csv) | Delete `*Folder` / `*File` / `*Load` rows for each plugin/singleton. Add one `FileLoader` row in their place (~12 rows total). Leave `*Dropdown01..08` rows alone. |
| [`SpaceMusic Setup - Dropdowns.csv`](SpaceMusic/Settings/SpaceMusic%20Setup%20-%20Dropdowns.csv) | Add `FileFormatEnum` with entries matching the registered `IContentFormat` names. |
| [`SpaceMusic Setup - Pro UI.csv`](SpaceMusic/Settings/SpaceMusic%20Setup%20-%20Pro%20UI.csv) | Regenerated by `--update-proui`; old `*Folder` / `*File` / `*Load` rows replaced by the new `FileLoader` row. |
| [`Models.cs`](SpaceMusic/csharp/SMCodeGen/Models.cs), [`UiModels.cs`](SpaceMusic/csharp/SMCodeGen/UiModels.cs), [`CsvParser.cs`](SpaceMusic/csharp/SMCodeGen/CsvParser.cs), [`CodeGenerator.cs`](SpaceMusic/csharp/SMCodeGen/CodeGenerator.cs), [`UiHierarchyBuilder.cs`](SpaceMusic/csharp/SMCodeGen/UiHierarchyBuilder.cs) | New TypeID wiring (see Codegen plumbing table). |
| `SpaceMusicMeta/Generated/LoadSiteManifest.g.cs` (new) | Codegen output: static `LoadSite[]` array for `LoadFileService` init. |
| [`SpaceMusic.UI.Pro/CsvPageView.cs`](SpaceMusic/csharp/SpaceMusic.UI.Pro/CsvPageView.cs) | Add `UiControlKind.FileLoader` case in `BuildParamRow`. Pass an `ILoadFileService` reference through the view's constructor so the case can wire `onLoad` to `LoadFileService.OpenFor(ownerKey)`. |
| [`SpaceMusic.UI.Pro/CsvUiSpec.cs`](SpaceMusic/csharp/SpaceMusic.UI.Pro/CsvUiSpec.cs) | Mirror `UiControlKind` addition; add `HasFolderDropdown`, `OwnerKey`, `DefaultFormat`, `DefaultSubfolder` to `UiParamSpec`. |
| `SpaceMusic.UI.Core/Controls/UiFactory.cs` | New `FileLoaderRow(...)` factory. |
| `SpaceMusic.UI.Stride/Channels/LoadFileService.cs` (new) | `OpenFor(ownerKey)` public API + per-frame folder enumeration for `dropdown:true` sites. Constructed in `SmShellUI`, registered into the DI surface that `CsvPageView` reads. |
| [`SpaceMusic.UI.Stride/SmShellUI.cs`](SpaceMusic/csharp/SpaceMusic.UI.Stride/SmShellUI.cs) | Construct `LoadFileService`, expose it to `CsvPageView`, tick it per-frame for dropdown sites. |
| `SpaceMusic.UI.Stride/FileLoader.cs` (new) | `[ProcessNode]` vvvv-facing bind-only read-only node (no Trigger input). |
| [`SpaceMusic_UI.vl`](SpaceMusic/SpaceMusic_UI.vl) `FileNameManager` | **Deleted** — its job (populating `*Folder` and `*File` dropdowns) is gone. `LoadFileService` handles the dropdown-enumeration case directly. |
| [`SpaceMusic.vl`](SpaceMusic/SpaceMusic.vl) `SaveAndCopyProjectFiles` / `Project Files` slot | Out of scope for this phase. The patch no longer needs to enumerate file paths for the scene save (model serialization captures them automatically), and the per-owner folder copy is deferred to a future "pack project" command. The patch can either keep its existing logic disabled or be removed if it has no other callers. |

Existing utilities to reuse:
- [`PublicChannelHelper`](SpaceMusic/csharp/SpaceMusicMeta/PublicChannelHelper.cs) — retry-bind + onNext callback. Use for every channel binding in `LoadFileService` and the vvvv `FileLoader` node.
- [`DirectChannelBinding<T>.BuildWriteValue`](SpaceMusic/csharp/SpaceMusic.UI.Stride/Channels/DirectChannelBinding.cs#L95) — reflection-based record clone preserving `DisplayName`. Reuse when writing the path back to `*File.Value` (record write must preserve DisplayName per existing convention).
- [`DynamicEnumCoordinator`](SpaceMusic/csharp/SpaceMusicMeta/DynamicEnumCoordinator.cs) — same per-frame `TryBind` + count-driven rebuild template for `LoadFileService`'s spread-instance handling.

## Implementation order

Stop at the end of each step, build, and visually verify before moving on. Each step is checkpoint-able as a commit.

Work continues on the current branch (no separate plan-033 branch).

1. **`FileFormatEnum` dropdown.** Add `FileFormatEnum` entry to `SpaceMusic Setup - Dropdowns.csv` with six entries (`Image`, `Audio`, `Video`, `Object`, `Scene`, `Document`). Regenerate dropdowns; build. Verify the enum compiles and `new FileFormatEnum("Image")` works.

2. **Codegen — `FileLoader` TypeID recognition.** Extend `Models.cs:57` `CSharpParamType` (add `"FileLoader" => "ParamString"`) and `:88` `DefaultInitializer()`. Add UserData parsing for `format:`, `subfolder:`, `dropdown:true`. Add `UiControlKind.FileLoader` to both `UiModels.cs:7` and `CsvUiSpec.cs:5`, with the `UiHierarchyBuilder` mapping `FileLoader` rows to `UiControlKind.FileLoader` (overriding the default `ParamString → EditField`). Build SMCodeGen. With a temporary one-line CSV experiment (add `TestFileLoader,FileLoader,30,...`), confirm `ParameterHierarchy.g.cs` emits `public ParamString TestFileLoader { get; set; }` and the renderer sees `UiControlKind.FileLoader`. Revert the experimental row.

3. **Codegen — override channels + folder contents.** In `CodeGenerator.cs` model emission, when a CSV row has type `FileLoader` also emit `*FileFormat`, `*RootFolder`, and (for `dropdown:true` rows) `*FolderContents` (`Spread<string>`) sibling fields. Verify via the temporary experimental row.

4. **Codegen — `LoadSiteManifest.g.cs`.** New output file containing a static `LoadSite[]` array (one entry per FileLoader CSV row, expanded by spread instance) with all channel paths, defaults, and the `EnableFolderDropdown` flag. Build. Confirm the manifest is reachable from `SpaceMusic.UI.Stride`.

5. **`LoadFileService` skeleton + `OpenFor` API.** New `SpaceMusic.UI.Stride/Channels/LoadFileService.cs`. Constructor takes the static `LoadSite[]` manifest. Public `Task OpenFor(string ownerKey)`: look up the matching site, read override channels via `PublicChannelHelper`, resolve format + start folder (override → last-folder → default), call `IBrowserService.OpenDialogAsync`, on Ok write the path to the site's `*File.Value`. Constructed in `SmShellUI` and exposed for `CsvPageView` to consume.

6. **Renderer — `FileLoaderRow` factory.** Add `UiFactory.FileLoaderRow(...)` in `SpaceMusic.UI.Core/Controls/UiFactory.cs`. Add `HasFolderDropdown` / `OwnerKey` / `DefaultFormat` / `DefaultSubfolder` to `UiParamSpec` in `CsvUiSpec.cs`. Add the `case UiControlKind.FileLoader` in `CsvPageView.BuildParamRow` at `:298`; the click handler calls `_loadFileService.OpenFor(spec.OwnerKey)`. Render is button-only at this step (no dropdown yet); button text = `Path.GetFileName(path)` or "Load File.." when path is empty.

7. **Pilot migration — Plugin1D.** In V1 CSV, delete `Plugin1DNFolder` + `Plugin1DNFile` + `Plugin1DNLoad`. Add one `Plugin1DNFile,FileLoader,30,...,format:Audio;subfolder:Audio,...` row. Leave the `Plugin1DNDropdown01..08` rows alone — they're unrelated plugin parameter dropdowns. Regenerate. Verify Plugin1D row in Pro UI renders the new button; clicking it opens the browser at `Library/Audio`; picking a file writes the path to `Plugin1DFile.Value`. Test the override channels by writing to `Plugin1DFileFormat` / `Plugin1DRootFolder` from the patch.

8. **vvvv `FileLoader` ProcessNode (bind-only, read-only).** New `SpaceMusic.UI.Stride/FileLoader.cs`. `[ProcessNode]` with `Owner` (string in), `Path` (string out). Internally `PublicChannelHelper.TryBind` to `XxxFile.Value` for the resolved Owner; subscribe to changes. Drop in a patch, set `Owner = "Plugin1D[0]"`, confirm `Path` mirrors `Plugin1DFile.Value` live.

9. **File-flip dropdown enumeration.** Extend `LoadFileService.Update(hub)` to watch each `dropdown:true` site's `*File.Value` for changes; on change enumerate the parent folder filtered by the resolved format and write to `*FolderContents`. Extend `UiFactory.FileLoaderRow` to draw a `ComboBox` above the button when `folderContentsBinding != null`; selecting an entry writes the full path to `*File.Value`. Verify: load `kick.wav`, dropdown lists all `.wav`/`.mp3`/`.flac` siblings, picking one swaps the loaded file without opening the dialog.

10. **Bulk migration of remaining plugins.** Apply the same CSV swap (delete `*Folder` + `*File` + `*Load`, add one `FileLoader` row) to Plugin2D, Plugin3D, Light, Stage, PostFX, Placement, Layer-Object, AudioPlayer1, MidiPlayer1, SaveScene, LoadScene. Regenerate. Verify each plugin family in vvvv.

11. **Patch-side cleanup.** Delete `FileNameManager` from `SpaceMusic_UI.vl` (its job — populating `*Folder` and `*File` dropdowns — is gone). Decide what to do with `SaveAndCopyProjectFiles` / the `Project Files` slot in `SpaceMusic.vl`: at minimum, disconnect them from the file-load pipeline (paths are now in normal channels and saved with the model); if no other consumers exist, remove the patch entirely. Verify scene save + scene reload round-trip — paths should be preserved via channel-model serialization alone.

12. **Repoint Prev/Next bang buttons.** `AudioPlayer1Prev/Next` and `MidiPlayer1Prev/Next` previously iterated the old `*File` dropdown. Rewire them to iterate `AudioPlayer1FolderContents` / `MidiPlayer1FolderContents` and write the resulting full path to `*File.Value`. Patch-side rewire.

13. **Run full verification list (see §Verification).** Document any deviations in changelog. Move the plan from `docs/plans/future/` to `docs/plans/` (active) before step 1, and to `docs/plans/done/` once complete.

## Out of scope for Phase 1

- **Per-owner scene-folder copy.** The current patch copies each loaded file into a per-owner subfolder of the scene folder on save. That logic is being retired with this plan — paths now save via normal channel serialization. A future "pack project" command in the Avalonia browser (separate plan) is the right home for bundling source files alongside the scene.
- **A top-level `Project.Files` registry channel.** Not needed — paths live in their plugin's channels already. A future "pack project" command can enumerate file-load sites from the generated `LoadSiteManifest` without a runtime registry.
- Cross-session persistence of `_lastFolders`. Phase 1 keeps it in-memory.
- Browser dialog for SaveScene-target / Export-target. Same `IBrowserService` is available — wiring is a separate small task.

## Verification

After `generate-params.bat` and `dotnet build`, in vvvv with a patch open:

1. **CSV migration sanity.** Open `SpaceMusic Setup - Parameters V1.csv`. The old `*Folder` / `*File` / `*Load` rows are gone; each plugin has exactly one `FileLoader` row with `format:` and `subfolder:` UserData. The `*Dropdown01..08` rows are still present (unrelated to file loading). Pro UI CSV regenerates cleanly via `--update-proui`.
2. **Spread plugin load.** Add a Plugin1D instance. Click "Load File..". Avalonia browser opens at `Library/Audio` (default for Plugin1D). Pick `kick.wav`. Browser closes; `Plugin1DFile.Value` updates to the picked path.
3. **Last-folder memory.** Click "Load File.." again on the same plugin. Browser opens in the folder of `kick.wav`, not `Library/Audio`. Pick `snare.wav`. `Plugin1DFile.Value` replaces.
4. **Per-plugin override — root folder.** From the patch, write `Plugin1D[0].Plugin1DRootFolder.Value = "Library/Drums"`. Click Load. Dialog opens at `Library/Drums`.
5. **Per-plugin override — format.** Set `Plugin1D[0].Plugin1DFileFormat` to `Video` from the patch. Click Load. The Avalonia browser pick-OK only enables for video extensions.
6. **File-flip dropdown.** Plugin1D row has `dropdown:true`. After loading `kick.wav`, a dropdown above the load button lists every `.wav`/`.mp3`/`.flac` in the same folder. Pick `snare.wav` from the dropdown. `Plugin1DFile.Value` updates without opening the browser.
7. **vvvv FileLoader node.** Drop a `FileLoader` node in the patch, set `Owner = "Plugin1D[0]"`. Output `Path` mirrors `Plugin1DFile.Value` live. Verify by writing to `Plugin1DFile.Value` from elsewhere — `Path` updates within a frame.
8. **Singleton load.** Click LoadAudio1 (FileLoader row with `subfolder:Audio`, no `N`). Same flow; owner key = `"AudioPlayer1"`.
9. **Layer-object load.** Click LoadObject inside a Layer (FileLoader row with `format:Object;subfolder:3DObjects`). Browser default = `Library/3DObjects`, format = `Object`.
10. **Spread shrink.** Reduce Plugin1D instance count from 3 → 2. `LoadFileService`'s site list rebinds; `Plugin1D[2]` channel goes away cleanly. Loading on `Plugin1D[0]` and `Plugin1D[1]` still works.
11. **Scene save still works.** Trigger SaveScene. Reload the scene → all `*File.Value` channels repopulate via normal channel-model serialization. No per-owner subfolder copy occurs (that's the new, intended behavior).
12. **Rapid-click regression check.** Click a load button rapidly several times. Subsequent clicks while the dialog is open are dropped by `IBrowserService.IsDialogOpen`; exactly one dialog opens per gesture.
13. **No references to deleted artifacts.** Search `SpaceMusic_UI.vl`: no `FileNameManager` references remain. Search `ParameterHierarchy.g.cs`: no `*Folder` properties remain (only `*File` as `ParamString`, `*FileFormat`, `*RootFolder`, and `*FolderContents` for `dropdown:true` rows). `*Dropdown01..08` properties stay (unrelated plugin parameter dropdowns).
