Nortis (anteriormente Notris) es un juego casero de PSX, escrito en C utilizando herramientas modernas. Es totalmente reproducible en hardware original y funciona con PSNoobSDK.
Vea el código base de PSX aquí.
El año pasado conseguí una PlayStation 1 negra poco común. Se llama Net Yaroze y es una consola especial que puede jugar juegos caseros así como títulos comunes de PSX. Era parte de un proyecto especial de Sony para atraer a aficionados y estudiantes a la industria de los juegos.
Los juegos de Yaroze eran muy limitados, ya que Sony no quería que los programadores de dormitorio compitieran con los desarrolladores comerciales. Sólo se podían reproducir en otros Yarozes o en discos de demostración especiales. Tenían que caber completamente en la RAM del sistema sin acceso al CD-ROM. A pesar de estas limitaciones, Yaroze fomentó una comunidad apasionada de desarrolladores independientes.
Y ahora yo tenía el mío propio. Lo que me hizo pensar: ¿cómo fue realmente escribir un juego de PlayStation?
Se trata de cómo escribí un juego casero simple para PSX, usando una versión de código abierto de las bibliotecas pero aún ejecutándolo en el hardware original y escrito en C clásico.
Saltar esta sección
Los juegos de PSX normalmente se escribían en C en estaciones de trabajo con Windows 9X. El kit de desarrollo oficial era un par de tarjetas de expansión ISA que se insertaban en una placa base de PC IBM común y contenían todo el chipset del sistema PSX, salida de video y RAM adicional (8 MB en lugar de 2 MB). Esto proporcionó TTY y salida del depurador a la máquina host.
Es posible que hayas oído hablar de las PlayStation azules. Estos fueron para control de calidad en lugar de desarrollo y son idénticos a las unidades minoristas excepto que pueden reproducir CD-ROM grabados. Sin embargo, al menos una empresa vendió un complemento especial para convertirlos en devkits:
El diseño fue muy amigable para los desarrolladores. Podrías jugar en CRT con controladores normales mientras revisas los puntos de interrupción de GDB en tu PC con Windows 95, hojeando un grueso libro de texto de funciones de C SDK.
En principio, un desarrollador de PSX podría trabajar completamente en C. El SDK comprendía un conjunto de bibliotecas de C llamadas PSY-Q e incluía un programa compilador ccpsx
que en realidad era solo una interfaz de GCC. Esto admitió una variedad de optimizaciones, como la inserción de código y el desenrollado de bucles, aunque las secciones críticas para el rendimiento aún justificaban un ensamblaje optimizado manualmente.
(Puede leer sobre esas optimizaciones en estas diapositivas de la conferencia SCEE).
C++ era compatible con ccpsx
, pero tenía fama de generar código "inflado", así como tiempos de compilación más lentos. En realidad, C era la lengua franca del desarrollo de PSX, pero algunos proyectos utilizaban lenguajes de scripting dinámicos además de un motor base. Por ejemplo, Metal Gear Solid usó TCL para secuencias de comandos de niveles; y los juegos de Final Fantasy fueron un poco más allá e implementaron sus propios lenguajes de código de bytes para batallas, sistemas de campo y minijuegos. (Puedes aprender más sobre esto aquí).
( Para obtener más información, consulte https://www.retroreversing.com/official-playStation-devkit )
Saltar esta sección
Pero llegué a esto desde una perspectiva muy diferente: un ingeniero de software en 2024 que trabajaba principalmente en aplicaciones web. Mi experiencia profesional había sido casi exclusivamente en lenguajes de alto nivel como JavaScript y Haskell; Había trabajado un poco en OpenGL y C++, pero el C++ moderno es casi un lenguaje completamente diferente al C.
Sabía que existían SDK de PSX para lenguajes como Rust, pero quería experimentar el sabor de la programación de PSX "real", tal como se hacía en los años 90. Por lo tanto, serían cadenas de herramientas modernas y bibliotecas de código abierto, pero C en todos los sentidos.
El juego tenía que ser algo en 2D que pudiera crearse un prototipo en un par de días. Me conformé con un clon de Tetris; pensé que sería lo suficientemente complejo como para experimentar lo que quería.
El primer paso fue construir un prototipo con una tecnología familiar. Esto me permitiría concretar el diseño básico, luego la lógica podría traducirse poco a poco a C.
Como desarrollador web, la tecnología más obvia para la creación de prototipos era JavaScript: es simple, conciso, fácil de depurar y cuenta con la API de gráficos HTML5 <canvas>
. Las cosas se juntaron muy rápido
Al mismo tiempo, temía que fuera difícil portar más funciones de JavaScript de alto nivel. Cualquier cosa que utilice clases o cierres tendría que reescribirse por completo, por lo que tuve cuidado de limitarme a un subconjunto de procedimiento simple del lenguaje.
Ahora bien, en realidad tenía un motivo oculto al emprender este proyecto: era una excusa para finalmente aprender C. El lenguaje ocupaba un lugar preponderante en mi mente y había comenzado a desarrollar un complejo de inferioridad por no saberlo.
C tiene una reputación intimidante y temía historias de terror sobre punteros colgantes, lecturas desalineadas y el temido segmentation fault
. Más precisamente: me preocupaba que si intentaba aprender C y fallaba, descubriría que, después de todo, no era muy buen programador.
Para simplificar las cosas, pensé que podría usar SDL2 para manejar la entrada y los gráficos, y compilar para mi entorno de escritorio (MacOS). Eso me daría un ciclo de compilación/depuración rápido y haría que la curva de aprendizaje fuera lo más suave posible.
A pesar de mis temores, encontré C increíblemente divertido. Muy rápidamente hizo "clic" para mí. Comienzas a partir de primitivos muy simples (estructuras, caracteres, funciones) y los construyes en capas de abstracción para eventualmente encontrarte encima de un sistema de trabajo completo.
El juego solo tardó un par de días en portarse y quedé muy satisfecho con mi primer proyecto en C verdadero. ¡Y no había tenido ni un solo error de segmento!
Fue un placer trabajar con SDL, pero hubo algunos aspectos que requirieron que asignara memoria dinámicamente. Esto sería un no-no en PlayStation, donde el malloc
proporcionado por el kernel de PSX no funciona correctamente. Y el proceso de gráficos sería un salto aún mayor...
Cuando se trata de PlayStation Homebrew, hay dos opciones principales para su SDK. Cualquiera:
Hay un par de otras opciones como C++ Psy-Qo , e incluso puedes renunciar a cualquier SDK solo para realizar E/S asignadas en memoria tú mismo, pero no fui lo suficientemente valiente para eso.
El mayor problema con Psy-Q es que sigue siendo un código propietario de Sony, incluso 30 años después. Legalmente, cualquier cerveza casera construida con él está en riesgo. Eso es lo que hundió el demake de Portal64: vinculó estáticamente libultra
, que es el SDK N64 patentado por Nintendo.
Pero para ser honesto, la razón principal por la que elegí PSNoobSDK fue que está muy bien documentado y es fácil de configurar. La API es muy similar a Psy-Q: de hecho, para muchas funciones simplemente podía consultar las referencias impresas que venían con mi Yaroze.
Si el uso de un SDK no auténtico ofende al purista de PSX que hay en ti, no dudes en dejar de leer ahora con disgusto.
Mi primera tarea fue una especie de hola mundo: dos cuadrados sobre un fondo de color. Suena simple, ¿verdad?
Saltar esta sección
(*Parte de esto está simplificado. Para obtener una guía más autorizada, lea el tutorial de PSNoobSDK)
Para empezar, piense en la VRAM de PSX como un gran lienzo de 1024 por 512 píxeles de 16 bits. En total, eso hace 1 megabyte de memoria compartida por framebuffers y texturas. Podemos elegir la resolución del framebuffer de salida (incluso hasta 640x480 píxeles si somos codiciosos), pero más resolución = menos texturas.
La mayoría de los juegos de PSOne (y... los juegos en general) tienen una noción de renderizado con doble búfer: mientras se prepara un fotograma, el otro se envía a la pantalla. Entonces necesitamos asignar dos buffers de cuadros:
(Ahora puedes ver por qué 640x480 no es práctico: no hay suficiente espacio para dos buffers de 480p. Pero este modo PUEDE usarse con cosas como el logotipo de inicio de PSX, que no necesita mucha animación)
Los buffers (denominados alternativamente entornos de visualización y dibujo) se intercambian en cada fotograma. La mayoría de los juegos de PSX apuntan a 30 fps (en Norteamérica), pero la interrupción VSync real llega a 60 Hz. Algunos juegos logran ejecutarse a 60 fps completos (me vienen a la mente Tekken 3 y Kula World (Roll Away), pero obviamente entonces necesitas renderizar en la mitad del tiempo. Recuerde que sólo tenemos 33 Mhz de potencia de procesamiento.
Pero, ¿cómo funciona el proceso de dibujo? Esto lo hace la GPU, pero la GPU de PSX funciona de manera muy diferente a una tarjeta gráfica moderna. Básicamente, a cada cuadro se envía a la GPU una lista ordenada de 'paquetes' o comandos de gráficos. "Dibuja un triángulo aquí", "carga esta textura para pelar el siguiente quad", etcétera.
La GPU no realiza transformaciones 3D; ese es el trabajo del coprocesador GTE (Geometry Transform Engine). Los comandos de la GPU representan gráficos puramente 2D, ya manipulados por hardware 3D.
Eso significa que la ruta de un píxel de PSX es la siguiente:
Entonces, en pseudocódigo, el bucle de trama de PSX (básicamente) es así
FrameBuffer [0, 1]
OrderingTable [0, 1]
id = 1 // flips every frame
loop {
// Game logic
// Construct the next screen by populating the current ordering table
MakeGraphics(OrderingTable[id])
// Wait for last draw to finish; wait for vertical blank
DrawSync()
VSync()
// The other frame has finished drawing in background, so display it
SetDisplay(Framebuffer[!id])
// Start drawing current frame
SetDrawing(Framebuffer[id])
// Send ordering table contents to GPU via DMA
Transfer(OrderingTable[id])
// Flip
id = !id
}
Puede ver en esto que mientras el cuadro 1 está en pantalla, el cuadro 2 todavía se está pintando y el cuadro 3 aún está potencialmente "construido" por el programa mismo. Luego, después de DrawSync/VSync, enviamos el cuadro 2 al televisor y obtenemos el cuadro de dibujo 3 de la GPU.
Como se mencionó, la GPU es una pieza de hardware completamente 2D, no conoce las coordenadas z en el espacio 3D. No existe un "búfer z" para describir las oclusiones, es decir, qué objetos están delante de otros. Entonces, ¿cómo se clasifican los elementos delante de los demás?
La forma en que funciona es que la tabla de pedidos comprende una cadena de comandos gráficos con enlaces inversos. Estos se recorren de atrás hacia adelante para implementar el algoritmo del pintor .
Para ser precisos, la tabla de pedidos es una lista con vínculos inversos. Cada elemento tiene un puntero al elemento anterior de la lista y agregamos primitivas insertándolas en la cadena. Generalmente, los OT se inicializan como una matriz fija, donde cada elemento de la matriz representa un "nivel" o capa en la pantalla. Los OT se pueden anidar para implementar escenas complejas.
El siguiente diagrama ayuda a explicarlo (fuente)
Este enfoque no es perfecto y, a veces, la geometría de PSX muestra un recorte extraño, porque cada poli solo puede estar en un único 'índice z' en el espacio de la pantalla, pero funciona bastante bien para la mayoría de los juegos. Hoy en día, estas limitaciones se consideran parte del encanto distintivo de la PSX.
Saltar esta sección
Hemos hablado mucho de teoría: ¿cómo se ve esto en la práctica?
Esta sección no analizará todo el código línea por línea, pero debería brindarte una muestra de los conceptos de gráficos de PSX. Si desea ver el código completo, vaya a hello-psx/main.c
.
Alternativamente, si no eres un programador, no dudes en seguir adelante. Esto es sólo para técnicos que tienen curiosidad.
Lo primero que necesitamos son algunas estructuras para contener nuestros buffers. Tendremos un RenderContext
que contiene dos RenderBuffers
y cada RenderBuffer
contendrá:
displayEnv
(especifica el área VRAM del búfer de visualización actual)drawEnv
(especifica el área VRAM del búfer de extracción actual)orderingTable
(lista con enlace inverso que contendrá punteros a paquetes de gráficos)primitivesBuffer
(estructuras para paquetes/comandos de gráficos, incluidos todos los polígonos) #define OT_SIZE 16
#define PACKETS_SIZE 20480
typedef struct {
DISPENV displayEnv ;
DRAWENV drawEnv ;
uint32_t orderingTable [ OT_SIZE ];
uint8_t primitivesBuffer [ PACKETS_SIZE ];
} RenderBuffer ;
typedef struct {
int bufferID ;
uint8_t * p_primitive ; // next primitive
RenderBuffer buffers [ 2 ];
} RenderContext ;
static RenderContext ctx = { 0 };
En cada fotograma invertiremos el bufferID
, lo que significa que podemos trabajar sin problemas en un fotograma mientras se muestra el otro. Un detalle clave es que p_primitive
se mantiene constantemente apuntado al siguiente byte en el primitivesBuffer
actual. Es imperativo que esto se incremente cada vez que se asigne una primitiva y se reinicie al final de cada trama.
Básicamente, antes que nada, necesitamos configurar nuestros entornos de visualización y dibujo, en configuración inversa para que DISP_ENV_1
use la misma VRAM que DRAW_ENV_0
, y viceversa.
// x y width height
SetDefDispEnv ( DISP_ENV_0 , 0 , 0 , 320 , 240 );
SetDefDispEnv ( DISP_ENV_1 , 0 , 240 , 320 , 240 );
SetDefDrawEnv ( DRAW_ENV_0 , 0 , 240 , 320 , 240 );
SetDefDrawEnv ( DRAW_ENV_1 , 0 , 0 , 320 , 240 );
Estoy bastante condensado aquí, pero a partir de aquí cada fotograma básicamente es como
while ( 1 ) {
// do game stuff... create graphics for next frame...
// at the end of loop body
// wait for drawing to finish, wait for next vblank interval
DrawSync ( 0 );
VSync ( 0 );
DISPENV * p_dispenv = & ( ctx . buffers [ ctx . bufferID ]. displayEnv );
DRAWENV * p_drawenv = & ( ctx . buffers [ ctx . bufferID ]. drawEnv );
uint32_t * p_ordertable = ctx . buffers [ ctx . bufferID ]. orderingTable ;
// Set display and draw environments
PutDispEnv ( p_dispenv );
PutDrawEnv ( p_drawenv );
// Send ordering table commands to GPU via DMA, starting from the end of the table
DrawOTagEnv ( p_ordertable + OT_SIZE - 1 , p_drawEnv );
// Swap buffers and clear state for next frame
ctx . bufferID ^= 1 ;
ctx . p_primitive = ctx . buffers [ ctx . bufferID ]. primitivesBuffer ;
ClearOTagR ( ctx . buffers [ 0 ]. orderingTable , OT_SIZE );
}
Esto podría ser mucho para asimilar. No te preocupes.
Si realmente quieres entender esto, lo mejor es echar un vistazo a hello-psx/main.c
. Todo está comentado con bastante detalle. Alternativamente, siga el tutorial de PSNoobSDK... es bastante conciso y está escrito con bastante claridad.
Ahora... ¿cómo dibujamos cosas? Escribimos estructuras en nuestro búfer de primitivas. Este búfer se escribe simplemente como una gran lista de chars
, por lo que lo convertimos en nuestra estructura de forma/comando y luego avanzamos el puntero del búfer de primitivas usando sizeof
:
// Create a tile primitive in the primitive buffer
// We cast p_primitive as a TILE*, so that its char used as the head of the TILE struct
TILE * p_tile = ( TILE * ) p_primitive ;
setTile ( p_tile ); // very very important to call this macro
setXY0 ( p_tile , x , y );
setWH ( p_tile , width , width );
setRGB0 ( p_tile , 252 , 32 , 3 );
// Link into ordering table (z level 2)
int z = 2 ;
addPrim ( ordering_table [ buffer_id ] + z , p_primitive );
// Then advance buffer
ctx . p_primitive += sizeof ( TILE );
¡Acabamos de insertar un cuadrado amarillo! ? Intenta contener tu emoción.
Saltar esta sección
En este punto de mi viaje, todo lo que realmente tenía era un programa de demostración de "hola mundo", con gráficos básicos y entrada de controlador. Puedes ver en el código en hello-psx
que estaba documentando tanto como fuera posible, realmente para mi propio beneficio. Un programa de trabajo fue un paso positivo pero no un verdadero juego.
Ya era hora de ser realista .
Nuestro juego necesita mostrar el marcador.
La PSX realmente no te ofrece mucho en cuanto a representación de texto. Hay una fuente de depuración (que se muestra arriba), pero es extremadamente básica: para desarrollo y no mucho más.
En su lugar, necesitamos crear una textura de fuente y usarla para darle piel a los quads. Creé una fuente monoespaciada con https://www.piskelapp.com/ y la exporté como PNG transparente:
Las texturas de PSX se almacenan en un formato llamado TIM. Cada archivo TIM comprende:
Debido a que la ubicación VRAM de la textura está "integrada" en el archivo TIM, necesita una herramienta para administrar las ubicaciones de sus texturas. Recomiendo https://github.com/Lameguy64/TIMedit para esto.
A partir de ahí solo tenemos una función para pelar un montón de quads, con las compensaciones de UV basadas en cada valor ASCII.
Necesitamos un espacio para que encajen las piezas. Sería fácil usar un aburrido rectángulo blanco para esto, pero quería algo que se sintiera más... PlayStation
Nuestra interfaz de usuario se está uniendo. ¿Qué pasa con las piezas?
Ahora viene un diseño visual importante. Lo ideal es que cada ladrillo sea visualmente distinto, con bordes nítidos y sombreados. Hacemos esto con dos triángulos y un quad:
Con una resolución nativa de 1x, el efecto sería menos claro, pero aún se ve bonito y grueso:
En el primer prototipo de mi juego implementé un sistema de rotación completamente ingenuo, que en realidad voltearía el bloque 90 grados en un punto central. Resulta que en realidad no es un buen enfoque, porque hace que los bloques se "tambaleen", moviéndose hacia arriba y hacia abajo a medida que giran:
En cambio, las rotaciones están codificadas para que sean "agradables" en lugar de "precisas". Una Pieza se define dentro de una cuadrícula de celdas de 4x4, y cada celda se puede llenar o vaciar. Hay 4 rotaciones. Por lo tanto: las rotaciones pueden ser simplemente matrices de cuatro números de 16 bits. Que se parece a esto:
/**
* Example: T block
*
* As a grid:
*
* .X.. -> 0100
* XXX. -> 1110
* .... -> 0000
* .... -> 0000
*
* binary = 0b0100111000000000
* hexadecimal = 0x4E00
*
*/
typedef int16_t ShapeBits ;
static ShapeBits shapeHexes [ 8 ][ 4 ] = {
{ 0 }, // NONE
{ 0x0F00 , 0x4444 , 0x0F00 , 0x4444 }, // I
{ 0xE200 , 0x44C0 , 0x8E00 , 0xC880 }, // J
{ 0xE800 , 0xC440 , 0x2E00 , 0x88C0 }, // L
{ 0xCC00 , 0xCC00 , 0xCC00 , 0xCC00 }, // O
{ 0x6C00 , 0x8C40 , 0x6C00 , 0x8C40 }, // S
{ 0x0E40 , 0x4C40 , 0x4E00 , 0x4640 }, // T
{ 0x4C80 , 0xC600 , 0x4C80 , 0xC600 }, // Z
};
Extraer los valores de las celdas es sólo un caso de simple enmascaramiento de bits:
#define GRID_BIT_OFFSET 0x8000;
int blocks_getShapeBit ( ShapeBits s , int y , int x ) {
int mask = GRID_BIT_OFFSET >> (( y * 4 ) + x );
return s & mask ;
}
Las cosas están tomando forma ahora con impulso.
Fue en este punto que me encontré con un problema: la aleatorización. Las piezas tienen que aparecer de forma aleatoria para que valga la pena jugar el juego, pero la aleatorización es difícil con las computadoras. En mi versión de MacOS, pude "sembrar" el generador de números aleatorios con el reloj del sistema, pero la PSX no tiene un reloj interno.
En cambio, una solución que toman muchos juegos es hacer que el jugador cree la semilla. El juego muestra una pantalla de presentación o de título con un texto como "presiona iniciar para comenzar", y luego se toma el tiempo al presionar ese botón para crear la semilla.
Creé un 'gráfico' declarando algunos int32
codificados en binario donde 1
bit sería un 'píxel' en una fila de ladrillos:
Lo que quería era que las líneas se disolvieran gradualmente a la vista. Primero necesitaba una función que efectivamente "realizara un seguimiento" de cuántas veces se llamaba. C facilita esto con la palabra clave static
: si se usa dentro de una función, la misma dirección de memoria y el mismo contenido se reutilizan en la siguiente invocación.
Luego, dentro de esta misma función hay un bucle que recorre los valores x/y de la 'cuadrícula' y decide si han ocurrido suficientes ticks para mostrar el 'píxel':
void ui_renderTitleScreen () {
static int32_t titleTimer = 0 ;
titleTimer ++ ;
// For every 2 times (2 frames) this function is called, ticks increases by 1
int32_t ticks = titleTimer / 2 ;
// Dissolve-in the title blocks
for ( int y = 0 ; y < 5 ; y ++ ) {
for ( int x = 0 ; x < 22 ; x ++ ) {
int matrixPosition = ( y * 22 ) + x ;
if ( matrixPosition > ticks ) {
break ; // because this 'pixel' of the display is not to be displayed yet
}
int32_t titleLine = titlePattern [ y ];
int32_t bitMask = titleMask >> x ;
if ( titleLine & bitMask ) { // there is a 'pixel' at this location to show
ui_renderBlock ( /* skip boring details */ );
}
}
}
}
Ya casi llegamos.
Los juegos clásicos de PSX se inician en dos etapas: primero, la pantalla de Sony Computer Entertainment y luego el logotipo de PSX. Pero si compilamos y ejecutamos el proyecto hello-psx
no es así. La segunda pantalla es simplemente negra. ¿Porqué es eso?
Bueno, el chapoteo de SCE proviene del BIOS, al igual que el sonido de arranque de PSX, pero el famoso logo es en realidad parte de los datos de licencia del disco. Está ahí para actuar como un "sello de autenticidad", de modo que cualquiera que piratee un juego está copiando la IP de Sony además de la del editor. Esto le dio a Sony más instrumentos legales para combatir la piratería de software.
Si queremos que nuestro juego muestre el logo, debemos proporcionar un archivo de licencia extraído de una ISO, pero por motivos de derechos de autor debemos .gitignore
.
< license file = " ${PROJECT_SOURCE_DIR}/license_data.dat " />
Bueno. Ahora estamos listos.
Todo esto comenzó con una compra impulsiva: mi PlayStation Yaroze negra. Irónicamente, en realidad no estaría jugando a mi juego ya que todavía poseía su hardware antipiratería. No me apetecía instalar un modchip en una pieza tan valiosa de la historia de PSX, no con mis habilidades de soldadura.
En lugar de eso, tuve que localizar una PlayStation gris modificada, una que todavía tuviera un manejo decente. Pensé que el objetivo de mi proyecto era escribir un verdadero juego de PlayStation y eso significaba usar una verdadera PlayStation.
También tuve que encontrar los medios adecuados. El láser de la PSX es bastante exigente y los CD-R modernos tienden a ser mucho menos reflectantes que los discos prensados. Mis primeros intentos con los CD de historias de comestibles fueron una pérdida de tiempo y, en el transcurso de unas dos semanas, creé muchos posavasos.
Este fue un momento oscuro. ¿Había llegado hasta aquí y no pude grabar el CD ?
Después de varias semanas, conseguí algunas acciones especiales de JVC Taiyo Yuden. Por lo que pude leer, estos eran bastante especializados y normalmente se usaban en aplicaciones industriales. Quemé el primer disco en la bandeja y me esperaba lo peor.
Este fue el momento de la verdad:
La secuencia de inicio de PlayStation resonó en los pequeños parlantes de mi monitor y el clásico logotipo "PS" apareció en la pantalla con una vibrante resolución de 640 por 480. El BIOS claramente había encontrado algo en ese disco, pero muchas cosas podrían fallar después de este punto. La pantalla se volvió negra y agucé el oído para escuchar el revelador clic-clic-clic de un error en la unidad.
En cambio, uno por uno, pequeños cuadrados de colores comenzaron a parpadear desde la oscuridad. Línea por línea deletrearon una palabra: NOTRIS
. Luego: PRESS START TO BEGIN
. El texto me llamó. ¿Qué pasaría después?
Una partida de Tetris, por supuesto. ¿Por qué me sorprendió? Escribir tu propio juego de PlayStation en C es realmente muy sencillo: todo lo que necesitas es no cometer ningún error . Eso es informática para usted, especialmente las cosas de bajo nivel. Es duro, afilado y hermoso. La informática moderna tiene ventajas más suaves, pero lo esencial no ha cambiado.
Aquellos de nosotros que amamos las computadoras necesitamos tener algo levemente malo en nosotros, una irracionalidad en nuestra racionalidad, una manera de negar toda la evidencia de nuestros ojos y oídos de que la hostil caja de silicio está muerta y es inflexible. Y crea mediante astuta maquinaria la ilusión de que vive.