The Backend That Never Ran a Float
A ray tracer — three spheres, a mirror bounce, hard shadows, all the vector math on tuples of floats — rendered identically on the interpreter and the C backend, and died in the WebAssembly assembler: local.set expected i32, got f64. The cause was stranger than a missing feature. Floats on Wasm are boxed, boxing needs a raw-f64 temporary, and the machinery to declare those temporaries with the right type existed and worked — but only the top-level emitter read it. The three function emitters declared every local i32, unconditionally. Float expressions at the top level had always worked; a float temporary inside a named function had never once made it through the assembler. The test suite asserted on the emitted text and assembled only integer programs, so the gap was invisible until a program cared.
The last unmeasured axis on the probe list was floats on the WebAssembly backend. The C backend had already been through a float probe — a Mandelbrot, which found documentation gaps and no churn — but Wasm represents floats differently: boxed, an i32 pointer to a heap-allocated f64, unboxed for each operation and re-boxed after, preserving the backend’s uniform everything-is-an-i32 value model. A ray tracer is the natural forcing program: almost nothing but float arithmetic, thousands of operations per pixel, every one of them a box. Three spheres on a giant ground sphere, a directional light, Lambertian and specular shading, hard shadows, one mirror bounce — and a design constraint that every backend must print the same checksum and write the same image file, so a divergence anywhere has nowhere to hide.
Identical on two backends, dead on the third
The interpreter rendered it. The C backend rendered it, byte-for-byte the same, and the
image looked right — reflections in the mirror sphere, shadows pooling under the others.
The Wasm build failed before running a single instruction: the assembler rejected the
module with local.set expected [i32] but got [f64]. Not a runtime wrong answer — a
type-invalid module. The compiler had emitted WebAssembly that WebAssembly refused to be.
The machinery that only one emitter read
The failing function was the pixel quantizer, and the failing instruction was the boxing
of the literal 255.999. Boxing a float means holding the raw f64 in a temporary local
while the allocator carves out eight bytes — and Wasm locals are typed, so that temporary
must be declared f64. Here is the strange part: the machinery for this existed and was
correct. There was a list tracking the type of every minted temporary, an f64-minting
helper that recorded into it, and the top-level program emitter faithfully read the list
when declaring its locals. But the three emitters for functions — top-level functions,
lifted inner functions, closure adapters — never consulted it. Each declared its extra
locals with a hardcoded i32, as many as the counter said, types be damned.
Which means the division of labor had hidden the bug perfectly. A float expression at the
top level of a program lands in the main-body emitter and works. The same expression
inside a named function lands in a function emitter and produces an invalid module. The
test suite’s float tests asserted on the emitted text — is there an f64.add in the
output — without assembling it, and the tests that did assemble and run were the
self-hosting suite, which is a compiler, which is integers and strings from end to end.
Every float test passed and no float function had ever been through the assembler. The
backend had, in the precise sense, never run a float program shaped like real code.
Three emitters, one list
The fix was to make the three function emitters do what the main emitter had always done: save the type list, reset it per function, and declare each extra local with its recorded type — with one wrinkle in the closure adapter, whose first few locals are capture unpacks, always i32, prepended before the tracked temporaries. Integer-only programs mint nothing but i32 temporaries, so their output is byte-identical to before — which matters, because the self-hosting fixpoint compares emitted WAT as strings, and the self-hosted compiler is an integer program. The fixpoint never flinched. With the declarations fixed, the ray tracer assembled, ran, and printed the same checksum as the interpreter and C, then wrote the same image file to the byte. Three backends, one picture. What it cost to render it — that measurement deserved its own episode.