Skip to content

Latest commit

 

History

History
1213 lines (1010 loc) · 72.1 KB

engineering-guide.md

File metadata and controls

1213 lines (1010 loc) · 72.1 KB

Flyback engineering guide

How the codebase is put together, how its code is written, and how its tests are written. The decisions themselves are in adr/; this is the map that says which of them you are standing on. CONTRIBUTING.md is what a change has to carry, and language.md is the text language.

Where this guide and an ADR disagree, the ADR is right and this guide is stale. Where an ADR and the code disagree, that is a finding worth raising.


1. The shape

One module graph makes a picture and a sound. A patch is compiled to a flat program for a register machine, once for the screen and once for the speakers, and everything else in the repository either builds that graph, runs that program, or shows the result. Who uses Flyback and what it talks to, and the programs and stores it is made of, are the C4 model in diagrams/; this section is how the projects inside those programs depend on each other.

The editor's projects, and which references which, are the editor's components (C3); ComponentDiagramTests holds that drawing to the project files. References run one way, down in that drawing, and there is no abstraction layer between the layers: a shell calls the engine's concrete types (0002). The other programs stand on the same projects:

Flyback.Viewer.Desktop flyback-viewer.exe  Ui; no Editor
Flyback.Cli            flyback-cli.exe     Gpu, Assist and Host; no Ui and no Avalonia
Flyback.Viewer.Web     wwwroot/            Gpu and Host, the module plugins linked in; no Ui
Flyback.Editor.Web     wwwroot/            Editor under Avalonia.Browser; needs wasm-tools
Flyback.Editor.Android .apk                Editor under Avalonia.Android; not built with the solution
Project Holds Rule it lives under
Flyback.Core Patch, NodeDef, PortSpec, NodeCatalog, OpCode, Emitter The plugin-facing surface. Its public API is versioned separately (0102).
Flyback.Engine PatchCompiler, CompiledPatch, IL, GLSL and JavaScript backends, SynthRenderer, AudioRenderer, codecs, Language/, PatchIO, the built-in Presets No third-party dependencies (0019). PNG, JPEG, WAV and AVI are written by hand for that reason.
Flyback.Gpu GpuFrameRenderer, GpuReadback, IGl, Gl, WebGl, HeadlessContext (WGL and EGL), HeadlessRenderer OpenGL through IGl: native function pointers the caller's context hands over, or WebGL 2 in the web viewer, so every picture on a GPU comes from one renderer; no Avalonia and no packages (0157, 0160).
Flyback.Plugins IFlybackPlugin, IPluginRegistry, the device, MIDI, secret and assistant interfaces, PluginHost, PatchWorkbench References Engine with PrivateAssets="all", so a plugin cannot reach the engine through it.
Flyback.Assist AssistantSession, AssistantRun, SavedConversation, ConversationStore, ITranscript The conversation the editor's column and flyback-cli ask both hold, kept out of the assembly a plugin is compiled against (0169).
Flyback.Host OutputSettings, Resolutions, PresetLibrary, SavedPreset What a program reads before it has a window, or without one; no Avalonia and no packages, so the CLI, the site and the pages reference it (0188).
Flyback.Ui PreviewHost, the CPU and GPU preview surfaces, AudioEngine, Colors, Text Exists so the viewer shares the editor's preview without referencing the editor (0124).
Flyback.Editor EditorView, MainWindow, NodeEditor, inspector, assistant panel, recording, the container they are composed in, usage counts, the running version and release notes UI is C# with no XAML (0016). A library, so a browser page can host the editor as well as the desktop (0162).
Flyback.Editor.Desktop Program, FlybackApp, Startup, installing an update, the shipped plugins The desktop program around Flyback.Editor.
Flyback.Viewer.Desktop A window, a transport and an argument parser Writes nothing to disk (0123).
Flyback.Viewer.Web WebSound, WebPicture, WebExports, JsSound, the page, its sound worker, and hear.mjs for Node The sound as JavaScript in a worker, the interpreter where it cannot be; ahead-of-time compiled only on publish; a patch too heavy to keep up plays its picture alone (0160).
Flyback.Editor.Web PageApp, CanvasPreview, PageSound, PageExports, the page Flyback.Editor under Avalonia.Browser, with the page's own preview, sound, title, focus and close registered in its container; the picture on a canvas of its own, the sound in the web viewer's worker. Built into the preset site's pages by worker/build-assets.sh; also built with Flyback.slnx, which therefore needs the wasm-tools workload; the gate's image installs it (0162).
Flyback.Editor.Android MainActivity, DeviceApp, DeviceFolders, DeviceRequest Flyback.Editor under Avalonia.Android, the module plugins and Flyback.Plugins.AndroidIO (AudioTrack, AudioRecord and MidiManager) linked in. Listed in Flyback.slnx with <Build Project="false" />, since it needs the android workload, a JDK and the Android SDK; built by path (0184).
Flyback.Cli One file per command over System.CommandLine The only place export lives (0078); on the GPU where there is one, and exact to the bit with --processor (0157).
Flyback.Site flyback-site: the readers a submission is checked with, and the commands the preset site's workflows run Never shipped; references the plugins the web pages link, to say what a browser lacks (0175). The Worker is in worker/, tested with Vitest; the site's containers show how the two check a submission.
Flyback.Site.Client SiteAdmin, SiteAddress, SiteMedia, SiteAnswer The admin API's address, token, uploads and refusals, for flyback-site and flyback-cli render-presets; no packages.

A project's namespace is its assembly name: Flyback.Ui, Flyback.Editor, Flyback.Editor.Desktop, Flyback.Editor.Web, Flyback.Engine. A folder adds a sub-namespace (Flyback.Engine.Render); nothing else does. The one exception is the source the plugins share: src/plugins/Shared/Programs is Flyback.Plugins.Programs in whichever plugin compiles it, since it is a folder and not a project (0173).


2. The patch model

All of it is in src/Flyback.Core/Graph.

  • Patch is nodes, connections, groups, panel controls and a format version. It has exactly one Output (0037): CanAdd refuses a second, Remove refuses the one.
  • NodeInstance is an id, a TypeId string, a position, a name, an Off switch, InputValues (one float per input, the knob) and a State dictionary of JSON for whatever else the module carries (0061).
  • Connection is its two ends. A wire has no id.
  • NodeDef is a module: type id, name, category, input and output PortSpecs, an emit function and a description. Modules are data in one catalog, not classes (0008).
  • PortSpec is a socket. PortKind is Scalar, Color (three registers) or Any, and Any is what lets one Multiply work on both (0010). Every input carries an editable default (0009), and may be normalled to a hidden module such as time with no wire (0050).

A module is declared like this, in one of the NodeCatalog.*.cs partials:

yield return new NodeDef(
    "osc.pulse", "Pulse", ModuleCategories.Oscillators,
    [
        Domain("in"), Freq, Num("phase", 0f, 0f, 1f), Num("width", 0.5f, 0f, 1f),
        Num("amp", 1f, 0f, 2f), Num("bias", 0f, -2f, 2f)
    ],
    [Num("out")],
    (em, i) =>
    {
        var phase = em.Phase(i[0], i[1], i[2]);
        var wave = em.Add(em.Mul(em.Binary(OpCode.Step, i[3], em.Unary(OpCode.Fract, phase)), 2f), -1f);
        return [em.Add(em.Mul(wave, i[4]), i[5])];
    },
    "A square with an adjustable duty cycle.");

The emit function receives the Emitter and its resolved inputs as Slots and returns one Slot per output. It writes ops; it does not compute anything. That is the whole extension mechanism, and a plugin uses the same one.

NodeCatalog is the built-in set, assembled once in a static constructor from the partials. ModuleCatalog is the immutable set a running program has: built-ins plus whatever plugins added. NodeCatalog.Install is called once at startup, before any window exists. ModuleCategories.All is both the set of curated categories and the palette order.

Presets are written in the text language: the engine's are .fbks files in Graph/Presets/, a plugin's are embedded and offered through PresetText.Preset, which reads their description and tags from the file. They declare no coordinates (0070) and arrive with their arithmetic folded into Expressions (0108): Presets.All runs ExpressionFusion.Fuse and PatchLayout.Arrange over each. The authoring-presets skill covers writing one.


3. The compiler

src/Flyback.Engine/Compile/PatchCompiler.cs. Three public roots, CompileForVideo, CompileForAudio and CompileForProbe, funnel into one Compile. The result is a CompiledPatch and a list of CompileIssues. Problems are values, not exceptions, here and almost everywhere else.

Backwards from the Output (0011). Resolve(node) is a memoized recursive walk up the wires from the sink. Only the sink's ports for this program are resolved, the screen's color or the speakers' left, right and volume, so the other half's upstream is never visited. That is how one graph becomes two programs (0022).

A flat register machine (0005). An Op is (Code, Out, A, B, C, K). The Emitter hands out a fresh register per value, so the program is in SSA form; a Slot is a base register and a width of 1 or 3, and a scalar broadcasts across a color (0007). Registers are double (0032). Constants and loads are shared through the emitter's dictionaries.

47 opcodes, in src/Flyback.Core/Compile/OpCode.cs, numbered by hand and never renumbered: the number is what a plugin bakes in. OpShape is the table of each opcode's inputs, outputs and whether it must be kept.

Unread ops are swept (0096). Emitter.ToProgram makes one backward pass and drops any op whose outputs nothing reads, except the ones OpShape.Kept names: ops with no output, and Delay, Allpass and Phase, which own memory by position.

A pixel runs only what a pixel changes (0064). FramePlan gives each op the latest stage any of its inputs needs, Frame, Row or Pixel, and sorts the program into three runs. Most of a large patch lands in Frame and runs once.

A cycle carries its own delay (0075). Cycles.Backwards finds the wire that closes each loop. That wire is read from a plane cell rather than followed, and every write is emitted at the end of the program, so each read precedes each write and the latency is one evaluation.

State survives a rebuild (0067). The emitter records which node owns each delay line, phase, unit cell and plane in StateOwners, and DelayState.Adopt carries the old contents into the new program per owner. The whole patch is recompiled on every edit (0021), and this is what makes that inaudible.

Knobs recompile nothing (0086). A panel knob, a MIDI voice and a meter reading arrive as LiveValues, a name-keyed float[] the program reads with a Live op. A single float is atomic and the block is not, deliberately: the audio thread takes no lock.

A polyphonic wire is lowered once per voice (0174). VoiceCounts says how many voices each module runs, from the modules that start a wire (NodeDef.StartsVoices) to the ones that add the voices back up (NodeDef.MergesVoices, and anything that taps or charts). Resolve is keyed by module and voice, so each voice claims memory of its own and the backends see an ordinary program; a wire short of voices is silent in the ones it lacks.

A Probe is a second compile root (0040), a Scan is a Probe read backwards (0043), and both work by pushing a substitute domain onto the emitter before resolving a Swept input.


4. Four backends, one specification

Backend Where Runs
Interpreter Interpreter, Arithmetic Everything, always. It is the specification.
IL IlEmitter, IlOps, IlProgram, IlCompiler The CPU picture and the sound, once built, and every offline render (0076)
GLSL GlslEmitter The live preview and live recording (0035)
JavaScript JsEmitter, JsLayout The web viewer's sound (0160)

The interpreter is one switch over the flat op array. Register access has no bounds check; Vouch checks the whole program once in the constructor instead. Arithmetic is guarded rather than allowed to produce NaN (0013): divide by zero is 0, the root of a negative is 0, Guard turns a non-finite value into 0.

The IL backend is built on a background thread at below-normal priority (normal while a patch just opened waits on it) and attached to the program through a Volatile field that renderers read once per frame or buffer. Each op is one inlined method in IlOps that calls the interpreter's own helpers, so a guard exists once. A stretch longer than 256 ops is several methods called in order, because past a few thousand locals the JIT stops optimizing a method and every op becomes a call. Every build is run against the interpreter before it is trusted, bit for bit, and refused if it differs. --interpreted keeps a run, or a flyback-cli render --processor, on the interpreter.

The GLSL backend emits text and touches no GL; Flyback.Gpu makes every GL call. Where a GLSL builtin disagrees with the interpreter (fract, mod, mix, pow, smoothstep, atan) it emits a helper that transcribes the interpreter instead. This is why the four opcode switches are not unified: the transcription is the point.

The JavaScript backend emits text too, a function that renders a buffer of the sound's evaluations in a browser, which runs no IL. It works in place on the program's memory, whose arrays JsLayout names as indices into the heap, and cuts the ops into functions of 128, since a larger function goes unoptimized. Float arithmetic the interpreter does in float is rounded with Math.fround where it happens. A program that reads a picture is left to the interpreter.

What keeps them in agreement is tests, all in tests/Flyback.Core.Tests/Compile, all theories over Enum.GetValues<OpCode>():

  • TotalityTests: every op, fed 21 hostile values (±0, ±∞, NaN, denormals), neither throws nor differs between IL and interpreter.
  • IlProgramTests: IL equals the interpreter over every preset, staged and whole.
  • GlslEmitterTests.Every_opcode_lowers_to_a_line: every op produces a line, except four that are named by hand as producing none.
  • GlslEmitterTests.Preset_lowers_as_approved: a snapshot of every preset's shader in both dialects.
  • JsProgramTests: the emitted script, run under Node, plays every preset's sound and every op as the interpreter does; skipped where there is no Node.

The plugins' presets are the larger shapes, and Flyback.Plugins.Tests holds them to the same: PluginPresetIlTests, PluginPresetGlslTests and PluginPresetScriptTests run IlParity and ScriptRun, which it compiles in from Flyback.Core.Tests/Compile. Cli.Tests/GpuRenderTests draws every engine preset on the GPU against the processor, and MetalRenderTests draws the same through Metal on a Mac (ADR-0214). PluginPresetMetalTests lowers every plugin preset to Metal.

A new opcode fails all of these until it exists everywhere.


5. Picture and sound

Picture. SynthRenderer runs the program per pixel with Parallel.For over rows, one register bank per worker, on every core but one (0006). The spare core is for the audio callback. Coordinates and value ranges are fixed by 0014. Feedback reads the previous frame, which ping-pongs between two buffers. PlaneState holds a float per pixel per plane for state on the video path, laid out pixel-major so rows stay independent (0074).

Sound. AudioRenderer is single-threaded by design and allocates nothing once built. It runs the program four times per output sample and decimates through a 64-tap windowed sinc, then a DC blocker and a clamp (0023). Oscillators accumulate phase on this path rather than multiplying time (0030). x and y are pinned to 0; the picture is heard only through a Scan (0077).

Memory. DelayState is everything a sound program remembers: delay and allpass rings, phases, unit cells, one plane cell per cycle and the trace rings a Scope reads. It belongs to the renderer, not the program, and stateful ops index it by their position among ops of their kind. With no DelayState each stateful op degrades to something total (Delay passes through), which is how the video path runs the same ops.

The device is not the engine's (0024). The engine generates samples. Flyback.Ui/Audio/AudioEngine joins a program to an IAudioDevice that a platform plugin supplies, and its callback swaps one immutable State record with Volatile rather than locking. It is the master clock the preview follows.

The microphone is read once a frame (0178). A Line In reads two live inputs, and AudioRenderer writes them from an ILineInSource before each frame, so every backend and the interpreter agree and no op was added. Flyback.Ui/Audio/LineIn opens the plugin's IAudioCapture only while the playing program reads them and hands what it hears over in a LineInFeed; a render hears the file --input names, as a RecordedLineIn.

Time is seconds (0048), and nothing in the engine reads a wall clock. t is an argument.


6. Threads

Thread Does Must not
UI (Avalonia dispatcher) Editing, compiling, layout Render a frame (0018). Blocking the dispatcher on a Parallel.For deadlocks against the compositor.
Thread pool CPU preview frames, one at a time behind a rendering flag Overlap: SynthRenderer is not thread-safe.
Compositor render thread GpuPreviewSurface.OnOpenGlRender Block the dispatcher; hand-off is behind one gate lock.
Audio callback AudioRenderer into the device buffer Lock, allocate or wait.
MIDI driver Notes into LiveValues through MidiHub Open or close a device with the hub's lock held.
Flyback IL compiler Builds IL at below-normal priority, normal while a cue waits on it Take the processor from the two above.

The capture sinks (IFrameSink, IAudioSink) state the rule for anything on a hot path: it may copy, and it must not encode, allocate, lock or wait.

Plugins are constructed on the UI thread at startup. Register has to be cheap and must not touch a device.


7. Files and the text language

Extension Is Code
.fbk The patch as indented JSON, keyed by string type ids (0020) PatchIO
.fbkb A zip: patch.fbk, the samples and pictures it names under files/, optionally the conversation (0060) PatchBundle
.fbks The patch as source text (0065) Language/

PatchFile.Open is the one door for all three, and both shells and the CLI go through it. A patch names its samples and pictures rather than carrying them (0052); the compiler is handed an ISampleLibrary and never does file I/O itself.

A file records which plugin each foreign module came from (0026), so PatchLoad can say what is missing instead of failing. A file from a newer format version is refused whole. Adding a module is not a version bump.

The language pipeline is Lexer.Scan → StatementTokens.ForParsing → Parser.Parse → Binder.Build. The parser knows nothing about modules; the binder reads short names, socket names and arities off the running ModuleCatalog, so a plugin's modules are usable in text the moment it loads. It never throws: faults are LanguageIssue(Line, Column, Message). Building is exact. Printing (PatchPrinter) is lossy by design, and its guarantee is narrower and testable: print a patch, build the text, and you get the same program opcode for opcode. language.md is the reference and language-for-agents.md is the short form an assistant is briefed with.

Copy and paste move a patch as the same JSON (0045), and undo is whole-document snapshots in PatchHistory.


8. Plugins

A plugin is one public parameterless class implementing IFlybackPlugin, in an assembly built with EnableDynamicLoading, in its own folder under plugins/ (0025). Register calls the registry:

void AddAudioOutput(IAudioOutput output);
void AddModules(ModuleProvider provider, IReadOnlyList<NodeDef> modules);
void AddPresets(IReadOnlyList<PatchPreset> presets);
void AddPatchAssistant(IPatchAssistant assistant);
void AddSecretStore(ISecretStore store);
void AddMidiInput(IMidiInput input);
void AddAudioInput(IAudioInput input);

A new kind of extension is a new method. Existing plugins only call the interface, so adding one does not break them.

Loading. PluginHost.Load scans once at startup and never reloads: code the audio thread calls into cannot be unloaded safely. Each folder gets its own AssemblyLoadContext with a resolver over the plugin's .deps.json, so two plugins may depend on different versions of a package. Flyback.Core, Flyback.Engine and Flyback.Plugins always resolve to the host's copy; otherwise the contract types get a second identity and every cast fails. That is why every plugin references Core and Plugins with Private="false" ExcludeAssets="runtime".

Only what somebody said yes to loads (0158). PluginTrust loads a folder the build listed in plugins.sha256, or one allowed-plugins.json in the data folder allows, and only while every file in it hashes as it did; a Debug build loads everything. A secret store registers only from a shipped plugin or one allowed with --secrets. An assistant gets an IAssistantTransport that sends a body for it and hands back what came back; the host builds each request and puts the key on it for one origin, so the assistant never holds the key or a request that carries it.

Nothing throws. A bad folder, a folder nobody allowed, a duplicate id or a refused module becomes a PluginProblem in the catalog. A backend, assistant or secret store id registered twice is refused to both; a plugin id is the first one's. Folders are read in ordinal order so two runs agree.

Module refusals are ModuleCatalog's, in Core. A provider is refused for a blank id, the reserved built-in id, an id already loaded, or an id that would claim existing module ids. A module is refused unless its type id starts with <provider id>., or if it is defined twice.

A plugin declares its modules (0134) with [assembly: FlybackModule(id, name)], which the install dialog and the shared plugins site read from metadata. After Register, a plugin that registered a module it did not declare is rolled back whole and becomes a PluginProblem. pack-plugin runs the same load before writing a package.

The contract has its own version (0102). PluginContractVersion in Directory.Build.props is the AssemblyVersion of Core and Plugins. The host compares it with what the plugin was compiled against before it looks at a single type: an older major needs rebuilding, a newer major or minor needs a newer Flyback. PublicAPI.Shipped.txt and PublicAPI.Unshipped.txt beside both projects are enforced by an analyzer, so neither project builds until a surface change is written down. The first *REMOVED* line since a release moves the major; the first added line moves the minor. Both projects give the host and its tests InternalsVisibleTo, so a member only the host reads is internal: public is reserved for what a plugin may name, and the fix for the analyzer's complaint is internal unless a plugin needs the member, in which case the new line is a decision named in the commit. ContractSurfaceTests in Flyback.Plugins.Tests fails on a public type or member no plugin built here names, unless it is promised there with a reason (the plugin-contract skill).

Building. Plugins are not project references. A host project lists PluginProject items and Directory.Build.targets builds each one straight into plugins/<FolderName>/, portable, with an optional Platform of win, osx or linux (0028). Only Flyback.Editor.Desktop and the test projects declare them, and the build writes plugins.sha256 beside the folder for each. The CLI and the viewer publish into the app's folder and read what it laid out; declaring plugins there would clear the folder out from under it.

What ships.

Plugin Adds Third-party
WinIO, MacIO, LinuxIO Sound and MIDI per platform (0063) NAudio.Wasapi for WASAPI in WinIO; ASIO and the others are hand-written P/Invoke
AndroidIO Sound and MIDI on Android, linked into Flyback.Editor.Android rather than loaded from a folder (0184) none
Dpapi, Keychain, Keyring Where an API key is kept (0034) ProtectedData in Dpapi
Picture, Voice, Effects, Mastering Modules and presets none
Codex Patch assistant over the installed codex program and a ChatGPT sign-in, so a subscription pays and no key exists (0173) none: a process with text in and JSON lines out
SystemOne A decision model over HTTP, the Decision server: whatever server it is given, such as a laya-serve (0186) none: hand-written JSON over HTTP
OpenAi, Gemini Patch assistants (0033, 0066) none: hand-written JSON over HTTP

Codex's <calls> protocol, session, process and locator are in src/plugins/Shared/Programs, source any plugin that runs a program compiles in rather than a project it references (0173). OpenAi and Gemini share src/plugins/Shared/Surveys, the survey loop over each one's IModelProbe, the same way (0161). LinuxIO and AndroidIO share src/plugins/Shared/Audio, the thread a blocking device is written or read on; MacIO and AndroidIO share src/plugins/Shared/Midi, the reader that cuts a stream of MIDI bytes into messages, since a CoreMIDI packet and an Android delivery hold more than one message.

An assistant never touches the patch directly. Everything it does goes through PatchWorkbench, which is the tool surface, the limits and the senses (looking at a frame, hearing a clip). It is a facade: WorkingPatch holds the patch and the handles its modules answer to, ToolTable says what each tool is and which class runs it, and the edits, reports, senses and measurements are a class apiece beside them in Flyback.Plugins/Assist. The two session loops in OpenAiSession and GeminiSession are similar on purpose and stay separate; the reason is in GeminiSession.cs's header.

tests/Flyback.Plugins.Sample is the worked example of a module plugin, and docs/plugin-guide.md is the published guide.


9. The shells

What each shell talks to, and where it keeps its files, is drawn in the desktop and web and Android container views. This section is how each is built inside.

The editor

No XAML (0016). Controls are built in C#. The shared values a style sheet would have held are in two token files in Flyback.Ui/Controls: Colors.cs (colors by role: Window, Canvas, Panel, Node…) and Text.cs (eight type sizes from Micro to Display, and the muted brush). A color or a font size written as a literal in a control is drift. The site's CSS mirrors Colors.cs.

Hubs and the regions around them (0148). Document owns who owns the patch and where an undo lands, TextWriteBack writes what the panel changes into the text, and CaretFollow points the panel at what the caret stands on; Playback owns compiling and the sound device, and drives pause, mute and rewind through the Transport the viewer shares; PatchFiles owns which file the patch is, and opening and saving it. A region is a class that takes what it reads (the canvas, the document, the plugins, the report line, a dialog, a file picker) and owns its own fields: Inspector, Palette, PanelKnobs, the settings sections (ISettingsSection), PluginInstalls, SettingsSession, Toolbar, PresetSlot, StatusBar, PictureWindow. They are composed in a container (0150): EditorServices registers each with AddPart<T>, a constructor says what it depends on. EditorView is handed them, lays them out, and answers the keys and dropped files of whichever window or page holds it; MainWindow is the desktop's window around it, keeping its size, its place and its close. There are no view models, and that has been decided twice.

Notices (0148). A hub says what happened by raising a notice, a record in Flyback.Editor.Notices carrying what a reactor needs (PatchChanged, PatchCompiled, SaveAsked), and a part that cares declares IReactTo<PatchCompiled> in its class header and reacts in On. Reactions resolves the reactors when the notice is raised, lowest Priority first, so a reaction is never a constructor dependency and never a cycle. To react to something, take nothing: declare the reaction. To find every reaction, search the notice's name. Raise is for a raiser that cannot wait, RaiseAsync for one that can, and a reaction is a Task either way. A control tells the region that owns it through a C# event; what the viewer shares (MidiHub, PreviewHost, IlCompiler) keeps its events too, and the part that takes it subscribes in its constructor. Three things are refused outright because each is a flaky test waiting to happen: a notice raised from a constructor (every reactor is built with the window, and a raise meanwhile throws), a notice raised off the UI thread (it throws at the raiser), and a notice raised after the window is disposed (it goes nowhere, and a chain still running stops).

A folder per feature, on a footing (0194). The footing, lowest first: the project's root (ReportLine, Attention, Reopen and the other small pieces every folder reads); Controls, the widgets that belong to no feature; Updates, the release feed, installing and restarting; Statistics; Shell, what a window or a page hands the editor (EditorSetup, EditorHost, IClose, IFocus, IFilePickers, FileTypes and the rest), which the desktop, web and Android programs each fulfill; Notices, one record per notice, with Reactions and the start phases; and Settings, what a settings section is built from (ISettingsSection, SettingsSession, InspectorRows). Each names only what is below it. The features stand on it in an order of their own, lowest first, each with its region, its controls and its settings section together: Site, Decide, Midi (the instruments), Canvas (the node editor and the palette), Hubs (Document, Playback, PatchFiles, and what writes the panel into the text), Assist, Knobs (the knob panel and the MIDI section), PluginPackages, Capture, Files, Gallery, Inspect, Bars, Keys and Windows (the picture window, full screen and the kept layout). Each names only the footing and the features below it; what it needs from above arrives as a notice (TakeMarked, MeasureModeChanged) or through an interface it defines and the feature above fulfills (IPatchConversation). Composition is everything that lists the features, and nothing names it: the registrations, EditorView, ShellLayout, MainWindow, the start, ThisMachine, and the settings pages with the sections of what has no folder or sits in the footing (the picture, the sound, privacy, usage and updates). FolderLayeringTests holds the order, read from the compiled editor. The namespace follows the folder, and a folder is never named for a type, since inside Flyback.Editor that name would then mean the namespace. For the same reason Canvas shadows Avalonia's control, so it is written Avalonia.Controls.Canvas.

The node editor is one control (0017). NodeEditor overrides Render and the pointer handlers, and nothing inside it is a control. It is split the way the window is: hubs (CanvasHistory, CanvasSelection, Viewport) and regions that take them (CanvasEdits, CanvasClipboard, CanvasGestures, CanvasPainter, SocketDial and the rest), with the control one file that hands its pointer, keys and Render to them. NodeGeometry, one per window since compact modules are the window's setting, is the single source of measurement that both painting and hit-testing call, which is what keeps a socket where it is drawn. A module is drawn as its category, or as itself if it is a standout or a plugin paints it (0116, 0118).

The preview. PreviewHost holds whichever backend is live. It starts on the GPU and falls back to the CPU once per session with no way back. GpuFrameRenderer owns the shader programs and the feedback texture pair and returns error strings rather than throwing. It calls OpenGL through Gl, a table of entry points found by name through whichever context is current, so flyback-cli render draws with the same renderer through a HeadlessContext. A live recording takes the GPU frame through GpuReadback (0049) and encodes with ffmpeg where it is found, Motion JPEG AVI where it is not (0089).

Startup is in Startup.Load: tidy a pending update, PluginHost.Load(), NodeCatalog.Install, then the first window, handed the DesktopLaunch that Load returns. The catalog is final before any palette is built. Once the container has built the editor, a part with something to do at start declares IStartAt and its phase: Built puts saved settings in force, Shown the layout and the first patch, Opened whatever needs a dialog or a file.

What it writes goes to the data folder, %APPDATA%/Flyback (GlobalConstants.DataFolder), as JSON; settings.json holds one section a concern (SettingsFile, ADR-0171). API keys go to the operating system's store, never to a file.

  • Recovery (0103): each window writes a snapshot and holds a .lock open beside it. A snapshot whose lock can be taken belongs to nobody. That is the whole crash detection.
  • Updates (0088): a release is installed at the next start only if SHA256SUMS.sig verifies against the committed public key. The new version installs itself through --apply-update, and that argument contract is only ever added to.
  • Usage (0094): Statistics/Usage.cs is the entire policy of what is counted and what is refused.

The viewer and the CLI

Both parse with System.CommandLine, both load plugins and install the catalog before building their command tree, and both read their defaults from the OutputSettings the editor saves, through Flyback.Host. The viewer is ViewerPlayer (its run, over the editor's Transport, with no window type in it) and ViewerWindow, composed in a container by ViewerServices as the editor is by EditorServices; --hidden runs the player with no preview at all. The CLI declares each command's options in Program.cs and runs each in its own file (RenderCommand, CheckCommand…). Commands take two TextWriters and return an exit code, which is what makes them testable without a process. flyback-cli viewer parses nothing and starts flyback-viewer beside it. flyback-cli shot checks its request and hands it to Flyback --shot beside it, which builds the editor's window on the headless platform and writes what it draws (ADR-0166).


10. Build, gate and release

Four short, commented files decide how everything here builds: Directory.Build.props for what every project is compiled with, Directory.Packages.props for every package version, .editorconfig for the analyzer rules this codebase answers differently, and global.json for the test runner. The reason for anything surprising in them is written above it.

dotnet test --solution Flyback.slnx -c Release
docker build --target gate .

The second is the truth (0120), and the pipeline shows which workflow runs it where. The first runs against whatever the machine happens to have, and the tests that need something missing skip rather than fail — the headless UI tests want a font stack to rasterize with, the recording tests want ffmpeg on PATH, the browser scenarios want a Chromium (FLYBACK_CHROMIUM, or one on PATH; scripts/setup.sh sets it to the Playwright one where there is one) — so a local run can be green about code it never ran. The Dockerfile carries both, which is why CI builds it rather than installing an SDK.

A test host that dies under dotnet test leaves a minidump and a sequence log of the tests it was in under TestResults/, since a crash that comes once in hundreds of runs is not there when asked for again. -p:CrashDumpType=Heap asks for a heap dump instead. Running a test assembly directly leaves none.

The stages stack, and that is what stops a release skipping anything. publish builds on gate, so per-platform artifacts cannot exist without every test having passed. gate builds on tested, which runs the tests and keeps a JUnit report per project and the run's exit code without failing; gate.sh takes them out into test-results/, writes summary.md there with the counts per project, each failing test's message and stack and each skipped test's reason, and on GitHub makes it the run's summary, then exits with that exit code, which is all gate adds to tested. A failed run keeps test-results/, hang dumps included, as the Build run's artifact. measured builds on gate too and runs the tests again under coverage, daily rather than per change, one project at a time, without the tests filed under unmeasured; nothing else reads it. coverage.sh builds it and writes the table, the specs' figure apart from the sum of the rest, for the Coverage workflow and locally alike, as release.sh is the whole release for the Release workflow. The gate's restore is locked to the committed lock files, so a version that moved fails there rather than building.

worker.yml deploys the website on every push to main that touches the site, once Build has passed on that commit: site/ as it stands, with the web viewer, the web editor and the stills built in. Nothing in that build reads the prose, so nothing catches a stale sentence, which is why a site edit goes in the same commit as the change it describes.

A release passes -p:Version=; every other build reports a suffixed version, which is what keeps it from updating itself or being counted. What the Release workflow does with that is its own header, and 0088 is why an update is signed at all.


11. Code style

Whitespace is .editorconfig, and the gate fails a change that leaves a file dotnet format whitespace Flyback.slnx would touch; run that command to fix it. Everything else is what the code already does, and new code should be indistinguishable from the file it lands in.

Language

  • File-scoped namespaces, in every file; the gate fails a block-scoped one.
  • sealed on a class by default. Static classes for things with no instance.
  • Records for data, readonly record struct for small values (Slot, PortNormal, AudioFormat, MidiMessage). Primary constructors where a type is its parameters.
  • var for locals. Collection expressions ([], [.. xs]). Switch expressions. is null and is not null, never == null.
  • Private fields are camelCase with no underscore, and private is written out. Constants are PascalCase. Native constants keep their native names (FLASHW_TRAY).
  • internal is the default visibility, in Core and Plugins too: public there is what a plugin could need. The host's assemblies and the tests reach internals through InternalsVisibleTo, not by making things public.
  • No #region, no #pragma warning disable, no TODO. A warning is an error, so it is fixed rather than silenced.
  • unsafe, Span<T> and Unsafe.Add appear where a hot loop has earned them, with the check that makes them safe done once up front (Vouch).
  • Nullable reference types are on. A ! is a claim that needs to be true.

Design habits

  • A failure is a value. CompileIssue, LanguageIssue, PluginProblem, PatchLoad, ModuleAddition.Rejected, BundleReport. Code that reads a file, loads a plugin or compiles a patch reports; it does not throw. Exceptions are for broken invariants (Patch.Output on a patch with none).
  • Data over types. A module is a NodeDef value. What a module carries is a part (NodeExtra), not a subtype (0054). A plugin's settings and its module's editor are declared as fields (SettingField, ExtraField), not drawn by the plugin.
  • Immutable, then swapped. ModuleCatalog, CompiledPatch and AudioEngine.State are replaced whole with a Volatile write. That is the project's answer to locking on a hot path.
  • Time is an argument. No TimeProvider, no injected clock. Code that needs the time is passed seconds.
  • No dependency where a page of code will do. The engine has none. The assistants talk JSON over HttpClient by hand rather than take an SDK. The editor is composed in Microsoft's own container (ADR-0150), because its wiring had stopped being a page. A first-party build-time analyzer with PrivateAssets="all" is fine; ADR-0019 is about what ships.
  • One place knows a thing. NodeGeometry for where a socket is, OpShape for an opcode's arity, PatchFile.Open for opening a file, Colors and Text for the look.
  • No interface without a second implementation. Interfaces exist at the plugin boundary and almost nowhere else. A shell calls the engine's concrete types.
  • Split a big class by region, not by pattern. A region that owns its own state becomes a class of its own (MainWindow's, ADR-0148; NodeEditor's, ADR-0017). A type is never cut into partial files (one-type-per-file.md).

Drivable by an agent

Most features here are built, tested and debugged by an agent with no eyes on the window, so every feature needs a way in that works without one. A feature that only a person watching the screen can check is not finished.

  • A command before a window. Whatever a patch does can be asked of flyback-cli or flyback-viewer: check, info, print --check, render --at for a still, shot for the editor's window, compare for "is this still the same instrument", flyback-viewer --hidden --for to hear it. A new question an agent keeps answering with a throwaway test gets a command or a flag instead.
  • Answers a script can read. Exit codes mean one thing each (Exit), a report has --json, and stdout carries only the answer.
  • The editor headless. A UI feature is reachable from a EditorTest and, where it has a look, from PatchShotTests. Screen coordinates and SendKeys are the last resort (the running-the-app skill), not the test.
  • The editor without a window. What a gesture or a key does lives in a service the container builds (ADR-0150), and the control only hands the pointer and the keys on. So a command can be run and checked headless: the specs' Editor opens the window the container builds and presses its keys, picks its presets and answers its questions. A feature whose logic can only be reached through a control's event handler is not finished.
  • Say where it went wrong. A failure names the module, the socket, the second or the frame, the way compare says where two patches part, so the next step is a fix rather than a bisect.

Comments

Doc comments are the norm, and they explain why a thing is the way it is, since what it does is in the code. Density runs 30 to 65 percent of a file, so a file's line count overstates its code.

  • One <summary> line where that carries it. <remarks> only for a real surprise. <param> on records, since the parameters are the type.
  • Never narrate history. Write as if the current shape were the only one it ever had. No "now", "no longer", "three rather than six", "unchanged by the consolidation". History is in git and the ADRs.
  • A comment that is growing into an argument is an ADR.
  • American spelling everywhere: "color", "analyzer", "behavior". The identifiers already use it, so anything else makes a comment disagree with its code.
  • .csproj, .props and .targets files are commented the same way. If a line of MSBuild is not obvious, the reason is written above it.

Names

Domain words, from the instrument rather than the framework: module, socket, wire, patch, knob, sink, normalled, take, plane, cell. glossary.md has every one, the code name it hides behind and the words it must not be called. Test and ADR titles are sentences. A module's type id is category.name for built-ins (osc.sine, math.add) and <provider id>.name for a plugin's, and once shipped it is never changed: saved patches name it.

Commits, changelog, ADRs

  • Commit straight to main; history is linear. The subject is a declarative sentence stating what is now true: "The command line can start the player". No Conventional Commits prefix. The body is a paragraph on what was wrong and what happens now.
  • An upgrade, a retarget and a slow-test fix each get a commit of their own. Churn that has not been pushed is squashed before it is.
  • CHANGELOG.md is for what a user would notice: one terse bullet per feature, nothing for tests, comments, ADRs or the README.
  • An ADR is Nygard's format: context, decision, consequences. One younger than a day is rewritten in place; an older one gets a dated amendment. Number a new one from main at commit time, because concurrent sessions have collided.

12. Writing tests

The projects

Project Tests Notes
Flyback.Core.Tests Model, compiler, backends, language, renderers The only user of Verify (snapshots) and CsCheck (properties)
Flyback.Specs Every feature's requirement as Gherkin scenarios Reqnroll; no C# test methods; references every project and plugin
Flyback.Editor.Tests Editor, viewer, capture, updates Headless Avalonia
Flyback.Viewer.Desktop.Tests The viewer's window and options Headless Avalonia
Flyback.Editor.Desktop.Tests The desktop shell: installer, downloader, update folder, release notes, no static holding a thread-owned object Headless Avalonia
Flyback.Ui.Tests The shared controls, the audio engine, MIDI, and Flyback.Host's settings and presets Headless Avalonia for the controls
Flyback.Cli.Tests Commands run in-process
Flyback.Site.Tests flyback-site's checks, and its commands against a stand-in site
Flyback.Plugins.Tests The host, every shipped module and preset Loads real plugins off disk
Flyback.Plugins.OpenAi.Tests, .Gemini.Tests, .Codex.Tests, .Programs.Tests, .SystemOne.Tests Wire translation and sessions Reference the plugin directly: translation is pure
Flyback.Plugins.Drawings.Tests The Path and Shape modules' reader and drawing References the plugin directly
Flyback.Plugins.WinIO.Tests The ASIO output, against FakeAsioDriver, a driver's vtable in memory References the plugin directly; runs anywhere
Flyback.Plugins.LinuxIO.Tests What the JACK output offers, against a stand-in for the server References the plugin directly; runs anywhere
Flyback.Ui.Testing The headless harness: UiTest, the Avalonia xunit adapter Not a test project
Flyback.Plugins.Testing What plugin-facing tests share: LoopbackDevice, a sound card the test is the thread of; Canned, an endpoint with scripted answers; JackDaemon, a JACK server to play into Not a test project
Flyback.Core.Benchmarks BenchmarkDotNet Not a test project
Flyback.Plugins.Sample, .FakeAssistant, .FakeDecider Plugins the tests load Not test projects

A test goes in the project that owns the behavior, not the one that is convenient.

Tools

xunit.v3 on Microsoft.Testing.Platform, with every test project an Exe. Microsoft.NET.Test.Sdk is absent on purpose; adding it switches a project back to VSTest. Assertions are Shouldly, exclusively. The only Assert. calls in the repository are Assert.SkipWhen and Assert.SkipUnless.

dotnet test --filter is ignored by this platform. Run the assembly itself:

./tests/Flyback.Core.Tests/bin/Release/net10.0/Flyback.Core.Tests.exe -class "Flyback.Core.Tests.Graph.MixerTests"
./tests/Flyback.Editor.Tests/bin/Release/net10.0/Flyback.Editor.Tests.exe -method "*WireDrop*"

-list tests lists them and -xml results.xml writes a time per test.

Test categories

A test is filed under what it needs beyond the .NET runtime, as its Category trait (no relation to a module's category):

Category Needs Filed by
ui The headless Avalonia thread [AvaloniaFact] and [AvaloniaTheory] themselves
ffmpeg ffmpeg, to write or read a clip [TestCategory(TestCategory.Ffmpeg)], or @ffmpeg on a feature or scenario
node Node, to run what a page runs [TestCategory(TestCategory.Node)], or @node
browser A Chromium to open the pages in @browser
jack A JACK server to play into [TestCategory(TestCategory.Jack)], or @jack
gpu An OpenGL context [TestCategory(TestCategory.Gpu)], or @gpu
unmeasured Nothing: it reads files or metadata and runs no Flyback code [TestCategory(TestCategory.Unmeasured)], or @unmeasured

The gate's image has all of them. The coverage run leaves out unmeasured, since it adds nothing to the figure; a test or scenario goes there only once a coverage run of it alone covers no line that every run of its project already does. Pick a category out, or leave one out:

./tests/Flyback.Core.Tests/bin/Release/net10.0/Flyback.Core.Tests -trait "Category=ffmpeg"
./tests/Flyback.Specs/bin/Release/net10.0/Flyback.Specs -trait- "Category=browser" -trait- "Category=gpu"

A test asks for its tool with TestCategory.Ffmpeg.Require(Encoder is not null, "no ffmpeg on this machine"), and a step with Needs.Tool(runtime, TestCategory.Ffmpeg, …). Either skips where the tool is missing, and fails on any machine when the test is not filed under the category.

Naming

The file, the class and the subject share a name: MixerTests.cs holds public class MixerTests. The namespace mirrors the folder. A method is a sentence with underscores that states the rule, capitalized like a sentence:

Every_opcode_lowers_to_a_line
The_il_is_the_interpreter_on_every_input
A_printed_preset_reads_back_as_the_same_instrument
A_wire_lands_on_a_swept_port_rather_than_the_first_scalar_one
The_json_and_the_prose_agree_about_how_bad_it_is
A_model_that_refuses_a_sound_is_reported_without_one

No Should_, no Given_When_Then, no Test suffix. Name the rule, not the bug: A_chunk_that_lies_about_its_length_costs_nothing outlives the hunt that found it, and Bug3 does not.

Nothing in src/ exists only for tests

A member that only tests call is a test of nothing real: either the app should use it or it should not be there. Where a test-only member has to stay (a shipped plugin contract, a seam that reads a result back), its doc comment says so and why. dotnet run --project tools/TestOnlyMembers -- Flyback.slnx lists the non-private members of src/ that no src/ code uses and tests do, one tab-separated row each; run it after a refactor and clear what it finds. tools/TestOnlyMembers/baseline.tsv holds the rows that stand, and the daily test-only-members.yml fails on a row not in it. The gate builds the tool on its own, against its committed lock file, so a change that stops it compiling fails on its push.

An engine test

Build a patch with PatchBuilder, compile it, evaluate it, assert on a register. There is no test-only patch DSL; tests use the same builder the presets do.

private static float Heard(params (int Port, float Value)[] knobs)
{
    var b = new PatchBuilder(NodeCatalog.BuiltIn);
    var mixer = b.Add(Mixer, 0, 0, knobs);
    var output = b.Add(NodeCatalog.OutputTypeId, 200, 0, (NodeCatalog.OutputVolumePort, 1f));

    b.Wire(mixer, 0, output, NodeCatalog.OutputLeftPort);

    var result = b.Patch.CompileForAudio(NodeCatalog.BuiltIn);
    result.HasErrors.ShouldBeFalse();

    var registers = result.Program.AllocateRegisters();
    result.Program.Evaluate(0d, 0d, 0d, registers, default);

    return (float)registers[result.Program.OutputBase];
}

[Fact]
public void Each_input_arrives_at_the_output_scaled_by_its_own_level()
{
    Heard((In(1), 1f), (Level(1), 0.25f)).ShouldBe(0.25f, 1e-5f);
    Heard((In(2), 1f), (Level(2), 0.5f)).ShouldBe(0.5f, 1e-5f);
}

The shape to copy: a private helper named for what it observes (Heard, Seen, Run), and facts that read as a table of cases. Floats are compared with a tolerance. Backends are compared bit for bit with BitConverter.DoubleToInt64Bits.

For a claim about every module, opcode or preset, write a [Theory] over the real set (Enum.GetValues<OpCode>(), Presets.All, PluginHost.Load().Modules.All) rather than a list, so that the next one added is covered without anybody remembering. Where a theory needs exceptions, name them one by one in a list with a comment, so that adding one is a decision somebody made. When a theory gathers many failures, collect them and assert once with the first few in the message.

A UI test

Derive from EditorTest and mark every method [AvaloniaFact] or [AvaloniaTheory], whether it touches a control or not. A plain [Fact] in a EditorTest class runs on a pool thread, and disposing from there reaches the dispatcher from the wrong thread. It throws only when work happens to be queued, so it shows up as some other test failing later.

public class BoxLabelTests : EditorTest
{
    [AvaloniaFact]
    public void A_long_socket_label_keeps_its_port()
    {
        var fitted = NodeEditor.Fit(Long + ".out", 170);

        fitted.ShouldEndWith("….out");
        NodeEditor.Fit("filter.cutoff", 170).ShouldBe("filter.cutoff");
    }
}

EditorTest gives you Show(control), NewMainWindow(setup, replace), NewCanvas(width, height, replace), Owned(window), Settle(window), All<T>(visual) and Pick(combo, name). Open windows through it. The window and the canvas come out of the editor's container, and replace registers a test's own service in place of one of them: Site(handler) for the preset site, or a pointer anchor that holds nothing. A part of the window is tested in the window: open one and take the part from it with Service<T>(window), replacing only what the test itself supplies, rather than building the part and its graph by hand. A test about one service of the canvas can take it from new ServiceCollection().AddCanvas() without a window. Headless gives the whole assembly one UI thread, so a window left open keeps its preview, timers and engine on that thread for every test after it; EditorTest closes what it opened, newest first, and closes a MainWindow without asking about unsaved work.

A control a test drives or reads has a Name, and the test finds it by that: All<Button>(window).Single(b => b.Name == "dismiss"). Never by its caption or the glyph it draws: those are the look, and they change without the behavior changing. A symbol is drawn by Glyphs rather than typed, so it looks the same on every platform and in a page, and a drawn one has no text to find it by anyway.

Prefer asserting on the smallest thing that holds the behavior. Much of the editor's logic is reachable as internal static methods (NodeEditor.Fit, NodeEditor.Text), and a test of one needs no window at all.

Flyback.Ui.Testing holds what any headless UI test needs, the specs' included (HeadlessApp; UiTest: Show, Settle, Pump, and windows closed with the test); EditorTest adds the editor's canvas and container on top of it. The Headless xunit adapter is vendored under tests/Flyback.Ui.Testing/Headless because the published package does not discover tests on xunit.v3 4.x. The assembly runs with ParallelMode.Collections, since every UI test queues on the one thread anyway. Each UI test blocks a pool thread while it waits for that thread, so PoolHeadroom raises the pool's minimum well past xunit's slot count; without it a machine short of memory stalls everything else that needs the pool.

Doubles

Write a small private sealed class in the test file. There is no mocking library. A double two test files of one project both want is one internal class in that project (FakeSecretStore, FakeTranscript, Unreachable); one that test projects share is in Flyback.Plugins.Testing, or linked from tests/Shared/ where it names the editor (FakePresetSite).

  • A sound card is LoopbackDevice : IAudioDevice, which keeps the callback and lets the test be the audio thread (Pump(frames)); LoopbackOutput is the backend a host starts it through.
  • An HTTP endpoint is Canned : HttpMessageHandler, handing back scripted CannedAnswers under a keyless KeyedTransport.
  • A CLI command is a function of two StringWriters returning an exit code.
  • A whole assistant is Flyback.Plugins.FakeAssistant, a real plugin that replays a script of tool calls through PatchWorkbench.
  • A temp folder is a field and IDisposable:
private readonly string folder = Path.Combine(Path.GetTempPath(), "flyback-priority-" + Guid.NewGuid().ToString("N"));

public void Dispose()
{
    if (Directory.Exists(folder)) Directory.Delete(folder, recursive: true);
}

Tests that are about loading (PluginHostTests, ShippedPresetTests, EveryModuleTests) call the real PluginHost.Load() against plugins the build laid out in the test output. The assertion that guards the boundary is The_contract_keeps_one_identity_across_the_boundary.

Snapshots

Only in Flyback.Core.Tests: every preset's GLSL in both dialects (Compile/snapshots/*.verified.glsl) and every preset's rendered frame (Rendering/snapshots/*.verified.png, compared as decoded pixels). A *.received.* file is test output and is ignored by git; *.verified.* is the committed baseline and must have LF line endings.

To accept a change, read at least one diff and agree with it, then move each .received over its .verified. A snapshot change that was not the point of the commit is a finding, not a chore.

Specs

Flyback.Specs states what a patch author can rely on, as requirements a reader can check:

Scenario: Turning a tone's frequency while it plays does not click
  Given a 10 Hz sine is playing
  When it has played 0.125 seconds
  And its frequency is turned to 12 Hz
  And it plays on for 0.1 seconds
  Then the sound never clicks

Every new feature ships with at least one scenario (see the tests skill). Features sit in a folder per area under Features/: Modules, Patching, PatchesAndPresets, Canvas, Playing, Devices, CommandLine, Assistant, DecisionModel, Pages, PresetSite and Plugins. The wiring behind each phrase lives in the steps: PatchSteps builds and edits patches, ScreenSteps, SpeakerSteps and CompilerSteps check the picture, the sound and what the compiler says, EditingSteps saves, opens, writes out, undoes and pastes, PresetSteps checks every shipped preset, ExportSteps exports with flyback-cli render and plays through the editor's sound engine, KeyboardSteps plays a stand-in keyboard through the editor's MIDI hub, CanvasEditingSteps, UnsavedWorkSteps and TransportSteps press keys, answer questions and move the seek bar in the editor's own window, headless, ViewerSteps plays a patch through flyback-viewer's own arguments and window, BrowserSteps opens the web viewer and the web editor in a headless Chromium and drives them through window.flyback and a finger (0193), and CliSteps runs flyback-cli on files. They share a fresh PatchContext, Session, Editor and ViewerRun per scenario, and the two windows one headless UI thread (Headless). A step drives the program the way somebody would; a scenario that composes services by hand is testing the wiring, and belongs in Flyback.Editor.Tests. The project references every program and library and lays out every shipped plugin under plugins\, so a feature of the editor, the viewer, the CLI, the preset site or a plugin takes its scenario here like any other. Building never compiles; each check compiles for its own sink. The sound steps evaluate the audio program at 1 kHz without the renderer's filters, so a sample is exactly what the patch computed. C# tests cover the edges.

Skips and optional outputs

A test that needs something the machine may lack says so and skips, through its category when it has one (Test categories):

TestCategory.Ffmpeg.Require(Encoder is not null, "no ffmpeg on this machine");
Assert.SkipUnless(OperatingSystem.IsWindows(), "the Windows backend only opens on Windows.");

Never a custom skip attribute, and never a silent pass: an early return is a pass that checked nothing. The shot tests (PatchShotTests, SkinShotTests and the rest) write the website's pictures when SHOT_DIR is set and skip when it is not.

A scenario that needs a tool is tagged with its category and calls Needs.Tool: it skips where the tool is missing, and fails when FLYBACK_GATE is set. The gate's run sets it, since its image has every tool and a skip there is a regression.

Speed

The median test takes under a millisecond. Anything over a second has to explain itself, and an unexplained one is a defect, fixed in a commit of its own. After a run, rank by duration:

./tests/Flyback.Editor.Tests/bin/Release/net10.0/Flyback.Editor.Tests.exe -xml results.xml

The usual causes are waiting on a wall clock, waiting out a deadline to prove nothing happened, leaving something running that later tests pay for, and rebuilding per test what the class could build once.

  • A spin-wait pumps the dispatcher and checks the clock, with a deadline: while (!done() && DateTime.UtcNow < deadline) { Dispatcher.UIThread.RunJobs(); Thread.Sleep(5); }. It asserts with a message when the deadline passes.
  • A wait that proves a negative is capped hard, and a comment says why that cap is enough.
  • A test that is honestly slow says why: "Twelve seconds because the faders are the slow part — the quickest is a sixth of a hertz."
  • The background IL compiler has Settled() for tests. Await it; do not sleep.

What a change needs

A behavior change lands with a test that fails before it and passes after. A bug fix lands with the failing test that confirmed the bug, filed under the rule it broke. /bughunt is that loop as a command. /mutate checks the tests a commit arrived with: it plants plausible bugs in the lines the commit changed, and every one the suite misses gets a test (ADR-0207).


13. Recipes

Add a built-in module. Add a NodeDef to the right NodeCatalog.*.cs partial with a type id that will never change. Build it from existing ops if at all possible: a module made only of existing ops needs nothing else, and survives to the shader for free. Every-module and every-preset theories pick it up. Update PublicAPI.Unshipped.txt if the surface moved, the site if it names modules, and the changelog.

Add an opcode. Last resort; first try composing existing ops, then a stateful cell (0041). It touches OpCode.cs (next number, never a reused one), OpShape.cs, Interpreter.Run, IlOps and IlEmitter, GlslEmitter (or the named list of ops that write nothing), and the Emitter if it needs a helper. TotalityTests, IlProgramTests and GlslEmitterTests fail until all of them agree, and the GLSL snapshots change. It moves the plugin contract's minor.

Add a module from a plugin. Copy tests/Flyback.Plugins.Sample. Type ids start with the provider id. Add a PluginProject item to Flyback.Editor.Desktop.csproj and to Flyback.Plugins.Tests.csproj. Update docs/plugin-guide.md if the contract moved.

Add a CLI flag or command. Declare it in the command's own file under Commands/, in its Build, and run it there; Program.Run lists the commands and nothing else. Test it in-process through the two writers. Update the README's CLI section and the site in the same commit.

Add a setting. A plugin's own settings are declared as SettingFields (0085) and drawn by the shell. The app's own go in the matching *.json settings class, loaded tolerant of a missing or damaged file.

Add a preset. Use the authoring-presets skill, and played-presets if it has MIDI voices or panel knobs. It must compile clean, fit the canvas, and stay inside what the speakers carry; ShippedPresetTests checks the first two.

Change how something looks. Change the token in Colors.cs or Text.cs, then site/assets/site.css, then retake the affected screenshots from the real app (site-screenshots skill). Never draw a module in HTML, CSS or SVG (0119).

Look at a patch. flyback-viewer, with --mute unless the sound is the point. A still is flyback-cli render -o still.png --at <seconds>, and the editor's window with the patch open is flyback-cli shot. The window itself is only for questions a shot cannot answer.


14. Refactors that are already declined

A first scan of the code surfaces these, and each has been decided:

  • View models for MainWindow. Declined by 0016 and again by 0148.
  • Unifying the interpreter, IL and GLSL opcode switches. The GLSL is a transcription because its builtins disagree with the specification (0035). The coverage tests are the guard.
  • Sharing the OpenAI and Gemini session loops. Deliberate; see GeminiSession.cs.
  • SIMD in the interpreter. 0006.
  • Incremental recompilation. 0021.
  • An imaging, audio or JSON library in the engine. 0019.

The refactors worth proposing are where the code has drifted from a decision, or where a pattern the repo established was never extended. ADR-0017 still describes a node editor a fraction of its current size; a color or type-size literal outside Colors.cs and Text.cs is the same kind of drift. When ranking candidates, count code lines rather than total lines, since comments are a third to two thirds of most files.