La hazaña: un escritorio gráfico para una máquina de 1982
En el repositorio zxdesk de GitHub, el usuario mindbox77 publicó un escritorio gráfico completo escrito en ensamblador Z80 que corre en un ZX Spectrum 48K original de 1982. No en un emulador: en la máquina real, con la ULA robándole ciclos al CPU mientras pinta la pantalla y el chip de vídeo disputándole cada T-state.
El escritorio incluye ventanas solapadas con orden Z y foco, menús desplegables con barra permanente, ratón Kempston, teclado matricial completo, un heap real de 8.112 bytes, una cola de eventos de 16 ranuras, una capa de almacenamiento con backends intercambiables (RAM, cinta y bancos extra del 128K), diálogos, controles, un bloc de notas, reloj, calendario, un administrador de archivos de dos paneles y un panel de ajustes que efectivamente cambia las cosas.
Todo cabe en los 48K del Spectrum y puede arrastrar una ventana en un solo frame de 69.888 T-states. El Z80 corre a 3,5 MHz, la pantalla es de un bit con attribute clash y la ULA le quita ciclos al procesador por debajo de la dirección $8000.
👥 ¿Quieres ir más allá de la noticia?
En nuestra comunidad discutimos las tendencias, compartimos oportunidades y nos ayudamos entre emprendedores. Sin humo, solo acción.
👥 Unirme a la comunidadPor qué la medición importa más que la intuición
El autor documenta su método con una franqueza inusual: cada cifra del proyecto se midió contra el reloj interno de la máquina, nunca contando instrucciones.
El frame del 48K dura exactamente 69.888 T-states a 3,5 MHz y nada de lo que haga un programa puede moverlo, así que es el único reloj utilizable. La fórmula que usa es:
cost = (K - K0) * (69888 - 118) - 16 * (C - C0) - delay
Validada contra tres retardos conocidos, la mayor desviación es de 7 T en 104.000, o 0,007%. El bucle de calibración, el handler y el stack viven todos por encima de $8000, donde la ULA nunca roba un ciclo; sólo la rutina bajo prueba toca memoria contendida.
La consecuencia operativa: cuando asumió que el peor caso de un write a pantalla costaba 50% más por la contención de memoria (el folklore del Spectrum), la medición real mostró 14,7%, o 1,7 T por byte. Sus presupuestos eran aproximadamente un tercio demasiado pesimistas.
Cuando midió su propia rutina de relleno optimizada, leía 7,4 T por byte en un benchmark previo. La rutina real costaba 11,5 T, 54% más, porque el benchmark medía la técnica sin la aritmética de avance de fila. De los 183 T que cuesta una fila, 88 son el push encadenado escribiendo píxeles; los otros 95 son el intercambio de registros, el avance de dirección, el test de frontera de celda y el lazo.
El bug que se reparó solo (y la prueba que fallaba por eso)
El primer bug serio del proyecto llegó enmascarado. El heap tenía un DEC HL de más en su rutina free, así que limpiaba el flag de usado y luego escribía un cero en el byte alto del tamaño del bloque. El heap debería haberse roto en el primer free.
No se rompió, porque el coalescedor encontró un bloque libre corto, miró más allá hacia un payload que casualmente era todo ceros, leyó esos ceros como un bloque libre vacío y los absorbió de a cuatro bytes hasta llegar exactamente al total correcto. Todos los números coincidían. Sólo apareció cuando un bloque liberado contenía algo distinto de ceros, y entonces la caminata entró en los píxeles y el heap volvió 2.619 bytes corto.
La conclusión no es sobre el asignador: es sobre el test. Un test que libera bloques que nunca escribió estaba testeando un relleno de ceros. Ahora escribe $A5 sobre el payload antes de liberar y valida el header directamente en vez del total, que es el número que se auto-reparaba.
La interrupción perdida y la técnica del «push adeudado»
Hay un detalle de hardware del Spectrum que la mayoría del código ignora: la ULA mantiene INT durante sólo 32 T-states y luego la retira. Una ventana de DI que cubra esos 32 T no postpone la interrupción, la destruye.
El autor lo descubrió observando: un fill colocado a 62.399 T en el frame reportó no cruzar la frontera del frame cuando claramente la cruzaba. Rescoring como interrupción perdida daba 11.745 T contra 11.744 T para el mismo fill en el border superior — coincidencia a 1 T, diagnóstico confirmado.
Con una interrupción inyectada después de cada instrucción individual de un fill de 8 por 24, la interrupción se rechazaba en 458 de 500 fronteras de instrucción en una rutina y 710 de 752 en la otra.
La solución obvia no funciona. Re-habilitar interrupciones entre filas suena correcto y falla, porque la interrupción no está pendiente, está perdida. Una ventana de EI de unos pocos T-states, una vez cada 236 T, la atrapa una fila de cada treinta. Hacer la región de DI corta no es lo mismo que hacer que esté ausente, y sólo ausente es una solución.
Lo que funciona es deber el último push. Los fills ahora corren con interrupciones habilitadas durante toda su ejecución. Lo que DI protegía era SP, así que la solución es garantizar que los dos bytes de la dirección de retorno caigan en un lugar que de todas formas va a ser sobreescrito. La cadena se hace un push más corta que la fila, y los dos bytes de la izquierda se adeudan — pagados una iteración después, una vez que SP se ha movido a la siguiente fila y ya no puede alcanzarlos.
Después: 0 de 607 y 0 de 904 fronteras de instrucción destruyen la interrupción. Costo: +48 T por fila. Vale la pena.
De 25 Hz a 50 Hz en el drag
El punto de partida era desesperanzador: un erase y redraw completo de una ventana costaba 96.010 T contra un frame de 69.888 T — 137% del frame, por eso el drag corría a 25 Hz.
Cuatro cambios, cada uno medido:
- Buffer fuera de pantalla: componer una vez al buffer, blit por frame. El trabajo caro es composición, no transferencia. Redraw pasa de 71.274 T a 26.048 T.
- Texto más rápido: reescritura del renderizador de texto. 1.307 T a 622 T por carácter, 2,10×.
- Damage rectangles: borrar sólo la tira que la ventana dejó vacante, no la ventana entera. Erase pasa de 19.664 T a 1.456 T.
- Fill de escritorio estrecho: dos bytes directos por
HLcon el patrón en registros, desenrollado cuatro veces para que ninguna fila calcule su fase. Strip de una sola columna pasa de 14.256 T a 5.712 T.
Más perseguir el haz en vez de esperar a que despeje toda la ventana: otros 16.128 T.
Un frame de drag ahora termina en 59.858 T, dentro del frame. El drag corre a 50 Hz.
¿Qué significa esto para tu startup?
Esta historia no es nostalgia retro. Es una clase magistral de ingeniería bajo restricciones que cualquier founder puede aplicar. Tres lecciones concretas:
- Medir antes de razonar. El autor partía del supuesto de 50% de penalización por contención y terminó en 14,7%. Casi un tercio de sus optimizaciones habrían sido prematuras. Antes de tocar tu stack, instrumenta.
- El cuello de botella casi nunca es donde crees. Asumía que los fills eran el problema. Eran el 21% del frame. El texto era el 50%. Optimizar el lugar equivocado es la forma más cara de perder tiempo de ingeniería.
- Una suite de tests débil te deja creer que el sistema funciona cuando se está reparando solo. El test que liberaba bloques sin haberlos escrito testeaba ceros, no memoria real. El bug del heap se ocultó hasta que apareció un payload no trivial. Testeá la propiedad invariante, no la salida que ya tenés.
La disciplina del autor — research, build, critique en cada sesión, una sesión por commit verificado, números de aceptación en el mensaje de commit — es exactamente la cadencia que separa un side project de un producto. Y el principio «no está hecho porque se vea bien en pantalla» aplica tanto a un driver de vídeo en Z80 como al onboarding de tu SaaS. Si querés ir más profundo, el repositorio incluye tstates.py con la aritmética de timing y los scripts SCRIPT=1 para reproducir interacciones idénticas frame por frame.
Fuentes
👥 ¿Quieres ir más allá de la noticia?
En nuestra comunidad discutimos las tendencias, compartimos oportunidades y nos ayudamos entre emprendedores. Sin humo, solo acción.
👥 Unirme a la comunidad













