
I translated DOOM from C to Rust without writing a line, then used the model for what it is actually good at
I needed to move a program from C to Rust and my first idea was to ask a model. A tool already existed that does it in 23 seconds. The translation came out identical across 11,113 frames, and the model ended up doing something else entirely: textures, per-pixel relief and continuous lighting on top of the original engine.
I needed to move a program from C to Rust. My first idea was the easy one: ask a model, chunk by chunk, and review the result.
Before starting I asked the question worth asking every time: does this already exist? It did. It is called c2rust, Immunant maintains it, and it translates C to Rust while preserving semantics. I tried it on zlib first, then on something you can actually look at: DOOM.
This article is what I measured. It ends with all three versions playable in the browser.
The question worth asking first
doomgeneric is 73,095 lines of C across 95 files. The translation produced 143,911 lines of Rust in 80 modules, and took 23.2 seconds.
I could write here that translating that with a model gets expensive. I measured it, and it does not: 550,595 input tokens and 1,896,319 output tokens, counted with the o200k_base tokenizer. A few dollars at today’s prices. The saving is not the argument, and claiming it is would be selling smoke.
The argument is a different one, in three parts.
The result is reproducible. I run c2rust twice and get the same file, byte for byte. I can review a change with diff and I can regenerate it a year from now. A model returns something different every run.
The errors are systematic. The four problems I hit showed up many times, always the same way: 23 sites of the same array pattern, five copies of the same duplicated function, 40 literals of the same type. You find one and fix the whole class. A scattered error on line 40,000 gets found one at a time.
And all of them failed loudly. The first two attempts died with index out of bounds and the third would not compile. None of them stayed quiet while returning a wrong number, which is the kind that actually costs you.
Installing it, stumbles included
c2rust 0.22.1 does not build against LLVM 22, which is what Homebrew installs by default: Clang’s API changed and getTypeForDecl was deprecated. You need llvm@18.
brew install llvm@18
LLVM_CONFIG_PATH=/usr/local/opt/llvm@18/bin/llvm-config \
PATH="/usr/local/opt/llvm@18/bin:$PATH" cargo install c2rust
Two more that cost time: cargo install c2rust c2rust-transpile fails entirely because c2rust-transpile is a library, not a binary; and setsid does not exist on macOS, so a background job launched with it never starts and the log only says “command not found”.
It breaks exactly where the C lies
The first two attempts to run the translated DOOM died with index out of bounds. Both for the same reason, and it is a beautiful one: DOOM declares arrays smaller than it uses, on purpose, and says so in a comment.
int columnofs[8]; // only [width] used
byte pad1; byte top[SCREENWIDTH]; byte pad2, pad3;
byte bottom[SCREENWIDTH]; byte pad4;
// leave pads for [minx-1]/[maxx+1]
The pad fields exist to absorb the out-of-range write. In C nobody checks anything; in Rust the bounds check fires. c2rust translated the declaration faithfully. The problem is that the declaration has been false since 1993. There are 23 sites, and they are fixed by going back to pointer arithmetic.
There is a trap inside the trap: patch_t is declared packed, so taking a reference to the field will not compile. You have to go through a raw pointer.
And one compile error of its own: toupper defined five times. c2rust translated macOS’s <ctype.h> inline function into every module that includes the header, each copy exported, and they collide with each other and with libc.
The verdict: 11,113 frames, one of them different
DOOM ships recorded demos. A demo is a sequence of inputs: if a single calculation changes, the playback drifts and the tic count stops matching. There is nothing to interpret.
I also hashed every frame the engine hands to the screen, because the tic count cannot see a drawing bug.
| Demo | Tics | Frames | Different |
|---|---|---|---|
| demo1 | 5026 | 5065 | 0 |
| demo2 | 3836 | 3875 | 1 |
| demo3 | 2134 | 2173 | 0 |
Frame 2363 of demo2 differs by one pixel. Before writing that down as the tool’s only failure, I ran the C binary against itself three times:
C, run 1 (143, 118, 75)
C, run 2 ( 90, 88, 41)
C, run 3 (another)
Rust ( 93, 79, 32)
The original DOOM reads an uninitialised pixel there. It differs from itself on every run, and one of those runs matched the Rust one exactly. The bug is from 1993, and the translation inherits it faithfully.
The price: almost half the file is unsafe
On zlib, which is smaller and has a human rewrite to compare against, I measured the lines that fall inside an unsafe block:
| Lines | Inside unsafe | ||
|---|---|---|---|
| c2rust, translation | 25,517 | 12,026 | 47.1% |
| zlib-rs, human rewrite | 13,399 | 4,179 | 31.2% |
Counting occurrences of the word unsafe gives the opposite answer, 0.98% against 2.56%, and it is a misleading metric: the translator wraps whole functions in a single block, so one occurrence covers two hundred lines. What you have to measure is the lines covered.
And it has to be said that these are not comparable things: zlib-rs is a rewrite, with redesigned structures and the type system doing work. An automatic translation cannot do that; its goal is to not change the program.
Ten times slower because of a four-line struct
The translated DOOM ran at a tenth of the original’s speed. The profiler put 98% of the time in a function that converts the 8-bit screen into the framebuffer, and inside it, in a bitfield accessor.
The whole cause is this:
struct color { uint32_t b:8, g:8, r:8, a:8; };
c2rust turns bitfields into a four-byte array with generic accessors that rebuild the value bit by bit at runtime and never inline into the caller. In C, reading c.r is loading a byte.
| demo1, realtics (lower is better) | |
|---|---|
| C with clang -O2 | 131 · 138 · 139 · 133 · 135 |
| Translated Rust | 1330 · 1396 · 1447 · 1428 · 1412 |
| Rust, with that struct changed | 132 · 135 · 131 · 129 · 132 |
Replacing that struct with the four bytes it already was (same memory layout, same semantics, about ten lines), the translated Rust ties with C.
The measured lesson: the Rust that comes out of c2rust is not slow; bitfields are. Any C that uses them in a hot loop inherits that cliff.
The artifact is nailed to the machine it was translated on
This was the finding I did not expect. Compiling the same code for WebAssembly revealed that the translation is not portable: it carries the machine it was made on baked in.
Three things, and all three compile cleanly on the original machine:
Type widths come frozen in. A constant that fits in a long integer on a 64-bit system no longer fits on a 32-bit one. Forty compile errors at once.
The startup initialisers never run. The tool registers them with a mechanism that only exists on Linux, Windows and macOS. On any other target the function ends up registered nowhere, and the program compiles cleanly and fails at runtime, with a message that points at none of this.
And there are internal symbols from Apple’s libc inside the binary, because macOS implements things like isspace by indexing its own global tables, and those reads got baked in.
None of the three shows up while you work on the machine you translated on. The details and the fix for each are in the repository.
Now the AI
With the translation verified, the question became a different one: what is the model good for, if not translating?
The first thing I tried was the obvious one, and it went badly. I ran a screenshot of the game through an upscaler:
The model hallucinated an office building with glass windows where there was a wall of DOOM panels. It read the vertical pattern as modern architecture. The model trained on drawings erased everything: the shotgun became a smear.
On the clean texture from the WAD, though, it works very well. The input was the problem: a screenshot carries perspective, lighting and dithering on top, and the model does not know how to undo that.
The rule: the AI adds, it does not replace
That is where the design that ended up working came from. The pack the engine loads stores a one-channel map with an average of exactly 128 per texel, and the engine does
final_colour = doom_colour × detail / 128
Because the average over each texel is 128 by construction, the colour, the shading and the lighting stay exactly what the 1993 engine computes. The only thing the model contributes is variation inside the texel, which is precisely what the engine lacks when a wall is close to the camera and one texel stretches across many pixels.
FLOOR0_1, texel (40, 40). Los números son los del paquete.rgb(111 87 67)media 128,06 ≈ 128promedio = el del motorComo la media del bloque es 128 por construcción, multiplicar pordetalle / 128 y promediar sobre el texel devuelve exactamente el color que calculó el motor de 1993. El modelo no puede correr la imagen ni aunque se equivoque de estilo: solo reparte, dentro del texel, un promedio que ya está fijado.
This took three attempts. Replacing the colour left floors much darker and in a different hue, because DOOM’s flats are dithered: two very different palette entries alternating to fake an intermediate tone, and the model reads that dithering as noise. Renormalising each texture’s global average is not enough either: the drift is local. What works is anchoring against the average of the block itself, because then no subtraction can saturate and shift the mean.
The relief comes for free
And then the best part showed up: that detail map is already a height map. The model drew where the surface sticks out and where it sinks in. Its gradient gives the normal, and with a fixed light you get per-pixel shading.
The original DOOM has nothing like it: every surface gets one light level from a 32-step table. And it does not cost a single extra byte, because the height was already in the map.
Stop throwing away lighting bits
With the relief in place, it still showed less than I expected. The cause was elsewhere: the engine computes the lighting precisely and then truncates it to 48 steps for walls, 128 for floors, and then remaps to 256 colours. At 320×200 that hides. Any higher and it is the strongest visual signature of “this is old”, the concentric rings on the floor, and it also flattens all the relief into a single band of light.
Since the world buffer is already 32-bit, that quantisation has no reason to exist. I keep the neighbouring step and the fraction the engine discards, and interpolate. The engine still decides the light; I just stopped throwing bits away. On a receding floor, distinct tones went from 32 to 45.
The 1993 numbers that stop being enough
To give the 4× detail somewhere to live I had to raise the internal resolution, and there DOOM shows its age. The screen arrays carry their size written by hand, because the tool resolved the constants at translation time. And some limits are from the original design: vertical screen coordinates live in a byte, so above 255 rows they overflow silently and the demo cuts out. That is the real resolution ceiling of 1993’s DOOM.
One took some finding and teaches a method. When widening a structure to 16 bits, the routine that clears it kept computing the size from the previous type and wiped half the array. On screen, vertical streaks hung off the geometry, and reading the code there was no way to tell where they came from.
What found it was downscaling the render to the original resolution, comparing it pixel by pixel against the C binary and painting a map of the differences:
La asimetría es el diagnóstico: 18 veces más diferencias en la mitad derecha. Solo algo que trata distinto a las dos mitades de un arreglo produce esa forma. Era un memset que seguía calculando el tamaño sobre el tipo anterior y borraba la mitad de los bytes.
The shape names the bug. A calculation error spreads across the whole screen; a memory one that treats the two halves of an array differently leaves exactly this asymmetry.
Optimising down to real time
With everything enabled, the renderer stopped hitting real time. The profiler put 2690 of 4043 samples in the loop that draws wall columns. Two techniques, neither of which touches quality:
Take what does not change out of the hot loop. The relief gradient is static per texture and was being recomputed for every pixel: from 5 samples down to 2, by baking it into the pack.
Kill the integer divisions. There were six modulo operations per pixel to wrap rows and columns. DOOM reads the row by masking to 128 but the texture can be shorter. Now the wrap comes from a 512-entry table built at load time: zero divisions.
| demo1, realtics | |
|---|---|
| Before | 7215 |
| After | 3009 |
2.4 times faster, with a deviation of 2.25 out of 255 against the unoptimised version: invisible.
What you see and what you do not
With all three texture layers (54 floors, 125 walls, 483 sprites), 76.3% of the pixels in the 3D view change relative to the untouched translation.


And here is a number that contradicts intuition: sprites contribute between 1.0% and 1.2%. They were the hardest of the three. They have transparency, their columns come split into posts, and the engine hands you a column that starts halfway down the sprite. And they are the ones you notice least, because enemies and the weapon take up very little screen. Walls and floors are almost everything.
| Native | Browser | Data | |
|---|---|---|---|
| 1280×800 | 38 fps | 18 fps | 45.6 MB |
| 640×400 | 160 fps | 61 fps | 45.6 MB |
I settled on 640×400. The visual jump comes from the relief and the lighting, not from the pixels: resolution was what it took to see the textures, and at 640 you already see them.
All three, playing
The original C code, that same code translated to Rust by a machine, and that translation with the layers above. All three compiled to WebAssembly. All three report the same tic count on the recorded demos, so the claim that they play the same is measured.
The translated engine, the verification harness and the pack generators are in doom-c2rust, with the comparison shots for each scene. The WAD is not included: it belongs to id Software.
When I would use c2rust and when I would not
Yes, when the goal is to stop compiling C without changing behaviour: porting a library into a Rust project, or having a base to rewrite piece by piece with the original’s tests as a safety net. It is deterministic, it is fast, and it does not invent.
No, if what you want is safe Rust. Almost half the file lands in unsafe and the result is nailed to the architecture and the libc of the machine it was translated on. And no, not either, if the C plays tricks with its own declarations: the manual work starts there the moment it compiles.
On the model: I would not use it to translate. I would use it exactly the way it ended up being used here, to generate data that modulates what the program already computes well. That constraint, that the engine keeps deciding, is what made the result add instead of spoil.
Sources
- c2rust, Immunant. Version 0.22.1.
- doomgeneric, ozkl. A DOOM port with a single platform dependency.
- zlib-rs, Trifecta Tech Foundation. The human rewrite I compared
unsafeagainst. - Real-ESRGAN, Xintao Wang et al. Model
realesrgan-x4plus, run through the ncnn-vulkan implementation. - doom-c2rust, the code from this article. GPL-2.0, inherited from DOOM.
- DOOM shareware WAD v1.9, sha1
5b2e249b9c5133ec987b3ea77596381dc0d6bc1d, redistributable.
Comments
No comments yet. The first one is yours.