The Debt Sweep

Built, Not Adopted was a confession. A pile of library code that worked, had tests, had a blog post written about it, and was sitting on a shelf while the rest of the app politely walked around it. That post was the wiring half of the fix: plug the libraries into the hot path.

This post is the other half. The deletion half. My favourite half.

Behind these posts there’s a long-running adversarial audit: findings I’d logged and not actioned. After Built, Not Adopted I took the tail of that audit and cut it into sprints. This is sprint one, and sprint one is almost entirely subtraction.

Lux is pre-launch. No users, no published plugins, no API contract anyone depends on. The project tenets are blunt about what that means: no compatibility shims, no deprecated wrappers, no feature flags that preserve legacy behaviour, no version-migration code. Every byte of backward-compatibility written before 1.0 is pure negative value. It’s debt with no asset behind it. And over a year of building fast, a few of those bytes had snuck in while I wasn’t looking.

The shims

Four of them, each deleted in its own commit, each a !-tagged breaking change.

The Material shim. Materials cross wires as Arc<Material>, refcount-bump cheap, the same pattern as layers and environments. Except lux-core still carried a #[deprecated]-tagged conversion that let a Material deep-clone itself field by field instead of going through the Arc. The tenets forbid #[deprecated] attributes by name. By name. And there one sat, in the tree, politely deprecating a path toward a 1.0 that will arrive long after the path is gone. Arc<Material> is the only wire form now.

The migration module. The .lux file format carries a version integer. lux_core::migration, all 845 lines of it, existed to translate old versions of that format forward into the current one. It is careful, well-meaning, thoroughly-tested infrastructure for a compatibility problem that does not exist. Every .lux file in the world was written by a build from the last few weeks, and I have met each one personally. There are no old versions to migrate. The module is gone and the version gate is strict equality. When the format actually stabilises at 1.0, migration code can come back, migrating from a real released format instead of from a thing that changed shape twice this month.

WelcomeState::Template. The welcome modal carried a second internal mode, kept “for backward compatibility” with a layout that no longer exists. There is no backward to be compatible with. Deleted. The sample gallery is the only mode.

The framegraph_chain raw-pointer fallback. A small unsafe block in the framegraph chain executor: a fallback view held as a raw pointer, from an era when the borrow checker and I were not on speaking terms. We’ve reconciled. The fallback is deleted and the unsafe left with it.

None of these was dangerous. Every one of them was a second way to do a thing that already had a first way, and a second way to do one thing is just the first thing you trip over at 2am.

#[lux_node], everywhere

The PinId post introduced the #[lux_node] attribute macro: declare a node’s pins in the macro arguments, and the PinId constants, the info() body, and the registration boilerplate all generate themselves. That post said the migration would be progressive: plugins move at their own schedule, the old string-indexed API keeps compiling. “Progressive” is the word you use when you mean “I’ll get to it.”

The Kornia analysis crate was the first crate written entirely through the macro. This sprint got to the rest of it. Eight pull requests, one per node family: Math and Logic, Animation, Spread, IO and Color and String and Mapping, Shape and SDF and Particle, the Texture filters and sources, the rest of Texture plus the Scene primitives, and the Scene cameras and deformers plus Export. 233 nodes across 42 plugin crates. Every node in the tree declares its pins through #[lux_node] now.

The mechanical win is the per-node boilerplate dropping by more than half. The structural win is bigger: there is no longer a hand-written info() anywhere that can silently disagree with the pin constants. The macro is the single source of truth, and a pin rename is a compile error at every call site instead of a runtime mystery three weeks later. The thing the PinId post promised, delivered to the last node.

The registry deletes itself

Once every node was on the macro, one more thing fell over.

Built, Not Adopted described build_registry as 200 lines of hand-rolled registry.register::<NodeT>() calls, one per node type, and started moving it onto the inventory crate: every #[lux_node] emits a factory spec at compile time, and build_registry collapses to a four-line walk over inventory::iter. That post added the honest caveat: “Plugins migrate one at a time; the hand-rolled fallback sticks around until the last one moves.”

The last one moved. The hand-rolled parity loop is deleted. build_registry is four lines, and adding a node to Lux no longer means remembering to edit a central file you will absolutely forget to edit. You write the node, the macro registers it, the inventory walk finds it. That’s the whole ceremony.

A few smaller ones

[workspace.lints] landed in the workspace Cargo.toml, with every crate inheriting it: clippy::pedantic on, wildcard_enum_match_arm denied so a new enum variant can’t slip through a catch-all arm unnoticed. Lint configuration used to be scattered across crate roots, drifting quietly apart. Now there’s one table.

Plugin-cross payload types (the structs that travel from a plugin through the engine to the render layer) got hoisted into lux-core, where the dependency rules say they belong. A handful of them had been defined render-side, which quietly meant a plugin had to know about a crate it is explicitly not allowed to depend on. Nobody noticed, because it compiled. It compiling was the bug. They live in lux_core::scene_payloads and lux_core::interfaces::offline now.

And a fix: the Render3D path had an .expect() on a shadow cascade resource that would panic if the resource went missing between frames. It’s a graceful skip now: log it and move on. Same lesson as the five unwraps in the render pipeline from a year ago, which I evidently needed to learn twice. The app should never die because a resource was one frame late to its own party.

Why this is a post at all

Deleting code does not demo. There is no screenshot. The app does exactly what it did before, which is the entire point of a breaking change nobody is depending on yet.

But the tenet is real and it has a deadline. The window where ripping out a shim costs nothing is the window before launch. After 1.0 every one of those four deletions becomes a migration guide, a deprecation cycle, and a support thread with someone who is, fairly, annoyed. The cheapest possible time to delete legacy code is before it is load-bearing for anyone who isn’t me.

So: the shims are gone, the macro migration is complete, the registry registers itself. The codebase is slightly smaller and now has exactly one way to do each of the things it just lost a second way to do.

Next post is the one the ResourceKind post promised. Async compute stops being a polite suggestion and goes live. The train rides the rails.


I have no idea what I’m doing or if any of this is right, but it’s fun. Follow along.

← Back to devlog