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
- 2. The patch model
- 3. The compiler
- 4. Four backends, one specification
- 5. Picture and sound
- 6. Threads
- 7. Files and the text language
- 8. Plugins
- 9. The shells
- 10. Build, gate and release
- 11. Code style
- 12. Writing tests
- 13. Recipes
- 14. Refactors that are already declined
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).
All of it is in src/Flyback.Core/Graph.
Patchis nodes, connections, groups, panel controls and a format version. It has exactly one Output (0037):CanAddrefuses a second,Removerefuses the one.NodeInstanceis an id, aTypeIdstring, a position, a name, anOffswitch,InputValues(one float per input, the knob) and aStatedictionary of JSON for whatever else the module carries (0061).Connectionis its two ends. A wire has no id.NodeDefis a module: type id, name, category, input and outputPortSpecs, an emit function and a description. Modules are data in one catalog, not classes (0008).PortSpecis a socket.PortKindisScalar,Color(three registers) orAny, andAnyis what lets one Multiply work on both (0010). Every input carries an editable default (0009), and may be normalled to a hidden module such astimewith 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.
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.
| 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.
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.
| 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.
| 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.
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.
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.
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
.lockopen 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.sigverifies 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.csis the entire policy of what is counted and what is refused.
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).
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 Releasedocker 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.
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.
- File-scoped namespaces, in every file; the gate fails a block-scoped one.
sealedon a class by default. Static classes for things with no instance.- Records for data,
readonly record structfor small values (Slot,PortNormal,AudioFormat,MidiMessage). Primary constructors where a type is its parameters. varfor locals. Collection expressions ([],[.. xs]). Switch expressions.is nullandis not null, never== null.- Private fields are
camelCasewith no underscore, andprivateis written out. Constants arePascalCase. Native constants keep their native names (FLASHW_TRAY). internalis 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 throughInternalsVisibleTo, not by making things public.- No
#region, no#pragma warning disable, noTODO. A warning is an error, so it is fixed rather than silenced. unsafe,Span<T>andUnsafe.Addappear 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.
- 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.Outputon a patch with none). - Data over types. A module is a
NodeDefvalue. 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,CompiledPatchandAudioEngine.Stateare replaced whole with aVolatilewrite. 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
HttpClientby 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 withPrivateAssets="all"is fine; ADR-0019 is about what ships. - One place knows a thing.
NodeGeometryfor where a socket is,OpShapefor an opcode's arity,PatchFile.Openfor opening a file,ColorsandTextfor 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).
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-cliorflyback-viewer:check,info,print --check,render --atfor a still,shotfor the editor's window,comparefor "is this still the same instrument",flyback-viewer --hidden --forto 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
EditorTestand, where it has a look, fromPatchShotTests. Screen coordinates andSendKeysare the last resort (therunning-the-appskill), 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'
Editoropens 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
comparesays where two patches part, so the next step is a fix rather than a bisect.
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,.propsand.targetsfiles are commented the same way. If a line of MSBuild is not obvious, the reason is written above it.
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.
- 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.mdis 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
mainat commit time, because concurrent sessions have collided.
| 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.
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.
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.
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.
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.
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.
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.
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));LoopbackOutputis the backend a host starts it through. - An HTTP endpoint is
Canned : HttpMessageHandler, handing back scriptedCannedAnswers under a keylessKeyedTransport. - 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 throughPatchWorkbench. - 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.
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.
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 clicksEvery 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.
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.
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.xmlThe 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.
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).
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.
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.