Nine Rules for Compile-Time Work with Rust const fn (Part 2)
Parse files, build tables, and catch mistakes … without a build script

This is Part 2 of an article about using const fn in Rust. Part 1 covers Rules 1 through 4; here, we examine Rules 5 through 9 and their examples:
- 5. Use const fn to replace repeated runtime calculations with lookup tables.
Example: Precompute a lookup table to provide gamma correction and runtime power limiting for an animated LED strip. - 6. Use const fn to construct programs in domain-specific languages.
Example: Linkage Blaze constructs the Armatron robot-arm program during compilation. - 7. Use const fn to build values of different types, then use &dyn Trait to combine them without heap allocation.
Example: One audio sequence combines an uncompressed tone, silence, and compressed NASA speech. - 8. Give up. When parsing with const fn becomes too complex or too slow, use a procedural macro, build script, or command-line generator.
Example: A motion-captured pirouette splits the work between const fn and a command-line generator. - 9. const fn does not choose between const and static; use static when address identity matters.
Example: Armatron’s touchscreen sliders use their static addresses as control identities.
Recall Rules 1 through 4 from Part 1:
- Process external files in const fn: no build.rs, no procedural macros, no runtime processing.
Example: The compiler sums an included file before the program starts. - Process simple file formats at compile time. For example, validate an image, transform its pixels, and retain only the result.
Example: A clockface image becomes validated, display-ready pixels for CYD Skeleton Clock. - Make two compile-time passes with a macro_rules! declarative macro. First derive sizing constants; then use those constants to construct an exactly sized value.
Example: The compiler discovers the size of a NASA audio clip, then constructs its exact-sized Rust value. - Use const fn to compose large values from small building blocks instead of listing every element.
Example: Stack and rotate two serpentine LED-panel layouts to produce one complete display layout.
Part 1 established the file-processing and composition techniques that support the remaining rules. We begin Part 2 with lookup tables.
Rule 5: Use const fn to replace repeated runtime calculations with lookup tables.
Consider an LED strip. Here we use Device Envoy to animate eight LEDs via a Pico:
Each addressable RGB LED contains red, green, and blue emitters. An 8-bit value from 0 through 255 controls each emitter. Before Device Envoy sends values to the strip, it adjusts each value in two ways:
- Brightness scaling limits worst-case current draw to a configured power budget.
- Gamma correction translates the application’s perceptual color value into the LED’s physical intensity. Because human vision responds nonlinearly to light, a value halfway between black and full brightness requires much less than half of the LED’s maximum output.
With a Device Envoy macro, users specify a strip’s length, power budget, and gamma curve at compile time:
led_strip! {
LedStrip8 {
pin: PIN_0,
len: 8,
max_current: Current::Milliamps(250),
gamma: Gamma::Srgb,
}
}At full brightness, Device Envoy assumes that each LED can draw as much as 60 milliamps, so eight LEDs can draw 480 milliamps. With a 250-milliamp budget, it limits maximum brightness to about 52% of full, or 132 on the 0-to-255 scale.
For gamma correction, Device Envoy stores a 256-item table for each of its three supported curves.
This const fn selects one gamma curve variant and folds the brightness limit into a new “combo” table:
pub const fn generate_combo_table(
gamma: Gamma,
max_brightness: u8,
) -> [u8; 256] {
let gamma_table = match gamma {
Gamma::Linear => &LINEAR_TABLE,
Gamma::Srgb => &GAMMA_SRGB_TABLE,
Gamma::SmartLeds => &GAMMA_SMARTLEDS_TABLE,
};
let mut result = [0_u8; 256];
let mut index = 0;
while index < 256 {
let corrected = gamma_table[index];
result[index] =
((corrected as u16 * max_brightness as u16) / 255) as u8;
index += 1;
}
result
}
At runtime, Device Envoy applies the table directly to each red, green, and blue value:
pixel.r = combo_table[pixel.r as usize];
pixel.g = combo_table[pixel.g as usize];
pixel.b = combo_table[pixel.b as usize];
Rule 5 conclusion
The LED-strip definition states the electrical and perceptual rules. During compilation, const fn applies those rules to all 256 input values and builds the table. At runtime, each color adjustment requires one lookup.
When a pure calculation has few possible inputs and runs frequently, use const fn to calculate each possible result once and store the results in a lookup table.
Rule 6: Use const fn to construct programs in domain-specific languages.
A domain-specific language (DSL) focuses on one problem area. Linkage Blaze is a DSL for describing animated jointed drawings and mechanisms.
Consider Armatron, a Linkage Blaze simulator modeled on the 1980s toy robot arm developed by Japan’s TOMY and sold in the United States by Radio Shack.
Six runtime parameters control the arm’s pose and claw:
You can try Armatron yourself. Use the robot sliders to move the hand closer to the red target. Use the view sliders to adjust the viewpoint. Armatron runs in a browser and on embedded devices such as the Cheap Yellow Display (CYD) from Rule 2. A separate browser-based interactive editor lets you modify the arm’s program or create a new mechanism and see the results immediately.
Here is the Linkage Blaze program that draws and controls the arm:
linkage![
// Define six runtime parameters. Each will control a turn or move.
.define_param("raise hand", 0.5)
.define_param("bend elbow", 0.5)
.define_param("close hand", 0.0)
.define_param("lower arm", 0.5)
.define_param("spin whole arm", 0.5)
.define_param("spin hand", 0.5)
// Draw the arm from its base to its wrist.
.yaw_param("spin whole arm", 180.0, -180.0)
.pen_color(Rgb888::new(0, 139, 139))
.pen_width(0.15)
.up(2.5)
.pitch_param("lower arm", -30.0, 0.0)
.forward(3.0)
.yaw_param("bend elbow", 90.0, -90.0)
.forward(3.0)
.pitch_param("raise hand", 90.0, -90.0)
.forward(1.0)
.roll_param("spin hand", -180.0, 180.0)
.forward(0.5)
.mark("wrist")
.yaw(90.0)
.forward_param("close hand", 0.5, 0.0)
.left(-1.0)
.restore("wrist")
.yaw(-90.0)
.forward_param("close hand", 0.5, 0.0)
.left(1.0)
.restore("wrist")
.pen_up()
.forward(0.25)
.pen_down()
]
Store the program in a checked type
The expression above constructs a compact, fixed-size linkage program of type LinkageFixed<6, 1, 25>. The three numbers reserve storage for six parameters, one mark, and 25 steps.
Aside: Marks support branching structures. Here, the one mark lets the arm draw both sides of the claw from the wrist. We’ll discuss parameters and steps below but not marks.
Each method in the expression takes the current program value and returns an updated one. The .define_param method records a parameter’s name and default value in a fixed-size metadata array, while methods such as .forward, .roll_param, and .pen_color append Step values to a separate fixed-size array.
Behind the scenes, a parameterized method such as .yaw_param finds a previously defined parameter and stores its index and range in the new Step. If the chain refers to an undefined parameter, compilation fails. Because the expression constructs a constant, Rust evaluates the const fn chain during compilation; the const generic parameters set the storage capacities. The resulting allocation-free LinkageFixed contains the parameter definitions and steps.
Aside: Programmers call this a fluent interface because each method returns a value on which the next method can operate, forming a readable chain.
Evaluate changing inputs in fixed memory
For this example, the application supplies a fixed-size array of six parameter values at runtime:
let params: [f32; 6] = [
0.72, // raise hand
0.35, // bend elbow
0.80, // close hand
0.45, // lower arm
0.61, // spin whole arm
0.27, // spin hand
];
Linkage Blaze’s evaluator walks through the active Step slice. Step::Forward advances the current position, while Step::Yaw and Step::Roll rotate the current orientation around different axes. Each instruction can contain either a fixed value or a value controlled by a runtime parameter. For example, .roll(...) and .roll_param(...) both produce Step::Roll values.
Given the parameter values, the evaluator can produce two kinds of output:
- A final pose, including the position of the last point — a calculation called forward kinematics in robotics.
- An iterator yielding the type and 3D geometry of every drawable item, providing everything needed to render the linkage.
Aside: For an explanation of and prototype for calculating the final 3D pose, see this spreadsheet project on GitHub.
Aside: Linkage Blaze uses a separate const fn system to project 3D items onto a 2D display. See the projection docs for more details.
At runtime, the application moves the simulated arm by evaluating the constant program with different parameter values.
Even with runtime parameters and branching geometry, evaluation uses a tiny, fixed amount of memory known at compilation time. DOF fixes the parameter-array size, MARKS fixes the saved branch-state storage, and N bounds the program’s step storage. The evaluator iterates drawable items instead of allocating and storing the complete 3D scene.
Composing Linkages as in Rule 4
Linkage programs can be composed like the LED panel layouts in Rule 4. The const fn method LinkageFixed::combine joins smaller linkage programs into one larger, fixed-size program during compilation.
In this example, CAMERA_AND_GRID supplies camera controls and a decorative floor grid, while the arm program describes the robot arm mechanism:
const SCENE_WITH_ARM: LinkageFixed<
{ CAMERA_AND_GRID.dof() + arm::DOF }, // New DOF
{ CAMERA_AND_GRID.mark_count() + arm::MARKS }, // New Marks
{ CAMERA_AND_GRID.step_count() + arm::STEP_COUNT - 1 },// New step count
> = CAMERA_AND_GRID.combine(arm::view());
The combine method concatenates their steps, combines their parameter and mark metadata, and adjusts the internal indices of the second program. The compiler constructs the final scene without dynamic allocation.
Aside: You can run the demo on a phone that likely uses an ARM processor: a WebAssembly simulation of an ESP32 simulation of a robot arm running on ARM. In other words, a simulated, simulated ARM arm.
Rule 6 conclusion
A const fn need not produce the final runtime result. When a computation’s structure is known at compilation time but its inputs change at runtime, use const fn to construct and check a fixed-memory DSL program, then interpret that program at runtime.
Rule 7: Use const fn to build values of different types, then use &dyn Trait to combine them without heap allocation.
In Rule 3, we saw how to load a simple, uncompressed audio file into an exactly sized type. But what if we want one sequence containing different audio types? For example:
- Uncompressed PCM (pulse-code modulation): Say, a musical tone synthesized into an exactly sized array of audio samples during const evaluation.
- Silence: store no samples, only the duration. Silence therefore requires very little storage, but it needs a different playback path.
- Compressed ADPCM (adaptive differential pulse-code modulation): for example, NASA’s “Discovery’s four computers…” recording. ADPCM encodes differences between samples, reducing storage by about 75% while requiring decoding during playback.
The following video shows a Raspberry Pi Pico playing these three clips in sequence:
The code that plays the sequence is:
audio_player_pin8.play([CHIME, GAP, NASA], AtEnd::Stop);
This call passes an ordinary three-element array, but the array does not store three clip values of one concrete type. CHIME, GAP, and NASA refer to values with different concrete types and sizes. The array works because every element has the same type: a reference to a Playable trait object.
Give different clip types one array-element type
Let’s unpack this from the outside in, starting with the signature of play and the AudioPlayer trait that defines it:
pub trait AudioPlayer {
fn play<'clip, I>(&self, audio_clips: I, at_end: AtEnd)
where
I: IntoIterator<
Item = &'clip dyn Playable,
>;
}This signature tells us three things.
- Every audio player has one sample rate, represented by the const parameter SAMPLE_RATE_HZ. The generated player in this example implements AudioPlayer<8000>.
- play accepts anything implementing IntoIterator, including an array.
- Every item produced by that iterator must be a reference to a dyn Playable.
dyn Playable lets the player call the appropriate playback implementation without knowing the clip’s concrete type. Because AudioPlayer and Playable use the same SAMPLE_RATE_HZ parameter, trying to include a clip prepared for a different rate produces a compile-time type error.
What makes a value playable? Its concrete type implements the Playable trait. The trait can look like this:
pub trait Playable
{
fn playback_clip(&self);
}
Device Envoy supplies implementations for PCM clips, ADPCM clips, and silence. For example:
impl<
const SAMPLE_RATE_HZ: u32,
const DATA_LEN: usize,
> Playable
for AdpcmClip
{
fn playback_clip(&self) {
// ... code to play the clip
}
}
The Playable trait must be dyn compatible—called “object safe” in older Rust terminology. This means that a reference to a value whose concrete type implements the trait can be coerced to &dyn Playable; Rust then calls the appropriate playback_clip implementation through the trait object. The referenced value keeps its concrete type and representation, while code using the reference needs to know only the Playable interface. Because the array contains borrowed references rather than heap-allocated Box values, the three clip types share one array-element type without heap allocation.
The compiler determines dyn compatibility from the trait’s definition. Roughly, methods callable through a trait object must not require the caller to know the concrete implementing type. playback_clip receives the value through &self, so the caller needs only a reference. A method that returned Self, for example, could not be called through &dyn Playable.
Aside: This is a simplified but workable trait definition. The actual Device Envoy code requires clip references to be &'static, allowing the background task to retain references to them after play returns without copying the clip data. Device Envoy also uses the sealed-trait pattern to restrict which types can implement Playable and returns an internal playback enum used by the background task.
Aside: &dyn Trait is like virtual-method dispatch through vtables in languages such as C++. Consider it a tenth way to do inheritance in Rust, a language without inheritance.
Constructing the three clip values
We construct the three clips like this:
pcm_clip! {
Nasa {
file: concat!(env!("CARGO_MANIFEST_DIR"), "/examples/data/audio/nasa_22k.s16"),
source_sample_rate_hz: VOICE_22050_HZ,
target_sample_rate_hz: AudioPlayerPin8::SAMPLE_RATE_HZ,
}
}
//...
const CHIME: &AudioPlayerPin8Playable =
&tone!(880, AudioPlayerPin8::SAMPLE_RATE_HZ, ms(100)).with_gain(Gain::percent(20));
const GAP: &AudioPlayerPin8Playable = &SilenceClip::new(ms(500));
const NASA: &AudioPlayerPin8Playable = &Nasa::adpcm_clip();AudioPlayerPin8Playable is a type alias for dyn Playable<8000>. The tone! macro constructs an uncompressed 880 Hz tone that lasts 100 ms. with_gain makes the tone quieter by linearly scaling every PCM sample to 20% of its original amplitude during const evaluation. SilenceClip::new constructs half a second of silence, storing only its duration rather than an array of samples. Finally, Nasa::adpcm_clip() resamples the original 22,050 Hz PCM recording to 8,000 Hz and encodes it as compressed ADPCM during const evaluation. The program requires no runtime tone synthesis, resampling, or compression.
Rule 7 conclusion
Const evaluation prepares each clip in the representation best suited to it: an exactly sized PCM sample array, a duration-only silence value, or an exactly sized ADPCM byte array. References to dyn Playable<8000> let the program use all three through one common interface while each value retains its original type, representation, and size. Those references can therefore form one ordinary array without copying the audio data or allocating anything on the heap.
Rule 8: Give up. When parsing with const fn becomes too complex or too slow, use a procedural macro, build script, or command-line generator.
Linkage Blaze can animate more than robot arms. The same crate can spin clock hands in three dimensions, make a skeleton signal the time with spinning signs, or play back a motion-captured ballet pirouette. All three demonstrations can run on a microcontroller or in a web browser.
Here is the pirouette:
Motion capture records a performer’s movements — often using markers attached to a special suit — so we can apply those movements to a digital figure. The resulting recording contains two kinds of information: motion measurements and a skeleton. The measurements record the performer’s root position and joint orientations over time. The skeleton describes the joints, their parent-child relationships, and their connections.
This pirouette comes from a 747 KB Biovision Hierarchy (BVH) file. BVH stores the measurements and the skeleton as structured text. The question is: how should we turn that file into a Linkage Blaze animation?
Parse the motion measurements during const evaluation
I decided to parse the motion measurements with a const fn. This uses the same general approach as the earlier rules, but on a larger scale. Here is some of the input:
HIERARCHY
ROOT hip
{
CHANNELS 6 Xposition Yposition Zposition Zrotation Yrotation Xrotation
JOINT abdomen
{
CHANNELS 3 Zrotation Xrotation Yrotation
JOINT chest
{
CHANNELS 3 Zrotation Xrotation Yrotation
...
}
}
}
...
MOTION
Frames: 592
Frame Time: 0.00833333
15.3137 84.8855 152.037 0.0 0.0 0.0 -0.188091 -0.00125237 -0.00538531 ...
...
-22.259 71.4852 127.337 180.965 -18.7087 573.482 -14.7504 ...
Getting from this text to an exactly sized table of 592 samples, each containing 132 parameter values, requires more than splitting a few strings.
The parser:
- scans the hierarchy to find every position and rotation channel declared for each joint;
- records whether that value represents position or rotation;
- finds and validates the MOTION, Frames:, and Frame Time: fields;
- parses signed decimal floating-point values, including exponent notation;
- verifies that the file contains exactly the expected number of values and samples;
- normalizes position and rotation values into Linkage Blaze’s parameter range; and
- quantizes each normalized f32 value into a u16.
The result is a const value containing an exactly sized array with 592 rows and 132 columns. Each row represents one recorded moment; each column holds one parameter—usually a joint rotation—for one of the skeleton’s 132 degrees of freedom.
The BVH motion parser is about as complicated as I would want a const fn to be. In fact, it may already be over the line. Whenever I rebuild the relevant crate from scratch, the compiler spends about eight seconds on my development machine re-parsing the 747 KB file.
Parse the skeleton with a separate command-line tool
The motion measurements ultimately form a regular, exactly sized table. The skeleton does not. It is a nested tree in which joints can contain different numbers of child joints. Parsing and constructing it with const fn would be impractical.
Three alternatives move the work out of const evaluation and into ordinary Rust, where the parser can use String, Vec, recursion, and Result values with detailed errors:
- A procedural macro: Runs during compilation and can generate Rust code but can still slow builds when processing large files. Procedural macros are also complex to write. See Nine Rules for Creating Procedural Macros in Rust.
- A build.rs build script: Runs automatically before Cargo compiles a crate and can generate files in Cargo’s OUT_DIR. With cargo::rerun-if-changed, Cargo reruns the script only when specified inputs change.
- A command-line generator: Runs explicitly and can produce an artifact that we can cache, inspect, test, and check into source control. The tradeoff is that someone must rerun the generator when the input changes.
For the skeleton, I chose the command-line approach. A tool named bvh-to-lb converts the BVH skeleton into a Linkage Blaze source file, which I check into the repository. Ordinary builds use that generated file without running bvh-to-lb again.
Rule 8 conclusion
This one BVH file shows two reasons not to use const parsing. The motion measurements fit naturally into an exactly sized table, but parsing them adds about eight seconds to a clean rebuild. The skeleton is smaller, but its nested structure makes constructing it with const fn impractical.
Use const parsing when the input and result fit fixed-size data structures and the compilation cost is acceptable. When const evaluation makes the parser awkward, use ordinary Rust in a procedural macro, build script, or command-line generator. If rebuild time is the problem, choose an approach that can reuse the generated result. The boundary is not what const fn can parse; it is what you are willing to make the compiler parse during a rebuild.
Rule 9: const fn does not choose between const and static; use static when address identity matters.
Rule 6 introduced Armatron, Linkage Blaze’s robot-arm simulator. Now consider its custom touch UI:
Armatron runs a game loop: on every frame, it reads the touchscreen, updates the arm’s state, and redraws the three-dimensional scene and every UI control, including sliders, buttons, and text labels. Presenting the controls anew on every frame makes this animmediate-mode UI.
To handle touch interactions, the code needs two kinds of information.
First, it needs each control’s location to determine which control a new touch selects. The const fn Slider::vertical constructs this layout information. We assign its result to a static, not a const; the second requirement explains why.
// Construct a vertical "z" slider at (16, 24), 201 pixels tall,
// with values running from 1.0 at the top to 0.0 at the bottom.
static TILT_SLIDER: Slider =
Slider::vertical("z", 16, 24, 201, 1.0, 0.0);
Second, the code must remember which slider captured a drag that spans multiple frames. The drag remains attached to that slider even when the touch moves outside its bounds.
Each frame, the simulator calls UiFrame::slider once for each static slider layout, passing that layout by reference:
ui_frame.slider(&TILT_SLIDER, &mut params[TILT_PARAM_INDEX])?;
ui_frame.slider(&DOLLY_SLIDER, &mut params[DOLLY_PARAM_INDEX])?;
ui_frame.slider(&XY_VIEW_SLIDER, &mut params[XY_VIEW_PARAM_INDEX])?
When a touch begins, UiFrame::slider stores a reference to the selected slider. On later frames, as the game loop visits each slider again, it compares the current slider with the stored one:
ptr::eq(active_slider, slider)
Only the selected slider continues responding when the touch moves outside its bounds. ptr::eq asks whether two references point to the same slider; it does not compare the sliders’ values.
A const names a value, not a storage location. This lets the value participate in further constant expressions and lets Rust retain the final results without keeping the intermediate values used to produce them. Because a const does not name storage, a program cannot rely on one stable address as its identity.
A static names one storage location that lasts for the entire program. Every reference to TILT_SLIDER points to that location, giving the slider a stable identity. That is why TILT_SLIDER is a static: Slider::vertical constructs the slider during const evaluation, while the static declaration gives the resulting slider its identity.
Aside: Couldn’t we declare the slider as a const and give it a string ID? Yes, but then every slider would need a separate ID, and the code would need to prevent duplicates. A named static already has a unique, stable address, so its address supplies the ID for free.
Aside: Ordinary English makes “constant” and “static” near synonyms and “mutable static” a contradiction. Set English aside. To paraphrase Humpty Dumpty, when Rust uses the words const and static, they mean just what Rust chooses them to mean—neither more nor less.
Rule 9 conclusion
const fn, const, and static answer different questions. A const fn makes compile-time construction possible. A const names a value that can feed further constant expressions. A static names one long-lived storage location, giving its value a stable identity.
In Armatron, const functions construct the UI layouts during compilation, while named static items give the interactive controls stable identities at runtime. Use a const when only the value matters; use a named static when a stable address is part of the value’s meaning.
Article Conclusion
So, there you have it — nine rules for compile-time work with Rust const fn.
Here’s what surprised me while using const fn across Device Envoy and Linkage Blaze:
- Const generics matter almost as much as const fn. Much of the power in these examples comes from carrying compile-time facts—sizes, dimensions, sample rates, counts, and degrees of freedom—into the type system. const fn computes values, while const generics make many of those values part of the type and therefore checkable.
- Declarative macros can hide some of the awkwardness. Stable Rust sometimes requires extra const-generic parameters or repeated compile-time passes. A macro_rules! declarative macro can derive needed constants, keep repeated file paths in sync, and generate type aliases or other boilerplate.
- Compile-time code can afford patterns that would look wasteful at runtime. The Linkage Blaze language (example below) repeatedly copies an entire fixed-size program value, appends one step, and returns the updated copy. That repeated copying could be expensive in hot runtime code, but here the compiler performs the construction once and the program keeps only the final value.
LinkageFixed::start()
.up(2.5)
.pitch_param("lower arm", -30.0, 0.0)
.forward(3.0)
.yaw_param("bend elbow", 90.0, -90.0)
.forward(3.0)
.pitch_param("raise hand", 90.0, -90.0)
.forward(1.0)
Those surprises describe using const fn in practice. Stepping back, how does it compare with the alternatives?
Rust is not unusual in being able to do work before runtime. C++ has constexpr and consteval; Zig has comptime; and Rust itself offers procedural macros, build.rs, and command-line generators.
What distinguishes const fn is the combination shown below: the computation remains ordinary Rust code in the target program, and Rust applies its type, ownership, and borrowing rules during const evaluation.

C++ constexpr/consteval and Zig comptime also keep build-time computation in the program, but they do not provide Rust’s safety guarantees. Rust’s procedural macros, build scripts, and command-line generators can use safe Rust, but they execute outside the target program. What const fn gives us is Rust’s safety guarantees at compile time, inside the same program that uses the result.
Thanks for following along with this exploration of compile-time work with Rust const fn. I hope some of these lessons prove useful when you decide what work belongs at compile time and how to structure it.
Aside: If you’re interested in future articles, please follow me on Medium. I write on scientific programming in Rust and Python, machine learning, and statistics. I tend to write about one article per month.
Nine Rules for Compile-Time Work with Rust const fn (Part 2) was originally published in Level Up Coding on Medium, where people are continuing the conversation by highlighting and responding to this story.