
Traduje DOOM de C a Rust sin escribir una línea, y después usé el modelo para lo que sí sirve
Necesitaba pasar un programa de C a Rust y mi primera idea fue pedírselo a un modelo. Existía una herramienta que lo hace en 23 segundos. La traducción quedó bit a bit idéntica en 11.113 tramas, y el modelo terminó sirviendo para otra cosa: texturas, relieve por píxel y luz continua sobre el motor original.
Necesitaba pasar un programa de C a Rust. Mi primera idea fue la fácil: pedírselo a un modelo, trozo a trozo, y revisar el resultado.
Antes de empezar me hice la pregunta que conviene hacerse siempre: ¿esto ya existe? Existía. Se llama c2rust, lo mantiene Immunant, y traduce C a Rust conservando la semántica. Lo probé primero sobre zlib y después sobre algo que se pudiera mirar: DOOM.
Este artículo es lo que medí. Termina con las tres versiones jugables en el navegador.
La pregunta que conviene hacerse antes
doomgeneric son 73.095 líneas de C en 95 archivos. La traducción produjo 143.911 líneas de Rust en 80 módulos, y tardó 23,2 segundos.
Podría escribir aquí que traducir eso con un modelo sale caro. Lo medí, y no sale caro: son 550.595 tokens de entrada y 1.896.319 de salida, contados con el tokenizador o200k_base. A los precios de hoy, unos pocos dólares. El ahorro no es el argumento, y decir que lo es sería vender humo.
El argumento es otro, y tiene tres partes.
El resultado es reproducible. Corro c2rust dos veces y obtengo el mismo archivo, byte a byte. Puedo revisar un cambio con diff y puedo volver a generarlo dentro de un año. Un modelo devuelve algo distinto en cada corrida.
Los errores son sistemáticos. Los cuatro problemas que encontré aparecían muchas veces, siempre igual: 23 sitios del mismo patrón de arreglo, cinco copias de la misma función duplicada, 40 literales del mismo tipo. Encuentras uno y arreglas la clase entera. Un error disperso en la línea 40.000 se busca de a una.
Y todos fallaron ruidosamente. Los dos primeros intentos murieron con index out of bounds y el tercero no compiló. Ninguno se quedó callado devolviendo un número equivocado, que es lo que de verdad cuesta caro.
Instalación, con los tropiezos
c2rust 0.22.1 no compila contra LLVM 22, que es lo que instala Homebrew por defecto: la API de Clang cambió y getTypeForDecl quedó obsoleta. Hay que usar 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
Otros dos que costaron tiempo: cargo install c2rust c2rust-transpile falla entero porque c2rust-transpile es biblioteca, no binario; y setsid no existe en macOS, así que un trabajo lanzado en segundo plano con él nunca arranca y el registro solo dice “command not found”.
Falla exactamente donde el C miente
Los dos primeros intentos de ejecutar el DOOM traducido murieron con index out of bounds. Los dos por la misma razón, y es una razón bonita: DOOM declara arreglos más chicos de lo que usa, a propósito, y lo dice en un comentario.
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]
Los pad existen para absorber la escritura fuera de rango. En C nadie comprueba nada; en Rust el chequeo de límites salta. c2rust tradujo fiel la declaración: el problema es que la declaración es falsa desde 1993. Son 23 sitios, y se resuelven volviendo a aritmética de punteros.
Hay una trampa dentro de la trampa: patch_t está declarado empaquetado, así que tomar una referencia al campo no compila. Hay que ir por puntero crudo.
Y un error de compilación aparte: toupper definido cinco veces. c2rust tradujo la función inline de <ctype.h> de macOS a cada módulo que incluye el encabezado, cada copia exportada, y chocan entre sí y con la libc.
El veredicto: 11.113 tramas, una sola distinta
DOOM trae demos grabados. Un demo es una secuencia de pulsaciones: si un solo cálculo cambia, la partida se desvía y el conteo de tics deja de coincidir. No hay interpretación posible.
Además hasheé cada trama que el motor entrega a la pantalla, porque el conteo de tics no ve un fallo de dibujo.
| Demo | Tics | Tramas | Distintas |
|---|---|---|---|
| demo1 | 5026 | 5065 | 0 |
| demo2 | 3836 | 3875 | 1 |
| demo3 | 2134 | 2173 | 0 |
La trama 2363 de demo2 difiere en un píxel. Antes de anotarlo como el único fallo de la herramienta, corrí el binario en C contra sí mismo tres veces:
C, corrida 1 (143, 118, 75)
C, corrida 2 ( 90, 88, 41)
C, corrida 3 (otro)
Rust ( 93, 79, 32)
El DOOM original lee ahí un píxel de memoria sin inicializar. Difiere de sí mismo en cada corrida, y una de esas corridas coincidió exacto con la de Rust. El fallo es de 1993, y la traducción lo hereda con fidelidad.
El precio: casi la mitad del archivo es unsafe
Sobre zlib, que es más chico y tiene una reescritura humana con la cual comparar, medí las líneas que caen dentro de un bloque unsafe:
| Líneas | Dentro de unsafe | ||
|---|---|---|---|
| c2rust, traducción | 25.517 | 12.026 | 47,1% |
| zlib-rs, reescritura humana | 13.399 | 4.179 | 31,2% |
Contar apariciones de la palabra unsafe da el resultado contrario, 0,98% contra 2,56%, y es una métrica engañosa: el traductor envuelve funciones enteras en un solo bloque, así que una aparición cubre doscientas líneas. Lo que hay que medir son las líneas cubiertas.
Y hay que decir que no son cosas comparables: zlib-rs es una reescritura, con estructuras rediseñadas y el sistema de tipos haciendo trabajo. La traducción automática no puede hacer eso; su objetivo es no cambiar el programa.
Diez veces más lento por un struct de cuatro líneas
El DOOM traducido corría a un décimo de la velocidad del original. El perfilador puso el 98% del tiempo en una función que convierte la pantalla de 8 bits al framebuffer, y dentro de ella, en un accesor de campos de bits.
La causa entera es esto:
struct color { uint32_t b:8, g:8, r:8, a:8; };
c2rust convierte los campos de bits en un arreglo de cuatro bytes con accesores genéricos que rearman el valor bit a bit en tiempo de ejecución y no se integran en el código llamante. En C, leer c.r es cargar un byte.
| demo1, realtics (menos es mejor) | |
|---|---|
| C con clang -O2 | 131 · 138 · 139 · 133 · 135 |
| Rust traducido | 1330 · 1396 · 1447 · 1428 · 1412 |
| Rust, cambiando ese struct | 132 · 135 · 131 · 129 · 132 |
Reemplazando ese struct por los cuatro bytes que ya era (misma disposición en memoria, misma semántica, unas diez líneas), el Rust traducido empata con C.
La lección medida: el Rust que sale de c2rust no es lento; lo son los campos de bits. Cualquier C que los use en un lazo caliente hereda ese acantilado.
El artefacto está clavado a la máquina donde se tradujo
Este fue el hallazgo que no esperaba. Compilar el mismo código para WebAssembly destapó que la traducción no es portátil: trae pegada la máquina donde se hizo.
Tres cosas, y las tres compilan limpio en la máquina de origen:
Los anchos de tipo vienen fijados. Una constante que en un sistema de 64 bits cabe en un entero largo, en uno de 32 bits ya no cabe. Cuarenta errores de compilación de golpe.
Los inicializadores de arranque no corren. La herramienta los registra con un mecanismo que solo existe en Linux, Windows y macOS. En cualquier otro objetivo la función queda registrada en ninguna parte, y el programa compila limpio y falla en ejecución, con un mensaje que no apunta a nada de esto.
Y hay símbolos internos de la libc de Apple dentro del binario, porque macOS implementa cosas como isspace indexando tablas globales propias y esas lecturas quedaron incrustadas.
Ninguno de los tres se ve mientras trabajas en la máquina donde tradujiste. Los detalles y el arreglo de cada uno están en el repositorio.
Ahora sí, la IA
Con la traducción verificada, la pregunta pasó a ser otra: ¿para qué sirve el modelo, si no es para traducir?
Lo primero que probé fue lo obvio, y salió mal. Pasé una captura del juego por un upscaler:
El modelo alucinó un edificio de oficinas con ventanas de vidrio donde había una pared de paneles de DOOM. Leyó el patrón vertical como arquitectura moderna. El modelo entrenado para dibujos borró todo: la escopeta quedó como una mancha.
Sobre la textura limpia del WAD, en cambio, funciona muy bien. El problema era la entrada: una captura trae perspectiva, iluminación y tramado encima, y eso el modelo no lo sabe deshacer.
La regla: la IA suma, no reemplaza
De ahí salió el diseño que terminó funcionando. El paquete que carga el motor guarda un mapa de un canal con media exacta 128 por texel, y el motor hace
color_final = color_de_doom × detalle / 128
Como la media sobre cada texel es 128 por construcción, el color, el sombreado y la iluminación siguen siendo exactamente los que calcula el motor de 1993. Lo único que aporta el modelo es la variación dentro del texel, que es justo lo que al motor le falta cuando una pared está cerca de la cámara y un texel se estira sobre muchos píxeles.
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.
Esto costó tres intentos. Reemplazar el color dejaba los suelos mucho más oscuros y con otro matiz, porque los flats de DOOM están tramados: dos entradas de paleta muy distintas alternadas para fingir un tono intermedio, y el modelo lee ese tramado como ruido. Renormalizar la media global de cada textura tampoco alcanza: el desvío es local. Lo que funciona es anclar contra la media del propio bloque de 4×4, porque así no hay resta que pueda saturar y desviar el promedio.
El relieve sale gratis
Y entonces apareció lo mejor: ese mapa de detalle ya es un mapa de alturas. El modelo dibujó dónde la superficie sobresale y dónde se hunde. Su gradiente da la normal, y con una luz fija sale sombreado por píxel.
El DOOM original no tiene nada parecido: cada superficie recibe un solo nivel de luz de una tabla de 32 escalones. Y no cuesta un byte extra, porque la altura ya estaba en el mapa.
Dejar de tirar bits de luz
Con el relieve puesto, seguía sin verse tanto como esperaba. La causa era otra: el motor calcula la iluminación con precisión y después la trunca a 48 escalones para paredes, 128 para suelos, y encima remapea a 256 colores. A 320×200 eso se disimula. Más arriba es la marca visual más fuerte de “esto es viejo”, los anillos concéntricos en el suelo, y además deja todo el relieve dentro de una misma banda de luz.
Como el buffer del mundo ya es de 32 bits, esa cuantización no tiene ninguna razón de existir. Guardo el escalón vecino y la fracción que el motor descarta, e interpolo. El motor sigue decidiendo la luz; solo dejé de tirar bits. En un suelo en fuga, los tonos distintos pasaron de 32 a 45.
Los números de 1993 que dejan de alcanzar
Para que el detalle tenga dónde ponerse hubo que subir la resolución interna, y ahí DOOM enseña su edad. Los arreglos de la pantalla traen el tamaño escrito a mano, porque la herramienta resolvió las constantes al traducir. Y algunos límites son del diseño original: las coordenadas verticales de pantalla viven en un byte, así que por encima de 255 filas se desbordan en silencio y el demo se corta. Ese es el techo real de resolución del DOOM de 1993.
Uno costó encontrar y enseña un método. Al ensanchar una estructura a 16 bits, la rutina que la limpia seguía calculando el tamaño sobre el tipo anterior y borraba la mitad del arreglo. En pantalla salían rayas verticales colgando de la geometría, y leyendo el código no había forma de saber de dónde venían.
Lo que lo encontró fue reducir el render a la resolución original, compararlo píxel a píxel contra el binario en C y pintar un mapa de las diferencias:
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.
La forma nombra el error. Un fallo de cálculo se reparte por toda la pantalla; uno de memoria que trata distinto a las dos mitades de un arreglo deja exactamente esta asimetría.
Optimizar hasta tiempo real
Con todo activado, el renderizador dejó de llegar a tiempo real. El perfilador puso 2690 de 4043 muestras en el lazo que dibuja columnas de pared. Dos técnicas, ninguna toca la calidad:
Sacar del lazo caliente lo que no cambia. El gradiente del relieve es estático por textura y lo estaba recalculando en cada píxel: de 5 muestras a 2, horneándolo en el paquete.
Matar las divisiones enteras. Había seis operaciones de módulo por píxel para envolver filas y columnas. DOOM lee la fila enmascarando a 128 pero la textura puede ser más baja. Ahora la vuelta sale de una tabla de 512 entradas construida al cargar: cero divisiones.
| demo1, realtics | |
|---|---|
| Antes | 7215 |
| Después | 3009 |
2,4 veces más rápido, con una desviación de 2,25 sobre 255 contra la versión sin optimizar: invisible.
Qué se ve y qué no
Con las tres capas de textura (54 suelos, 125 paredes, 483 sprites) el 76,3% de los píxeles del visor cambian respecto a la traducción sin tocar.


Y aquí va un número que contradice la intuición: los sprites aportan entre 1,0% y 1,2%. Eran el trabajo más difícil de los tres. Tienen transparencia, sus columnas vienen partidas en trozos y el motor entrega una columna que empieza a media altura del sprite. Y son los que menos se notan, porque los enemigos y el arma ocupan muy poca pantalla. Las paredes y los suelos son casi todo.
| Nativo | Navegador | Datos | |
|---|---|---|---|
| 1280×800 | 38 fps | 18 fps | 45,6 MB |
| 640×400 | 160 fps | 61 fps | 45,6 MB |
Me quedé con 640×400. El salto visual viene del relieve y de la luz, no de los píxeles: la resolución hizo falta para ver las texturas, y a 640 ya se ven.
Las tres versiones, jugando
El código original en C, ese mismo código traducido a Rust por la máquina, y esa traducción con las capas de arriba. Los tres compilados a WebAssembly. Los tres dan el mismo conteo de tics en los demos grabados, así que la afirmación de que juegan igual está medida.
El motor traducido, el arnés de verificación y los generadores de los paquetes están en doom-c2rust, con las comparativas de cada escena. El WAD no va incluido: es de id Software.
Cuándo usaría c2rust y cuándo no
Sí, cuando el objetivo es dejar de compilar C sin cambiar el comportamiento: portar una biblioteca a un proyecto Rust, o tener una base sobre la cual reescribir por partes con las pruebas del original como red. Es determinista, es rápido y no inventa.
No, si lo que se busca es Rust seguro. Casi la mitad del archivo queda en unsafe y el resultado está clavado a la arquitectura y a la libc de la máquina donde se tradujo. Y no, tampoco, si el C hace trampas con sus propias declaraciones: ahí el trabajo manual empieza en cuanto compila.
Sobre el modelo: no lo usaría para traducir. Lo usaría exactamente como terminó usándose aquí, para generar datos que se modulan encima de lo que el programa ya calcula bien. Esa restricción, que el motor siga decidiendo, es lo que hizo que el resultado sumara en vez de estropear.
Fuentes
- c2rust, Immunant. Versión 0.22.1.
- doomgeneric, ozkl. Port de DOOM con una sola dependencia de plataforma.
- zlib-rs, Trifecta Tech Foundation. La reescritura humana contra la cual comparé el
unsafe. - Real-ESRGAN, Xintao Wang et al. Modelo
realesrgan-x4plus, ejecutado con la implementación ncnn-vulkan. - doom-c2rust, el código de este artículo. GPL-2.0, heredada de DOOM.
- WAD de DOOM shareware v1.9, sha1
5b2e249b9c5133ec987b3ea77596381dc0d6bc1d, redistribuible.
Comentarios
Todavía no hay comentarios. El primero es tuyo.