Save and Load: The .lux File Format
You can’t really call something a tool until you can save your work.
Lux has been running off an in-memory graph for months. Quit the app and your patch is gone, I got away with it because I was mostly building the tool, not patches, but the moment I started trying to use Lux to make things, the lack of save/load went from “I’ll get to it” to “this is the only thing that matters.”
Today it matters in the right direction. Ctrl+S saves. Ctrl+O loads. Projects live in .lux files. Done.
The format
.lux is JSON, I thought about a binary format, bincode or similar, and I’ll probably add one as an optimisation later. But for a first pass, JSON is the right choice: it’s diffable, it’s inspectable, it survives version mismatches gracefully, and when a project file breaks you can open it in a text editor and see exactly why.
The top-level structure:
pub struct ProjectFile {
pub version: u32,
pub metadata: Metadata, // name, created, modified
pub nodes: Vec<NodeEntry>, // type, id, position, pin values, state
pub connections: Vec<Wire>, // src node/pin → dst node/pin
pub canvas: CanvasState, // pan, zoom, selection
pub output: OutputConfig, // resolution, mode, clear color
}
Each NodeEntry carries the node’s type name, its position on the canvas, the current values of every input pin, and a base64-encoded blob of the node’s internal state. That last one is important, a Spring node carries its velocity, a Counter carries its count, a FrameDelay carries its buffer. Every stateful node already implements save_state() and restore_state() for undo, so the project format just reuses that.
GPU handles get filtered out before serialisation. A TextureHandle is a runtime-only identifier, it points to a wgpu texture in the current session’s pool and means nothing across launches. Save a patch with a LoadImage node and the texture handle is dropped; on load, LoadImage re-decodes the file and gets a fresh handle. The thing that persists is the path, not the pixel data.
Ctrl+S, Ctrl+O, and Save As
Native file dialogs via the rfd crate. Works on Linux, macOS, and Windows without me writing a single line of platform-specific code.
- Ctrl+S: save to the current path, or open a Save As dialog if there isn’t one.
- Ctrl+Shift+S: always open the Save As dialog.
- Ctrl+O: open a file picker and load a project.
File extension is enforced on save: if you type a name without .lux, it gets added. If you type a name with the wrong extension, it’s corrected. Small thing, but it means you never end up with mypatch.txt containing JSON that you can’t find later.
On load, the undo history gets cleared; there’s no sensible way to “undo past the load” and trying would just corrupt your history. The canvas state (pan, zoom, selection) is fully restored so the project opens exactly the way you saved it.
The invisible-nodes bug
Here’s the one that made me stop and stare at the screen for a while.
I’d built save and load, round-tripped a test patch, verified every pin value came back correctly, and everything was green. Then I loaded a real patch, one with twenty-odd nodes, and the canvas came up completely empty. No nodes, no wires, nothing. But the inspector showed the graph was loaded. The nodes existed. They just weren’t drawing.
The bug was the render cache. Every node caches its draw commands keyed by the node ID, and the cache skips re-drawing when nothing has changed. Loading a project creates all the nodes with their saved IDs, and the cache still had stale entries from whatever was on the canvas before the load. The cache said “I already have a render for node 7, reuse it”, except node 7 was now a completely different node at a completely different position, and its cache entry pointed to an empty render from an earlier session.
Fix: clear the render cache on project load. One line in the load path. Plus a test that loads a project, checks that the render cache was invalidated, and catches this if it ever regresses.
The funny part is that the cache was doing exactly what I told it to do. It just didn’t know what “load” meant, because I’d never told it. Explicit invalidation at the right moment beats cleverness every time.
What “saved” means now
A Lux project saves everything that makes it your project: the graph topology, every pin value, every stateful node’s internal state, the canvas view, the output resolution and mode. You can quit the app mid-patch, relaunch, open the file, and pick up exactly where you left off, including the bounce that was halfway through its decay and the counter that was partway through its cycle.
The thing that doesn’t save is the transient GPU state. Textures get re-decoded from their source files. Render caches get rebuilt on the first frame. That’s correct; those are implementation details, not content.
One file, one extension, two keyboard shortcuts. Lux is finally a tool you can quit.