Sprites y la OAM (Object Attribute Memory)

La OAM (Object Attribute Memory) es una memoria de 544 bytes dentro del chip gráfico de la SNES, que almacena las posiciones y propiedades de hasta 128 sprites. Se divide en dos tablas porque la información que un sprite necesita requiere 34 bits, pero la Tabla Principal solo le puede dar 32 bits (4 bytes). Los 2 bits sobrantes de cada sprite se guardan amontonados en la Tabla Secundaria.


La Tabla Principal (512 bytes)

Esta tabla tiene 128 bloques, uno por cada sprite. Cada bloque usa 4 bytes.

Ejemplo de memoria cruda (4 bloques / 4 sprites):
58 B7 40 30 | 68 B7 42 30 | 80 B7 44 30 | 90 B7 46 30

Tomemos el primer bloque, que corresponde al Sprite 0: 58 B7 40 30

El hardware lee esto byte por byte:

  • Byte 0 (Coordenada X) – 58 (Hex): En decimal es 88. El PPU sabe que debe empezar a dibujar este sprite 88 píxeles a la derecha del borde de la pantalla.
  • Byte 1 (Coordenada Y) – B7 (Hex): En decimal es 183. El sprite se dibuja en la línea horizontal 183 (hacia abajo).
  • Byte 2 (Tile) – 40 (Hex): En decimal es 64. Esto le dice al PPU: “Busca en la memoria de video (VRAM) el cuadrito gráfico número 64, porque ese es el dibujo que lleva este sprite”.
  • Byte 3 (Atributos) – 30 (Hex): En binario es 0011 0000. Cada bit funciona como un interruptor de encendido/apagado para características visuales:
    • Bit 7 (V-Flip) = 0: No voltear verticalmente.
    • Bit 6 (H-Flip) = 0: No voltear horizontalmente.
    • Bits 5 y 4 (Prioridad) = 11: Dibujarlo por delante de casi todo el escenario (alta prioridad).
    • Bits 3, 2 y 1 (Paleta) = 000: Usar la primera paleta de colores de los sprites.
    • Bit 0 (Nombre) = 0: Usar la primera página de gráficos.

La Tabla Secundaria (32 bytes)

Aquí es donde están los 2 bits extra que le faltaban a cada sprite. Como 2 bits es muy poco para ocupar un byte completo, el PPU empaca la información de 4 sprites diferentes dentro de un solo byte.

Tomemos un byte de esta tabla secundaria para ver cómo funciona: 5A (Hex).
En binario, 5A es 0101 1010.

Ese byte se divide en cuatro pares de bits (2 bits por sprite):

  • 01 (Para el Sprite 3)
  • 01 (Para el Sprite 2)
  • 10 (Para el Sprite 1)
  • 10 (Para el Sprite 0)

Para el Sprite 0 el PPU lee su par de bits, que es 10

  • El primer bit (1) es el bit de Tamaño: La SNES te deja elegir globalmente dos tamaños para los sprites, por ejemplo, pequeños de 8×8 y grandes de 16×16. Este bit le dice al PPU: “Usa el tamaño grande (16×16)”. Si fuera 0, usaría el pequeño (8×8).
  • El segundo bit (0) es el Noveno bit de X: Esto resuelve un problema físico. La pantalla de la SNES mide 256 píxeles de ancho. El Byte 0 de la Tabla Principal (que era 58 Hex, 88 en decimal) solo puede contar hasta 255.
    • Si queremos que un enemigo entre a la pantalla suavemente por la izquierda, su coordenada X necesita ser negativa (ej. -10).
    • Si queremos que salga por la derecha suavemente, su X tiene que ser mayor a 255 (ej. 260).
    • El Byte 0 no puede hacer números negativos ni mayores a 255 por sí solo.
    • Este “Noveno bit” actúa como un multiplicador o extensor de rango. Si está en 0, la X es normal (de 0 a 255). Si se pone en 1, el rango de la X cambia, permitiendo valores desde -256 hasta 511. Como nuestro Sprite 0 tiene un 0 aquí, su posición real sigue siendo 88. Si este bit hubiera sido 1, el hardware habría calculado la posición como 88 - 256 = -168, dibujándolo fuera de la pantalla.

Pachinko Wars: Entendiendo e Inyectando Gráficos Comprimidos con LZSS en SNES

En la ingeniería inversa de juegos de Super Nintendo (SNES), es muy común encontrarnos con gráficos comprimidos para ahorrar espacio en la ROM. Uno de los algoritmos de compresión sin pérdida más utilizados en esta época es el LZSS (Lempel-Ziv-Storer-Szymanski).

Para entender el código ensamblador que veremos más adelante, primero debemos comprender cómo funciona la lógica base de este algoritmo.

La Lógica del LZSS (Pseudocódigo)

El LZSS funciona utilizando un “diccionario” o ventana deslizante (normalmente un buffer en la memoria RAM). El algoritmo lee un “byte de control” cuyos 8 bits nos dicen cómo leer los siguientes datos:

  • Si el bit es 1, leemos el siguiente byte tal cual (Modo Literal) y lo copiamos a la salida.
  • Si el bit es 0, leemos los siguientes 2 bytes. Estos bytes contienen una posición en nuestro buffer y una longitud. Copiamos esa cantidad de bytes desde la posición indicada (Modo Diccionario).

Algoritmo simplificado en Python:

def lzss_descomprimir(datos_comprimidos):
    buffer = inicializar_buffer(4096, byte=0x00)
    cursor_buffer = 0x0FEE # Explicaremos esto más abajo
    salida = []
    mientras haya_datos(datos_comprimidos):
        byte_control = leer_byte()
        para cada bit en byte_control (de derecha a izquierda):
            si bit == 1:
                # MODO LITERAL: Copiamos el byte tal cual
                valor = leer_byte()
                salida.añadir(valor)
                # Actualizamos nuestro diccionario
                buffer[cursor_buffer] = valor
                cursor_buffer = (cursor_buffer + 1) % 4096
            si bit == 0:
                # MODO DICCIONARIO: Copiamos secuencias ya vistas
                byte1 = leer_byte()
                byte2 = leer_byte()
                # Desempaquetamos la posición y la longitud
                posicion = calcular_posicion(byte1, byte2)
                longitud = calcular_longitud(byte2) + 3 # Mínimo copiamos 3 bytes
                para i desde 0 hasta longitud:
                    valor = buffer[(posicion + i) % 4096]
                    salida.añadir(valor)
                    # Actualizamos nuestro diccionario
                    buffer[cursor_buffer] = valor
                    cursor_buffer = (cursor_buffer + 1) % 4096

El Cursor en 0x0FEE

En el algoritmo LZSS de este juego, el cursor que escribe en el buffer temporal de 4KB (4096 bytes) no empieza en la posición 0x000.

Para hacer el código más eficiente y evitar un error (como que el algoritmo intente “copiar” del diccionario antes de haber escrito nada), se configuró el cursor inicial restando el límite máximo de copia al tamaño total del buffer.

Sabiendo que el comando máximo empaquetado en los bits es F (15) + 3, lo máximo que el algoritmo puede copiar de un solo golpe son 18 bytes.

  • Tamaño del buffer temporal: 4096
  • Menos el límite de copia: 18
  • Resultado: 4078 (Que en hexadecimal es 0x0FEE)

Análisis del Código Ensamblador

Tras realizar una búsqueda en la ROM, encontramos que los punteros y parámetros están definidos (hardcodeados) en la siguiente rutina. Aquí es donde se prepara el setup para la descompresión.

Preparación de Parámetros

00D200  LDA #$60
00D202  STA $0001      ; Parámetro de VRAM (Posiblemente modo gráfico o registro de destino)
00D205  LDA #$00
00D207  STA $0000      ; Dirección de VRAM (Destino final tras la descompresión)
00D20A  LDA #$0E
00D20C  STA $0005      ; [BANCO] de la ROM donde están los tiles a descomprimir ($0E)
00D20F  LDA #$EE
00D211  STA $0004      ; [BYTE ALTO] de la fuente
00D214  LDA #$5A
00D216  STA $0003      ; [BYTE BAJO] de la fuente. Puntero fuente = ROM[0E:EE5A]
00D219  LDA #$10
00D21B  STA $000D      ; [BYTE ALTO] Tamaño a descomprimir
00D21E  LDA #$00
00D220  STA $000C      ; [BYTE BAJO] Cantidad a descomprimir: $1000 bytes
00D223  JSR $9407      ; Salto a la rutina intermedia

Rutina Intermedia

009407  PHY
009408  PHP
009409  REP #$10       ; Index Registers (X,Y) a 16 bits
00940B  SEP #$20       ; Accumulator (A) a 8 bits
00940D  LDX $0003      ; X = Dirección Fuente ($EE5A)
009410  LDA $0005      ; A = Banco Fuente ($0E)
009413  JSR $8BBF      ; Salto a inicializar el Buffer LZSS

Inicialización del Buffer (LZSS_SETUP)

Esta rutina se encarga de limpiar el buffer de trabajo en la WRAM (Work RAM de la SNES) y configurar el cursor inicial que calculamos antes (0x0FEE).

; A = 0E   <--- BANCO
; X = EE5A <--- DIRECCIÓN FUENTE
008BBF  PHP
008BC0  REP #$10       ; X,Y a 16-bit
008BC2  SEP #$20       ; A a 8-bit
008BC4  STX $9F        ; RAM[$9F] = Puntero de Lectura (EE5A)
008BC6  STA $A1        ; RAM[$A1] = Banco de Lectura (0E)
008BC8  LDA #$00
008BCA  STA $A7        ; Reinicia variables de control LZSS
008BCC  STA $A6        ; Contador de bits del byte de control
008BCE  LDX #$0FEE      
008BD1  STX $AA        ; RAM[$AA] = Cursor del Buffer = $0FEE
008BD3  DEX
008BD4  STA $7EF000,X  ; Limpia con $00 la memoria RAM de SNES (Dirección base $7EF000)
008BD8  DEX
008BD9  BPL $008BD4    ; Bucle hasta limpiar los 4096 bytes
008BDB  PLP
008BDC  RTS

Preparando el Destino

Justo después de inicializar el buffer, el código prepara dónde se guardarán los datos ya descomprimidos.

009416  REP #$20       ; A a 16 bits
009418  LDA $000C      ; A = $1000 (Tamaño original solicitado)
00941B  ASL            ; A = A * 2 = $2000 (Tamaño real en bytes de salida esperado)
00941C  TAY            ; Y = $2000 (Contador de bytes a descomprimir)
00941D  SEP #$20       ; A a 8 bits
00941F  LDA #$7E       ; A = $7E (Banco de WRAM en SNES)
009421  LDX #$7000     ; X = $7000 (Dirección destino de la descompresión)
009424  JSR $8BDD      ; Salto a la descompresión principal

Nota: Los datos se descomprimirán temporalmente en $7E:7000 antes de ser transferidos a la VRAM mediante DMA.

El Bucle Principal de Descompresión (LZSS_DESCOMPRESS)

Esta es la implementación ensamblador del pseudocódigo que vimos al inicio.

; A = 7E   <--- Banco Destino (WRAM)
; X = 7000 <--- Dirección Destino
; Y = 2000 <--- Cantidad de bytes a descomprimir
008BDD  PHP
008BDE  REP #$10       ; X, Y a 16 bits
008BE0  SEP #$20       ; A a 8 bits
008BE2  STX $A2        ; RAM[$A2] = Dirección Destino ($7000)
008BE4  STA $A4        ; RAM[$A4] = Banco Destino ($7E)
008BE6  LDA $A7        ; A = RAM[$A7] (Flag para saber si el byte de control se agotó)
008BE8  BNE $008C36    ; Si no es cero, saltar a lógica de control
008BEA  DEC $A6        ; Entra con A[$A6] = 0 - 1 = $FF
008BEC  BPL $008C03    ; Si es positivo, salta. Si es $FF, toca leer nuevo byte de control
; --- LEER NUEVO BYTE DE CONTROL ---
008BEE  LDA [$9F]      ; A = ROM[Fuente] -> Lee el Byte de control
008BF0  INC $9F        ; I++ (Avanza puntero fuente)
008BF2  BNE $008BFD
008BF4  INC $A0        ; Incrementa byte alto si hay overflow
008BF6  BNE $008BFD
008BF8  INC $A1        ; Incrementa banco si es necesario
008BFA  SEC            
008BFB  ROR $A0
008BFD  STA $A5        ; RAM[$A5] = Guarda el Byte de Control actual
008BFF  LDA #$07       ; Reinicia el contador de bits (8 bits, de 7 a 0)
008C01  STA $A6
; --- PROCESAR BIT DE CONTROL ---
008C03  LDA [$9F]      ; A = Lee el siguiente byte de la ROM (Puede ser literal o parte del diccionario)
008C05  INC $9F        ; I++ (Avanza puntero)
008C07  BNE $008C12
008C09  INC $A0
008C0B  BNE $008C12
008C0D  INC $A1
008C0F  SEC
008C10  ROR $A0
008C12  ROR $A5        ; Rotamos el byte de control a la derecha. El bit evaluado pasa al Carry Flag (C)
008C14  BCS $008C49    ; Branch if Carry Set: Si C==1 (MODO LITERAL), salta a 008C49 para copiarlo
; --- MODO DICCIONARIO ---
008C16  STA $A8        ; Guarda la primera parte del puntero del diccionario
008C18  LDA [$9F]      ; Lee el segundo byte de la ROM (Dirección + Longitud)
008C1A  INC $9F        ; I++
008C1C  BNE $008C27
008C1E  INC $A0
008C20  BNE $008C27
008C22  INC $A1
008C24  SEC
008C25  ROR $A0
008C27  PHA            ; Guarda en la pila
008C28  LSR            ; A >> 1 (Aislando los bits superiores)
008C29  LSR            ; A >> 1
008C2A  LSR            ; A >> 1
008C2B  LSR            ; A >> 1 -> Nos quedamos con los 4 bits del diccionario
008C2C  STA $A9
008C2E  PLA            ; Recupera de la pila
008C2F  AND #$0F       ; Filtra los 4 bits inferiores (La longitud de la copia)
008C31  CLC
008C32  ADC #$03       ; A = Longitud + 3 (Mínimo copiamos 3 bytes)
008C34  STA $A7        ; Guarda cantidad restante a copiar
008C36  DEC $A7        ; Decrementa contador de copia del diccionario
008C38  LDX $A8        ; Carga posición en el diccionario
008C3A  REP #$20
008C3C  TXA
008C3D  INC
008C3E  AND #$0FFF     ; Fuerza a mantenerse dentro de los 4KB ($0FFF)
008C41  STA $A8
008C43  SEP #$20
008C45  LDA $7EF000,X  ; Lee el byte del buffer diccionario (Dirección base $7EF000)
; --- MODO LITERAL Y ESCRITURA EN DESTINO ---
008C49  LDX $AA        ; Carga el Cursor actual del Buffer 
008C4B  STA $7EF000,X  ; Escribe el nuevo byte en el Buffer LZSS para futuras referencias
008C4F  STA [$A2]      ; Escribe finalmente en el RAM Destino ($7E7000)
008C51  REP #$20
008C53  INC $A2        ; Incrementa puntero destino
008C55  TXA
008C56  INC
008C57  AND #$0FFF     ; Asegura que el Cursor del Buffer se envuelva (wrap-around) a los 4KB
008C5A  STA $AA        ; Guarda el nuevo Cursor
008C5C  SEP #$20
008C5E  DEY            ; Decrementa contador total (Y = $2000)
008C5F  BNE $008BE6    ; Si no ha terminado, repite todo el proceso
008C61  PLP
008C62  RTS            ; Fin de la descompresión

El Set de Herramientas (Toolkit)

Para poder modificar estos gráficos, inyectar una firma personalizada y reinsertarlos en la ROM, se crearon tres scripts en Python que facilita un poco el proceso.

Herramienta 1: Descompresor LZSS (lzss.py)

Este script recrea la lógica ensamblador que acabamos de analizar. Toma los datos binarios o hexadecimales de la ROM y los descomprime reconstruyendo los gráficos originales. Podemos utilizar Tile Molester para ver los graficos directamente.

python lzss.py tiles_intro $(calc -c "15*15*32" 2> /dev/null)

lzss.py

import sys
import os
def decompress_lzss(compressed_data, target_size=0x2000):
    """
    Descomprime datos usando el algoritmo LZSS.
    """
    buffer = bytearray(4096)
    buffer_pos = 0xFEE 
    decompressed = bytearray()
    src_pos = 0
    while src_pos < len(compressed_data) and len(decompressed) < target_size:
        control = compressed_data[src_pos]
        src_pos += 1
        for i in range(8):
            if len(decompressed) >= target_size or src_pos >= len(compressed_data):
                break
            bit = (control >> i) & 1
            if bit == 1:
                # MODO LITERAL
                val = compressed_data[src_pos]
                src_pos += 1
                decompressed.append(val)
                buffer[buffer_pos] = val
                buffer_pos = (buffer_pos + 1) % 4096
            else:
                # MODO DICCIONARIO
                if src_pos + 1 < len(compressed_data):
                    b1 = compressed_data[src_pos]
                    b2 = compressed_data[src_pos+1]
                    src_pos += 2
                    dict_pos = ((b2 & 0xF0) << 4) | b1
                    length = (b2 & 0x0F) + 3
                    for _ in range(length):
                        if len(decompressed) >= target_size:
                            break
                        val = buffer[dict_pos]
                        decompressed.append(val)
                        buffer[buffer_pos] = val
                        buffer_pos = (buffer_pos + 1) % 4096
                        dict_pos = (dict_pos + 1) % 4096
    return decompressed
if __name__ == "__main__":
    if len(sys.argv) < 2:
        print("python lzss.py <archivo_de_entrada> <bytes_de_salida_a_guardar>", file=sys.stderr)
        sys.exit(1)
    input_filename = sys.argv[1]
    # Manejo de la cantidad de bytes que queremos conservar al final
    output_size = None
    if len(sys.argv) >= 3:
        try:
            output_size = int(sys.argv[2], 0)
        except ValueError:
            print(f"Error: El tamaño '{sys.argv[2]}' no es válido.", file=sys.stderr)
            sys.exit(1)
    if not os.path.exists(input_filename):
        print(f"Error: El archivo '{input_filename}' no existe.", file=sys.stderr)
        sys.exit(1)
    print(f"Leyendo archivo '{input_filename}'...", file=sys.stderr)
    with open(input_filename, "rb") as f:
        raw_data = f.read()
    try:
        text_data = raw_data.decode("ascii")
        clean_hex = "".join(text_data.split())
        compressed_bytes = bytearray(bytes.fromhex(clean_hex))
        print("Formato detectado: Texto Hexadecimal", file=sys.stderr)
    except (UnicodeDecodeError, ValueError):
        compressed_bytes = bytearray(raw_data)
        print("Formato detectado: Binario Raw", file=sys.stderr)
    print(f"Descomprimiendo el bloque (Target interno: 0x2000)...", file=sys.stderr)
    decompressed_bytes = decompress_lzss(compressed_bytes, target_size=0x2000)
    # AQUI RECORTAMOS EL RESULTADO
    if output_size is not None:
        if output_size > len(decompressed_bytes):
            print(f"Advertencia: Pediste {output_size} bytes, pero solo se descomprimieron {len(decompressed_bytes)}.", file=sys.stderr)
        else:
            decompressed_bytes = decompressed_bytes[:output_size]
    print(f"¡Éxito! Nos quedamos con {len(decompressed_bytes)} bytes de salida.", file=sys.stderr)
    output_filename = "dump.bin"
    with open(output_filename, "wb") as f:
        f.write(decompressed_bytes)
    print(f"[ARCHIVO GUARDADO] Se ha creado el archivo '{output_filename}'.", file=sys.stderr)
    for i in range(0, len(decompressed_bytes), 16):
        chunk = decompressed_bytes[i:i+16]
        line = " ".join(f"{b:02X}" for b in chunk)
        print(line, file=sys.stdout)

Herramienta 2: Inyector de Firma Gráfica (patch_dump.py)

Una vez extraídos los gráficos, necesitamos inyectar nuestra firma. La SNES utiliza formatos de bits planos (bitplanes) para sus gráficos (usualmente 2bpp o 4bpp).

Este script toma una cadena de texto (ENG BY LOWLEVEL.1989), la convierte a gráficos 4bpp utilizando una fuente hardcodeada hexadecimal sacada del propio juego, y fuerza una paleta de color específica manipulando los bitplanes. El resultado lo inyecta en el archivo dump.bin (limitando el offset para no sobrescribir la paleta original comprimida en 0E:F288).

python patch_dump.py

(Nota: El script permite elegir colores alternando bits entre el Plano 1 y el Plano 2, creando un efecto naranja/rojizo o amarillo dependiendo del index).

patch_dump.py

HEX_FONT = """
00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
FF 00 FF 00 FF 00 FF 00 FF 00 FF 00 FF 00 FF 00
00 00 1C 1C 1C 1C 18 18 10 10 00 00 60 60 60 60
FF 00 FF 1C FF 1C FF 18 FF 10 FF 00 FF 60 FF 60
20 20 90 90 40 40 00 00 00 00 00 00 00 00 00 00
FF 20 FF 90 FF 40 FF 00 FF 00 FF 00 FF 00 FF 00
60 60 90 90 90 90 60 60 00 00 00 00 00 00 00 00
FF 60 FF 90 FF 90 FF 60 FF 00 FF 00 FF 00 FF 00
24 24 24 24 7E 7E 24 24 7E 7E 24 24 24 24 00 00
FF 24 FF 24 FF 7E FF 24 FF 7E FF 24 FF 24 FF 00
00 00 E2 E2 A4 A4 E8 E8 10 10 2E 2E 4A 4A 8E 8E
FF 00 FF E2 FF A4 FF E8 FF 10 FF 2E FF 4A FF 8E
30 30 48 48 48 48 30 30 4A 4A 84 84 7A 7A 00 00
FF 30 FF 48 FF 48 FF 30 FF 4A FF 84 FF 7A FF 00
20 20 20 20 40 40 00 00 00 00 00 00 00 00 00 00
FF 20 FF 20 FF 40 FF 00 FF 00 FF 00 FF 00 FF 00
08 08 10 10 20 20 20 20 20 20 10 10 08 08 00 00
FF 08 FF 10 FF 20 FF 20 FF 20 FF 10 FF 08 FF 00
10 10 08 08 04 04 04 04 04 04 08 08 10 10 00 00
FF 10 FF 08 FF 04 FF 04 FF 04 FF 08 FF 10 FF 00
00 00 10 10 54 54 38 38 10 10 38 38 54 54 10 10
FF 00 FF 10 FF 54 FF 38 FF 10 FF 38 FF 54 FF 10
00 00 10 10 10 10 10 10 FE FE 10 10 10 10 10 10
FF 00 FF 10 FF 10 FF 10 FF FE FF 10 FF 10 FF 10
00 00 00 00 00 00 00 00 E0 E0 E0 E0 60 60 C0 C0
FF 00 FF 00 FF 00 FF 00 FF E0 FF E0 FF 60 FF C0
00 00 00 00 00 00 00 00 FE FE 00 00 00 00 00 00
FF 00 FF 00 FF 00 FF 00 FF FE FF 00 FF 00 FF 00
00 00 00 00 00 00 00 00 00 00 60 60 60 60 00 00
FF 00 FF 00 FF 00 FF 00 FF 00 FF 60 FF 60 FF 00
00 00 02 02 04 04 08 08 10 10 20 20 40 40 80 80
FF 00 FF 02 FF 04 FF 08 FF 10 FF 20 FF 40 FF 80
00 00 38 38 64 64 C6 C6 C6 C6 C6 C6 4C 4C 38 38
FF 00 FF 38 FF 64 FF C6 FF C6 FF C6 FF 4C FF 38
00 00 18 18 38 38 18 18 18 18 18 18 18 18 3C 3C
FF 00 FF 18 FF 38 FF 18 FF 18 FF 18 FF 18 FF 3C
00 00 7C 7C C6 C6 06 06 0C 0C 38 38 60 60 FE FE
FF 00 FF 7C FF C6 FF 06 FF 0C FF 38 FF 60 FF FE
00 00 FE FE 0C 0C 18 18 3C 3C 06 06 C6 C6 7C 7C
FF 00 FF FE FF 0C FF 18 FF 3C FF 06 FF C6 FF 7C
00 00 0C 0C 1C 1C 3C 3C 6C 6C CC CC FE FE 0C 0C
FF 00 FF 0C FF 1C FF 3C FF 6C FF CC FF FE FF 0C
00 00 FC FC C0 C0 FC FC 06 06 06 06 C6 C6 7C 7C
FF 00 FF FC FF C0 FF FC FF 06 FF 06 FF C6 FF 7C
00 00 3C 3C 60 60 C0 C0 FC FC C6 C6 C6 C6 7C 7C
FF 00 FF 3C FF 60 FF C0 FF FC FF C6 FF C6 FF 7C
00 00 FE FE C6 C6 0C 0C 18 18 30 30 30 30 30 30
FF 00 FF FE FF C6 FF 0C FF 18 FF 30 FF 30 FF 30
00 00 7C 7C C6 C6 E6 E6 7C 7C CE CE C6 C6 7C 7C
FF 00 FF 7C FF C6 FF E6 FF 7C FF CE FF C6 FF 7C
00 00 7C 7C C6 C6 C6 C6 7E 7E 06 06 0C 0C 78 78
FF 00 FF 7C FF C6 FF C6 FF 7E FF 06 FF 0C FF 78
00 00 00 00 18 18 00 00 00 00 18 18 18 18 00 00
FF 00 FF 00 FF 18 FF 00 FF 00 FF 18 FF 18 FF 00
00 00 00 00 18 18 00 00 00 00 18 18 18 18 08 08
FF 00 FF 00 FF 18 FF 00 FF 00 FF 18 FF 18 FF 08
00 00 00 00 40 40 70 70 7C 7C 70 70 40 40 00 00
FF 00 FF 00 FF 40 FF 70 FF 7C FF 70 FF 40 FF 00
00 00 00 00 10 10 10 10 38 38 38 38 7C 7C 00 00
FF 00 FF 00 FF 10 FF 10 FF 38 FF 38 FF 7C FF 00
00 00 00 00 7C 7C 38 38 38 38 10 10 10 10 00 00
FF 00 FF 00 FF 7C FF 38 FF 38 FF 10 FF 10 FF 00
00 00 78 78 CC CC 0C 0C 38 38 30 30 00 00 30 30
FF 00 FF 78 FF CC FF 0C FF 38 FF 30 FF 00 FF 30
00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
FF 00 FF 00 FF 00 FF 00 FF 00 FF 00 FF 00 FF 00
00 00 38 38 6C 6C C6 C6 C6 C6 FE FE C6 C6 C6 C6
FF 00 FF 38 FF 6C FF C6 FF C6 FF FE FF C6 FF C6
00 00 FC FC C6 C6 C6 C6 FC FC C6 C6 C6 C6 FC FC
FF 00 FF FC FF C6 FF C6 FF FC FF C6 FF C6 FF FC
00 00 3C 3C 66 66 C0 C0 C0 C0 C0 C0 66 66 3C 3C
FF 00 FF 3C FF 66 FF C0 FF C0 FF C0 FF 66 FF 3C
00 00 FC FC C6 C6 C2 C2 C2 C2 C2 C2 C6 C6 FC FC
FF 00 FF FC FF C6 FF C2 FF C2 FF C2 FF C6 FF FC
00 00 FE FE C0 C0 C0 C0 FC FC C0 C0 C0 C0 FE FE
FF 00 FF FE FF C0 FF C0 FF FC FF C0 FF C0 FF FE
00 00 FE FE C0 C0 C0 C0 FC FC C0 C0 C0 C0 C0 C0
FF 00 FF FE FF C0 FF C0 FF FC FF C0 FF C0 FF C0
00 00 3C 3C 66 66 C0 C0 CE CE C6 C6 66 66 3E 3E
FF 00 FF 3C FF 66 FF C0 FF CE FF C6 FF 66 FF 3E
00 00 C6 C6 C6 C6 C6 C6 FE FE C6 C6 C6 C6 C6 C6
FF 00 FF C6 FF C6 FF C6 FF FE FF C6 FF C6 FF C6
00 00 78 78 30 30 30 30 30 30 30 30 30 30 78 78
FF 00 FF 78 FF 30 FF 30 FF 30 FF 30 FF 30 FF 78
00 00 06 06 06 06 06 06 06 06 C6 C6 C6 C6 7C 7C
FF 00 FF 06 FF 06 FF 06 FF 06 FF C6 FF C6 FF 7C
00 00 C6 C6 CC CC F8 F8 F0 F0 D8 D8 CC CC C6 C6
FF 00 FF C6 FF CC FF F8 FF F0 FF D8 FF CC FF C6
00 00 C0 C0 C0 C0 C0 C0 C0 C0 C0 C0 C0 C0 FE FE
FF 00 FF C0 FF C0 FF C0 FF C0 FF C0 FF C0 FF FE
00 00 C6 C6 EE EE FE FE D6 D6 C6 C6 C6 C6 C6 C6
FF 00 FF C6 FF EE FF FE FF D6 FF C6 FF C6 FF C6
00 00 C6 C6 E6 E6 F6 F6 FE FE DE DE CE CE C6 C6
FF 00 FF C6 FF E6 FF F6 FF FE FF DE FF CE FF C6
00 00 7C 7C C6 C6 C6 C6 C6 C6 C6 C6 C6 C6 7C 7C
FF 00 FF 7C FF C6 FF C6 FF C6 FF C6 FF C6 FF 7C
00 00 FC FC C6 C6 C6 C6 C6 C6 FC FC C0 C0 C0 C0
FF 00 FF FC FF C6 FF C6 FF C6 FF FC FF C0 FF C0
00 00 7C 7C C6 C6 C6 C6 C6 C6 DE DE D4 D4 7A 7A
FF 00 FF 7C FF C6 FF C6 FF C6 FF DE FF D4 FF 7A
00 00 FC FC C6 C6 C6 C6 CE CE F8 F8 CC CC C6 C6
FF 00 FF FC FF C6 FF C6 FF CE FF F8 FF CC FF C6
00 00 7C 7C C6 C6 C0 C0 7C 7C 06 06 C6 C6 7C 7C
FF 00 FF 7C FF C6 FF C0 FF 7C FF 06 FF C6 FF 7C
00 00 FC FC 30 30 30 30 30 30 30 30 30 30 30 30
FF 00 FF FC FF 30 FF 30 FF 30 FF 30 FF 30 FF 30
00 00 C6 C6 C6 C6 C6 C6 C6 C6 C6 C6 C6 C6 7C 7C
FF 00 FF C6 FF C6 FF C6 FF C6 FF C6 FF C6 FF 7C
00 00 C6 C6 C6 C6 C6 C6 C6 C6 6C 6C 38 38 10 10
FF 00 FF C6 FF C6 FF C6 FF C6 FF 6C FF 38 FF 10
00 00 C6 C6 C6 C6 D6 D6 FE FE EE EE C6 C6 82 82
FF 00 FF C6 FF C6 FF D6 FF FE FF EE FF C6 FF 82
00 00 C6 C6 EE EE 38 38 10 10 38 38 EE EE C6 C6
FF 00 FF C6 FF EE FF 38 FF 10 FF 38 FF EE FF C6
00 00 CC CC CC CC CC CC 78 78 30 30 30 30 30 30
FF 00 FF CC FF CC FF CC FF 78 FF 30 FF 30 FF 30
00 00 FE FE 0E 0E 1C 1C 38 38 70 70 E0 E0 FE FE
FF 00 FF FE FF 0E FF 1C FF 38 FF 70 FF E0 FF FE
0C 0C 08 08 08 08 08 08 08 08 08 08 0C 0C 00 00
FF 0C FF 08 FF 08 FF 08 FF 08 FF 08 FF 0C FF 00
44 44 28 28 10 10 7C 7C 10 10 7C 7C 10 10 00 00
FF 44 FF 28 FF 10 FF 7C FF 10 FF 7C FF 10 FF 00
30 30 10 10 10 10 10 10 10 10 10 10 30 30 00 00
FF 30 FF 10 FF 10 FF 10 FF 10 FF 10 FF 30 FF 00
18 18 24 24 00 00 00 00 00 00 00 00 00 00 00 00
FF 18 FF 24 FF 00 FF 00 FF 00 FF 00 FF 00 FF 00
3C 3C 00 00 00 00 00 00 00 00 00 00 00 00 00 00
FF 3C FF 00 FF 00 FF 00 FF 00 FF 00 FF 00 FF 00
00 00 10 10 08 08 00 00 00 00 00 00 00 00 00 00
FF 00 FF 10 FF 08 FF 00 FF 00 FF 00 FF 00 FF 00
00 00 00 00 3C 3C 06 06 3E 3E 66 66 66 66 3F 3F
FF 00 FF 00 FF 3C FF 06 FF 3E FF 66 FF 66 FF 3F
00 00 00 00 60 60 60 60 7C 7C 66 66 66 66 7C 7C
FF 00 FF 00 FF 60 FF 60 FF 7C FF 66 FF 66 FF 7C
00 00 00 00 00 00 3C 3C 66 66 60 60 66 66 3C 3C
FF 00 FF 00 FF 00 FF 3C FF 66 FF 60 FF 66 FF 3C
00 00 00 00 06 06 06 06 3E 3E 66 66 66 66 3E 3E
FF 00 FF 00 FF 06 FF 06 FF 3E FF 66 FF 66 FF 3E
00 00 00 00 00 00 3C 3C 66 66 7E 7E 60 60 3E 3E
FF 00 FF 00 FF 00 FF 3C FF 66 FF 7E FF 60 FF 3E
00 00 00 00 1C 1C 36 36 30 30 7C 7C 30 30 30 30
FF 00 FF 00 FF 1C FF 36 FF 30 FF 7C FF 30 FF 30
00 00 00 00 3A 3A 64 64 66 66 3E 3E 06 06 3C 3C
FF 00 FF 00 FF 3A FF 64 FF 66 FF 3E FF 06 FF 3C
00 00 00 00 60 60 60 60 7C 7C 66 66 66 66 66 66
FF 00 FF 00 FF 60 FF 60 FF 7C FF 66 FF 66 FF 66
00 00 18 18 18 18 00 00 18 18 18 18 18 18 18 18
FF 00 FF 18 FF 18 FF 00 FF 18 FF 18 FF 18 FF 18
00 00 06 06 06 06 00 00 06 06 06 06 66 66 3C 3C
FF 00 FF 06 FF 06 FF 00 FF 06 FF 06 FF 66 FF 3C
00 00 00 00 60 60 66 66 6C 6C 78 78 6C 6C 66 66
FF 00 FF 00 FF 60 FF 66 FF 6C FF 78 FF 6C FF 66
00 00 00 00 60 60 60 60 60 60 60 60 60 60 7C 7C
FF 00 FF 00 FF 60 FF 60 FF 60 FF 60 FF 60 FF 7C
00 00 00 00 7C 7C 6A 6A 6A 6A 6A 6A 6A 6A 6A 6A
FF 00 FF 00 FF 7C FF 6A FF 6A FF 6A FF 6A FF 6A
00 00 00 00 7C 7C 66 66 66 66 66 66 66 66 66 66
FF 00 FF 00 FF 7C FF 66 FF 66 FF 66 FF 66 FF 66
00 00 00 00 3C 3C 66 66 66 66 66 66 66 66 3C 3C
FF 00 FF 00 FF 3C FF 66 FF 66 FF 66 FF 66 FF 3C
00 00 00 00 7C 7C 66 66 66 66 7C 7C 60 60 60 60
FF 00 FF 00 FF 7C FF 66 FF 66 FF 7C FF 60 FF 60
00 00 00 00 3E 3E 66 66 66 66 3E 3E 06 06 07 07
FF 00 FF 00 FF 3E FF 66 FF 66 FF 3E FF 06 FF 07
00 00 00 00 6C 6C 7E 7E 66 66 60 60 60 60 60 60
FF 00 FF 00 FF 6C FF 7E FF 66 FF 60 FF 60 FF 60
00 00 00 00 3C 3C 62 62 38 38 1C 1C 46 46 3C 3C
FF 00 FF 00 FF 3C FF 62 FF 38 FF 1C FF 46 FF 3C
00 00 00 00 18 18 7E 7E 18 18 18 18 18 18 1C 1C
FF 00 FF 00 FF 18 FF 7E FF 18 FF 18 FF 18 FF 1C
00 00 00 00 66 66 66 66 66 66 66 66 66 66 3E 3E
FF 00 FF 00 FF 66 FF 66 FF 66 FF 66 FF 66 FF 3E
00 00 00 00 66 66 66 66 66 66 24 24 3C 3C 18 18
FF 00 FF 00 FF 66 FF 66 FF 66 FF 24 FF 3C FF 18
00 00 00 00 6A 6A 6A 6A 6A 6A 6A 6A 6A 6A 3C 3C
FF 00 FF 00 FF 6A FF 6A FF 6A FF 6A FF 6A FF 3C
00 00 00 00 66 66 36 36 1C 1C 38 38 6C 6C 66 66
FF 00 FF 00 FF 66 FF 36 FF 1C FF 38 FF 6C FF 66
00 00 00 00 66 66 66 66 3C 3C 18 18 30 30 60 60
FF 00 FF 00 FF 66 FF 66 FF 3C FF 18 FF 30 FF 60
00 00 00 00 7E 7E 06 06 0C 0C 18 18 30 30 7E 7E
FF 00 FF 00 FF 7E FF 06 FF 0C FF 18 FF 30 FF 7E
00 00 0C 0C 18 18 18 18 30 30 18 18 18 18 0C 0C
FF 00 FF 0C FF 18 FF 18 FF 30 FF 18 FF 18 FF 0C
00 00 18 18 18 18 18 18 18 18 18 18 18 18 18 18
FF 00 FF 18 FF 18 FF 18 FF 18 FF 18 FF 18 FF 18
00 00 18 18 0C 0C 0C 0C 06 06 0C 0C 0C 0C 18 18
FF 00 FF 18 FF 0C FF 0C FF 06 FF 0C FF 0C FF 18
00 00 00 00 00 00 70 70 DB DB 0E 0E 00 00 00 00
FF 00 FF 00 FF 00 FF 70 FF DB FF 0E FF 00 FF 00
00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
"""
import os
def parse_clean_4bpp_font(hex_string, color_index=1):
    """Convierte el string hex, fuerza el color y genera un bloque 4bpp (32 bytes)."""
    clean_hex = "".join(hex_string.split())
    raw_bytes = bytes.fromhex(clean_hex)
    tiles_4bpp = []
    # Iteramos en bloques de 32 bytes desde el código fuente original
    for i in range(0, len(raw_bytes), 32):
        block = raw_bytes[i:i + 32]
        tile_16b = bytearray(block[:16]) # Tomamos solo los 16 bytes base
        # Iteramos en pares (Byte 0 = Plano 1, Byte 1 = Plano 2, etc.)
        for j in range(0, 16, 2):
            bp1 = tile_16b[j]
            bp2 = tile_16b[j+1]
            # Combinamos la información de la forma para no perder píxeles
            shape_mask = bp1 | bp2 
            if color_index == 1:
                tile_16b[j] = shape_mask      # Activar Plano 1
                tile_16b[j+1] = 0             # Desactivar Plano 2
            elif color_index == 2:
                tile_16b[j] = 0               # Desactivar Plano 1
                tile_16b[j+1] = shape_mask    # Activar Plano 2
            elif color_index == 3:
                tile_16b[j] = shape_mask      # Activar Plano 1
                tile_16b[j+1] = shape_mask    # Activar Plano 2
        # Construimos el tile final: 16 bytes de datos + 16 bytes de ceros (0x00)
        final_tile = bytes(tile_16b) + (b'\x00' * 16)
        tiles_4bpp.append(final_tile)
    return tiles_4bpp
def generate_signature_graphics(signature, tiles):
    payload = bytearray()
    for char in signature:
        tile_index = ord(char) - 32
        if tile_index < 0 or tile_index >= len(tiles):
            print(f"[!] Error: Character '{char}' not found in the provided source.")
            # Si el carácter no existe, rellenamos con 32 bytes de ceros para no romper la alineación
            payload.extend(b'\x00' * 32)
        else:
            payload.extend(tiles[tile_index])
    return payload
def inject_signature(file_path, start_offset, signature, color_index):
    if not os.path.exists(file_path):
        print(f"[!] Error: File '{file_path}' does not exist.")
        return
    # Extraer fuente, filtrar color y formatear con 16 bytes de ceros al final
    font_tiles = parse_clean_4bpp_font(HEX_FONT, color_index=color_index)
    payload = generate_signature_graphics(signature, font_tiles)
    with open(file_path, 'r+b') as f:
        f.seek(start_offset)
        f.write(payload)
    print(f"[*] Success: Signature injected at {hex(start_offset)} using Color {color_index}.")
    print(f"[*] Total bytes written: {len(payload)} ({len(signature)} tiles of 32 bytes).")
if __name__ == "__main__":
    ROM_FILE = "dump.bin"          # Tu archivo de 8kb
    OFFSET = 0x1000                # Dirección de inicio (mitad del archivo de 8kb)
    SIGNATURE_TEXT = "ENG BY LOWLEVEL.1989"
    # 1 o 2 = Naranja oscuro/rojizo, 3 = Amarillo claro
    COLOR_INDEX = 1 
    inject_signature(ROM_FILE, OFFSET, SIGNATURE_TEXT, COLOR_INDEX)

Herramienta 3: Re-compresor LZSS (lzss_compress.py)

Finalmente, para inyectar los datos modificados de vuelta en la ROM, necesitamos empaquetarlos usando el mismo método. Este script invierte el proceso: analiza el archivo sin comprimir, busca coincidencias en un buffer circular (usando “sliding window” overlap) y genera de nuevo los bytes de control correspondientes.

python lzss_compress.py dump.bin | wl-copy

El resultado (compressed.bin) o la salida estándar puede ser pegada de nuevo en la ROM mediante un editor hexadecimal usando el offset original ($0E:EE5A), y asi modificar los tiles del juego original.

lzss_compress.py

import sys
import os
def compress_lzss(uncompressed_data):
    """
    Comprime datos usando el algoritmo LZSS
    """
    buffer = bytearray(4096)
    buffer_pos = 0xFEE 
    compressed = bytearray()
    src_pos = 0
    total_len = len(uncompressed_data)
    last_percent = -1
    while src_pos < total_len:
        flags = 0
        token_data = bytearray()
        # Procesar en bloques de 8 tokens (1 bit por token para el byte de control)
        for i in range(8):
            if src_pos >= total_len:
                break
            max_len = min(18, total_len - src_pos)
            best_len = 0
            best_pos = 0
            # Solo buscamos en el diccionario si podemos hacer un match de al menos 3 bytes
            if max_len >= 3:
                first_char = uncompressed_data[src_pos]
                for dict_start in range(4096):
                    if buffer[dict_start] != first_char:
                        continue
                    distance = (buffer_pos - dict_start) % 4096
                    match_len = 1
                    # Simular la lectura circular con solapamiento (overlapping)
                    while match_len < max_len:
                        if distance > 0 and match_len >= distance:
                            expected_char = uncompressed_data[src_pos + match_len - distance]
                        else:
                            expected_char = buffer[(dict_start + match_len) % 4096]
                        if uncompressed_data[src_pos + match_len] != expected_char:
                            break
                        match_len += 1
                    # Guardar la mejor coincidencia encontrada
                    if match_len > best_len:
                        best_len = match_len
                        best_pos = dict_start
                        if best_len == max_len:
                            break # No podemos encontrar nada mejor que el máximo (18)
            if best_len >= 3:
                # MODO DICCIONARIO (bit = 0, no modificamos la flag 'i')
                b1 = best_pos & 0xFF
                b2 = (((best_pos >> 8) & 0x0F) << 4) | (best_len - 3)
                token_data.append(b1)
                token_data.append(b2)
                # Actualizar el buffer circular
                for _ in range(best_len):
                    buffer[buffer_pos] = uncompressed_data[src_pos]
                    buffer_pos = (buffer_pos + 1) % 4096
                    src_pos += 1
            else:
                # MODO LITERAL (bit = 1, marcamos la flag en la posición 'i')
                flags |= (1 << i)
                val = uncompressed_data[src_pos]
                token_data.append(val)
                # Actualizar el buffer circular
                buffer[buffer_pos] = val
                buffer_pos = (buffer_pos + 1) % 4096
                src_pos += 1
        # Escribimos el byte de control (flags) seguido de los datos
        compressed.append(flags)
        compressed.extend(token_data)
        # Indicador de progreso visual
        percent = (src_pos * 100) // total_len
        if percent != last_percent:
            print(f"\rComprimiendo: {percent}%", end="", file=sys.stderr)
            last_percent = percent
    print("\nCompresión completada.", file=sys.stderr)
    return compressed
if __name__ == "__main__":
    if len(sys.argv) < 2:
        print("python lzss_compress.py <dump.bin>", file=sys.stderr)
        sys.exit(1)
    input_filename = sys.argv[1]
    if not os.path.exists(input_filename):
        print(f"Error: El archivo '{input_filename}' no existe.", file=sys.stderr)
        sys.exit(1)
    print(f"Leyendo archivo '{input_filename}'...", file=sys.stderr)
    with open(input_filename, "rb") as f:
        uncompressed_bytes = f.read()
    print(f"Comprimiendo {len(uncompressed_bytes)} bytes...", file=sys.stderr)
    # Comprimir los datos
    compressed_bytes = compress_lzss(uncompressed_bytes)
    print(f"¡Éxito! Se han generado {len(compressed_bytes)} bytes comprimidos.", file=sys.stderr)
    # Exportamos el archivo binario final
    output_filename = "compressed.bin"
    with open(output_filename, "wb") as f:
        f.write(compressed_bytes)
    print(f"[ARCHIVO GUARDADO] Se ha creado el archivo '{output_filename}'.", file=sys.stderr)
    print("datos comprimidos en hexadecimal:\n", file=sys.stderr)
    # Imprimir en formato hexadecimal (16 bytes por línea)
    for i in range(0, len(compressed_bytes), 16):
        chunk = compressed_bytes[i:i+16]
        line = " ".join(f"{b:02X}" for b in chunk)
        print(line, file=sys.stdout)

Cómo la SNES calcula la dirección y el valor de color de un Sprite en Memoria

A continuación dejo todos los componentes necesarios para entender como hacer reversing a esta parte.

OAM (Object Attribute Memory) – Selección de Paleta

La OAM tiene 544 bytes y define las propiedades de los sprites. Cada sprite en pantalla consume 4 bytes consecutivos:

  • Byte 0: Posición X.
  • Byte 1: Posición Y.
  • Byte 2: Índice del Tile (Puntero a los gráficos en VRAM).
  • Byte 3: Atributos del Sprite.

El Byte 3 se estructura a nivel de bits como vhoopppc. Los bits p p p (bits 1, 2 y 3) definen el número de paleta (0 a 7) asignada a ese sprite.

  • 000 = Paleta 0
  • 001 = Paleta 1
  • (…)
  • 111 = Paleta 7

VRAM (Video RAM) – El Índice del Píxel

En VRAM residen los gráficos puros (Tiles). Los sprites estándar usan un formato de 4bpp (4 bits por píxel).
Al leer un Tile, cada píxel se forma apilando 4 bits, lo que permite un valor numérico local del 0 al 15 (0000 a 1111 en binario).

  • Valor 0 (0000): El hardware lo procesa como fondo (transparencia).
  • Valores 1 a 15 (0001 a 1111): Tinta sólida (colores usables).

CGRAM (Color Generator RAM) y el límite de 15 bits

Es la memoria física (512 bytes) que contiene los colores.

  • Base de Fondos: Direcciones $000 a $0FF.
  • Base de Sprites: Direcciones $100 a $1FF.

Las paletas se dividen en bloques de 32 bytes ($20 en hexadecimal). Cada paleta tiene 16 colores (16 * 2 = 32). El programador tiene total libertad para adaptar y sobreescribir esta paleta a su gusto, definiendo qué valores hexadecimales ocupan esos bytes.

El formato RGB555 y el almacenamiento en Memoria (Por qué es FF 7F y no FFFF):
Aunque la memoria dedica 16 bits (2 bytes) a cada color, el sistema solo utiliza 15 bits (5 Azul, 5 Verde, 5 Rojo). El bit 16 es ignorado por hardware y es fijado a 0.

  • El valor máximo con los tres canales encendidos (Blanco puro) en binario es: 0111 1111 1111 1111.
  • En valor hexadecimal absoluto, eso es $7FFF.
  • La SNES guarda los datos usando Little Endian (el byte de menor peso se escribe primero). Por lo tanto, el $7FFF se divide e invierte en memoria guardándose como FF 7F. Si intentas escribir FFFF, un emulador preciso corregirá el bit 16 automáticamente, devolviéndolo a FF 7F.

Ejemplo de una sub-paleta en Memoria (32 bytes a partir de $100):

100: 00 00 FF 7F 1F 00 E0 03 FF 7F 00 00 00 00 00 00
110: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00

En este bloque adaptado por el programador, el Color 0 es 00 00 (transparente), el Color 1 es FF 7F (Blanco Puro), el Color 2 es 1F 00 (Rojo puro) y el Color 3 es E0 03 (Verde puro).

La Fórmula del Hardware (Direccionamiento Físico)

Cuando la PPU va a dibujar un píxel específico de un sprite en pantalla, ejecuta el siguiente cálculo matemático para saber qué bytes exactos leer en la CGRAM:

Dirección CGRAM = Memoria Base + (Paleta OAM * Tamaño Paleta) + (Píxel VRAM * Tamaño Color)

  • Memoria Base: $100 (Inicio del bloque de sprites).
  • Tamaño Paleta: $20 (32 bytes).
  • Tamaño Color: 2 (2 bytes por color).

La fórmula matemática final es:
Dirección = $100 + (Valor ppp * $20) + (Valor Píxel * 2)

Caso Práctico: El cálculo del color en $102

Si tenemos un sprite dibujando texto blanco y queremos saber por qué el emulador altera los valores ubicados específicamente en la dirección $102 y no en la $100, aplicamos el rastro de memoria:

A. Lectura de OAM:
El sprite del texto tiene los bits ppp del Byte 3 configurados en 000.

  • Offset de paleta: 0 * $20 = $000.

B. Lectura de VRAM:
La PPU escanea la cuadrícula del Tile y llega al píxel que forma la “tinta” de la letra blanca. El programador dibujó esa letra usando el valor 1 (0001).

  • Offset del píxel: 1 * 2 = $002.

C. Cálculo Final (PPU):

  • Base: $100
  • Offset OAM: + $000
  • Offset VRAM: + $002
  • Dirección Resultante: $102

La PPU lee los bytes $102 y $103, extrae los valores almacenados (FF 7F) y pinta el píxel blanco en pantalla respetando el límite de 15 bits.

¿Qué pasa con el fondo transparente del texto?
Cuando la PPU lee el píxel que actúa como fondo vacío de la letra en la VRAM, obtiene el valor 0 (0000).

  • Base: $100
  • Offset OAM: + $000
  • Offset VRAM: 0 * 2 = + $000
  • Dirección Resultante: $100

El hardware identifica que el valor del píxel en VRAM es 0. Por arquitectura de la SNES, se aborta la lectura de color para la dirección $100 y renderiza el píxel actual como transparente, dejando ver los gráficos de fondo (Backgrounds) que existan debajo del sprite.

Cómo se almacena un tile en la SNES en memoria?

En la SNES, los gráficos no utilizan un formato lineal (píxel por píxel). Utilizan un formato planar (planos de bits).

Para dibujar un tile estándar de 8×8 píxeles a 16 colores (4bpp), se necesitan 4 bits por píxel.

El cálculo es el siguiente:

  • Un tile mide 8×8 = 64 píxeles.
  • A 4 bits por píxel: 64 píxeles × 4 = 256 bits.
  • Como 1 byte equivale a 8 bits: 256 bits ÷ 8 = 32 bytes.

Esto significa que un tile completo ocupa exactamente 32 bytes en la memoria.

Entrelazado de planos (Interleaving)

La memoria divide estos 32 bytes en 4 “planos” o capas. Los datos no se escriben de forma secuencial por plano, sino que se entrelazan por pares para que el PPU los procese más rápido.

Rango de bytes Función
Bytes 0–15 Contienen las 8 líneas de los Planos 1 y 2 entrelazados.
Bytes 16–31 Contienen las 8 líneas de los Planos 3 y 4 entrelazados.

Orden de escritura por línea

Para procesar la primera línea de 8 píxeles del tile, el hardware lee los primeros bytes así:

  • $00: Fila 1, Plano 1.
  • $01: Fila 1, Plano 2.
  • $02: Fila 2, Plano 1.
  • $03: Fila 2, Plano 2.

Ejemplo en memoria (WRAM)

Si volcamos un bloque de memoria temporal desde la dirección $7060 donde la CPU está construyendo un gráfico, obtenemos exactamente los 32 bytes de un tile:

7060: 00 00 7C 00 C6 00 C6 00 7E 00 06 00 0C 00 78 00
7070: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00

Análisis del volcado

  • Direcciones $7070 a $707F: Todos son $00. Significa que los Planos 3 y 4 están vacíos.
  • Bytes impares ($7061, $7063…): Todos son $00. Significa que el Plano 2 está vacío.
  • Bytes pares ($7060, $7062…): Contienen datos. Este gráfico solo utiliza el Plano 1 (es de 1bpp monocromático).

Representación visual (Plano 1)

Si tomamos únicamente las direcciones pares (el Plano 1) de ese bloque de memoria y convertimos los valores hexadecimales a binario, los unos (1) representan píxeles activos y los ceros (0) el fondo transparente.

Dirección Hex Binario Píxeles
$7060 $00 00000000 ........
$7062 $7C 01111100 .█████..
$7064 $C6 11000110 ██...██.
$7066 $C6 11000110 ██...██.
$7068 $7E 01111110 .██████.
$706A $06 00000110 .....██.
$706C $0C 00001100 ....██..
$706E $78 01111000 .████...

Resultado: Los bits forman exactamente el número 9.

La CPU escribe estos bytes secuencialmente. Una vez que los 32 bytes están ensamblados en esta porción de la memoria WRAM, el gráfico queda listo para ser transferido en bloque hacia la VRAM mediante un canal DMA.

DMA en la SNES

1. ¿Cómo funciona?

El DMA (Direct Memory Access) transfiere bloques de datos sin que la CPU tenga que copiar cada byte individualmente.

La SNES tiene 8 canales DMA (0–7). Todos son configurables y tienen sus propios registros. Ningún canal tiene un destino fijo: puedes configurar cada uno para transferir datos a distintos registros de hardware.

2. Registros principales

Los registros de cada canal siguen el mismo patrón. El canal 0 utiliza $4300, el canal 1 $4310, el canal 2 $4320, etc.

Registro (canal 0) Función
$4300 Modo y comportamiento de la transferencia.
$4301 Registro de destino del bus B.
$4302–$4303 Dirección de origen de 16 bits.
$4304 Banco de origen.
$4305–$4306 Longitud a copiar en bytes.
$420B Activa los canales de DMA normal.
$420C Habilita los canales de HDMA.

El HDMA transfiere datos durante el intervalo H-Blank de cada scanline.

La diferencia principal es que el DMA normal transfiere un bloque de datos cuando lo activas, mientras que el HDMA realiza transferencias sincronizadas con el dibujado de la pantalla.

3. ¿Cómo se asignan las direcciones?

Origen (bus A)

La dirección se construye combinando el banco y la dirección de 16 bits.

Ejemplo:

  • $4302 = $00: byte bajo.
  • $4303 = $02: byte alto.
  • $4304 = $00: banco.

Origen resultante: $00:0200.

Destino (bus B)

$4301 selecciona el registro de destino del bus B. Para los registros del PPU, se calcula sumando $2100 al valor de $4301.

$4301 Destino Función
$04 $2104 OAMDATA: escritura en OAM.
$18 $2118 VMDATAL: escritura en VRAM.
$22 $2122 CGDATA: escritura en CGRAM.

4. ¿Cómo se calcula la longitud?

La longitud combina $4305 (byte bajo) y $4306 (byte alto).

Ejemplo:

  • $4305 = $20.
  • $4306 = $02.

Longitud resultante: $0220 = 544 bytes.

En DMA normal, una longitud de $0000 representa 65.536 bytes.

5. Ejemplo de código

STZ $2102       ; Pone a cero el byte bajo de la dirección OAM.
STZ $2103       ; Pone a cero el byte alto y sus bits de configuración.
                ; Así, la dirección inicial de OAM queda en $0000.
                ; El DMA empezará a escribir los datos desde esa posición de OAM.
LDX #$00
STX $4300       ; Modo DMA 0
LDX #$04
STX $4301       ; Destino: $2104 (OAMDATA)
LDA #$0200
STA $4302       ; Dirección de origen: $0200
LDX #$00
STX $4304       ; Banco de origen: $00
LDA #$0220
STA $4305       ; Longitud: 544 bytes
LDX #$01
STX $420B       ; Activa el canal DMA 0

Resultado

Parámetro Valor
Canal 0
Modo 0
Origen $00:0200
Destino $2104 (OAMDATA)
Longitud $0220 = 544 bytes

El DMA lee 544 bytes desde $00:0200 y los envía al puerto de escritura OAM $2104.

Pachinko Wars: cómo hallar el dinero

En este artículo explicaré cómo Pachinko Wars almacena el dinero en memoria y cómo localizar dicho valor utilizando el emulador Mesen.

Para ello utilizaremos la herramienta Debug → Memory Search. No debe confundirse con Debug → Memory Viewer.

Localizando el valor del dinero

Actualmente tenemos 20480G. Este valor no puede almacenarse en un único byte, ya que el valor máximo de un byte es 255.

Como no conocemos el formato utilizado por el juego para guardar el dinero, aprovecharemos la tienda para realizar algunas pruebas. Compraremos una pelotica que cuesta 10G y observaremos qué valores cambian en memoria.

Procedimiento

  1. Entrar en la tienda y situarse en la opción para comprar peloticas.
  2. Abrir la herramienta Debug → Memory Search.
  3. Realizar la primera búsqueda con los siguientes parámetros:
    • Memory type: Work RAM
    • Format: Hex
    • Value size: 16-bit
      (También podría ser 8 o 32 bits, pero inicialmente asumiremos que ocupa al menos 2 bytes.)
    • Compare To: Specific value = FFFF
    • Operator: Less than
    • Pulsar Apply Filter.
  4. Comprar una pelotica de 10G.
  5. Realizar una segunda búsqueda:
    • Compare To: Previous search value
    • Operator: Less than
    • Pulsar Apply Filter.
  6. Comprar otra pelotica de 10G.
  7. Repetir la búsqueda anterior:
    • Compare To: Previous search value
    • Operator: Less than
    • Pulsar Apply Filter.

Después de estos pasos encontraremos la dirección de memoria RAM[$11B7], cuyo contenido es 07 FE.

A primera vista estos valores no parecen tener sentido, ya que tras las compras nuestro dinero quedó en 20460G. Por lo tanto, es momento de experimentar para descubrir cómo funciona realmente este formato.

Analizando el formato

Comenzamos modificando ambos bytes:

RAM[$11B7] = 00
RAM[$11B8] = 00

Al volver al juego observamos que ahora tenemos 0G.

Probemos otro valor:

RAM[$11B7] = 01
RAM[$11B8] = 00

Ahora tenemos 10G.

Esto indica que el primer byte representa unidades de 10G. Verifiquémoslo asignando el valor máximo posible para un byte:

RAM[$11B7] = FF
RAM[$11B8] = 00

El resultado es 2550G.

La relación es evidente:

255 × 10 = 2550G

Por lo tanto, cada incremento en RAM[$11B7] equivale a 10G.

El papel del segundo byte

La siguiente hipótesis es que el segundo byte actúa como un contador que aumenta cada vez que el primer byte alcanza su valor máximo.

Probemos generando 2560G:

RAM[$11B7] = 00
RAM[$11B8] = 01

El resultado es exactamente 2560G.

Si queremos obtener 2570G, basta con hacer:

RAM[$11B7] = 01
RAM[$11B8] = 01

De esta forma podemos deducir que el dinero se almacena como un valor de 16 bits dividido en dos bytes:

Dinero = ((RAM[$11B8] * 256) + RAM[$11B7]) * 10

Dinero máximo

Si asignamos el valor máximo a ambos bytes:

RAM[$11B7] = FF
RAM[$11B8] = FF

Obtendremos:

65535 × 10 = 655350G

Por lo tanto, el valor máximo de dinero que puede almacenar el juego es 655350G.

¿Qué ocurre si vendemos peloticas con el dinero al máximo?

Quedaba una última curiosidad por resolver. Una vez alcanzado el valor máximo posible, ¿qué sucede si vendemos peloticas y el juego intenta aumentar todavía más el dinero?

Tras realizar la prueba, el resultado fue el esperado para un desbordamiento (overflow): el contador volvió al inicio y el dinero quedó reducido a 10G.

Esto confirma que el juego utiliza un contador de 16 bits sin ninguna protección adicional contra desbordamientos. Cuando se supera el valor máximo (FFFF), el contador vuelve a cero y continúa incrementándose desde ahí.

Comunicación Serie en el Control de la NES

Hoy me ha dado ganas de escribir un poco sobre la NES, y en este caso en especifico quiero explicar un poco a bajo nivel como funciona el control de la nes, un poco a nivel de electronica (alto nivel), como a nivel de software (bajo nivel).

Que vamos a realizar?

El programa que tengo en mente debe realizar lo siguiente:

  1. Leer la entrada del control 1 bit a bit
  2. Guardar la salida en la posición de memoria 0x00
  3. Leer la entrada del control 2 bit a bit
  4. Guardar la salida en la posición de memoria 0x01
  5. Compilar la rom

La idea es poder revisar el estado de los botones que tenemos pulsando utilizando el debugger del emulador FCEUX y leer la posición de memoria 0x00 para el control 1 y 0x01 para el control 2.

Estructura del control en memoria

La siguiente tabla es una representación de como guardaremos los datos en memoria.

Bit 7 Bit 6 Bit 5 Bit 4 Bit 3 Bit 2 Bit 1 Bit 0
A B Select Start Up Down Left Right

Que necesitamos conocer antes de programar?

Antes que nada necesitamos conocer un poco el hardware de la nes, de igual forma intentare escribirlo a muy alto nivel, utilizando nombres claves por si desean investigar puedan realizar la investigación por su cuenta y asi este post quede lo mas simple posible.

El shift register

El shift register utilizado en el control de la NES es el 4021. Este es un registro de desplazamiento de 8 bits con capacidad de conversión de datos de paralelo a serie. Esto significa que puede almacenar simultáneamente ocho valores de entrada y posteriormente enviarlos de forma secuencial, un bit a la vez. Debido a que dispone de 8 bits, cada botón del control se representa mediante un bit dentro del registro.

Para leer el estado de los botones, el procesador de la consola (el Ricoh 2A03) interactúa con los mandos a través de sus puertos de entrada/salida, mapeados en las direcciones de memoria $4016 (JOY1) para el control 1 y $4017 (JOY2) para el control 2.

A bajo nivel, el pulso se genera mediante la propia lógica de decodificación de direcciones del procesador:

  1. Decodificación de la lectura: Cuando el 2A03 ejecuta una instrucción de lectura sobre el bus (por ejemplo, LDA $4016), su decodificador interno de direcciones detecta la combinación de la dirección $4016 y la señal de lectura del bus en alto).
  2. Generación de la señal física: Luego la lógica interna del 2A03 conmuta a nivel bajo la línea de salida (/JOY1 o /JOY2). Con esto se genera un pulso de clock directo al pin 9 (CLK) del registro de desplazamiento 4021.
  3. Desplazamiento del bit: El flanco de subida de este pulso de reloj obliga al 4021 a desplazar su registro un bit hacia adelante, colocando inmediatamente el estado del siguiente botón en la salida serial del pin 3 (Q8).
  4. Mapeo en el bus de datos: La línea serial del pin 3 entra de vuelta al 2A03 y se mapea directamente en el bit de datos $D_0$ durante ese mismo ciclo de lectura.

De esta forma, la generación del pulso de clock es un efecto secundario puramente hardware derivado de la lectura en memoria: cada vez que el código ejecuta una instrucción de lectura en $4016 o $4017, el procesador emite automáticamente un pulso de reloj, permitiendo obtener el estado completo de los 8 botones realizando 8 lecturas consecutivas.

El compilador

Para compilar el programa que vamos a crear utilizaremos el compilador cc65, no tenia pensado explicar como se compila, pero vamos es muy sencillo

git clone https://github.com/cc65/cc65.git
cd cc65
make
sudo make PREFIX=/usr/local install

El código

Dejaré el código comentado para facilitar su comprensión, pero se asume que quienes lo lean tienen conocimientos básicos de Make y ASM, por lo que no explicaré cada punto de manera detallada.

Ensamblador

main.asm

; ---------------------------------------------------------------------------
; Cabecera iNES (Formato estándar de 16 bytes)
; ---------------------------------------------------------------------------
.segment "HEADER"
    .byte 'N', 'E', 'S', $1A  ; Firma de identificación
    .byte 1                   ; 1 banco de 16KB PRG
    .byte 0                   ; 0 bancos de 8KB CHR (Usa CHR RAM)
    .byte $01                 ; Mapper 0 (NROM), Mirroring Vertical
    .byte $00, $00, $00, $00, $00, $00, $00, $00, $00 ; Padding (9 bytes)
; ---------------------------------------------------------------------------
; Variables en RAM (Zero Page)
; ---------------------------------------------------------------------------
.segment "ZEROPAGE"
    joy1:      .res 1         ; Dir $00 - Estado final estable del control 1
    joy2:      .res 1         ; Dir $01 - Estado final estable del control 2
    temp_joy1: .res 1         ; Dir $02 - Variable temporal de trabajo 1
    temp_joy2: .res 1         ; Dir $03 - Variable temporal de trabajo 2
; ---------------------------------------------------------------------------
; Código Principal
; ---------------------------------------------------------------------------
.segment "CODE"
RESET:
    sei          ; Deshabilita interrupciones
    cld          ; Deshabilita modo decimal (no soportado por NES)
    ldx #$40
    stx $4017    ; Deshabilita interrupciones del APU (Audio)
    ldx #$FF
    txs          ; Inicializa el puntero de la pila (Stack Pointer) a $FF
    inx          ; X pasa a ser 0x00
    stx $2000    ; Deshabilita NMI (Interrupciones de video)
    stx $2001    ; Apaga el renderizado de pantalla
    stx $4010    ; Deshabilita interrupciones DMC
vblankwait1:
    bit $2002
    bpl vblankwait1
vblankwait2:
    bit $2002
    bpl vblankwait2
MainLoop:
    ; 1. Latch (Captura el estado actual de ambos controles)
    lda #$01
    sta $4016    ; Envía señal de HIGH al strobe
    lda #$00
    sta $4016    ; Envía señal de LOW al strobe (captura completada)
    ; 2. Lectura secuencial de los 8 botones
    ldx #$08     ; Contador para leer 8 veces
ReadLoop:
    ; --- Leer Control 1 en la variable temporal ---
    lda $4016    ; Lee el puerto del control 1
    lsr a        ; Extrae el bit al Carry Flag
    rol temp_joy1; Inserta el bit por la derecha y desplaza a la izquierda
    ; --- Leer Control 2 en la variable temporal ---
    lda $4017    ; Lee el puerto del control 2
    lsr a        ; Extrae el bit al Carry Flag
    rol temp_joy2; Inserta el bit por la derecha y desplaza a la izquierda
    dex          ; Reduce el contador X en 1
    bne ReadLoop ; Si X no es cero, vuelve a leer el siguiente botón
    ; 3. Volcar los resultados empaquetados a las variables finales
    lda temp_joy1
    sta joy1     ; Actualiza $00 con el byte ordenado: [A|B|Sel|St|U|D|L|R]
    lda temp_joy2
    sta joy2     ; Actualiza $01 con el byte ordenado: [A|B|Sel|St|U|D|L|R]
    jmp MainLoop ; Repite el bucle infinitamente
NMI:
IRQ:
    rti          ; Retorno de interrupción
; ---------------------------------------------------------------------------
; Vectores de hardware obligatorios
; ---------------------------------------------------------------------------
.segment "VECTORS"
    .word NMI    ; Vector NMI ($FFFA)
    .word RESET  ; Vector Reset ($FFFC) - Punto de entrada
    .word IRQ    ; Vector IRQ/BRK ($FFFE)

La razón por la que no se envía la señal de alto y bajo (Latch o Strobe) al registro $4017 para el control 2 se debe a cómo está diseñado el hardware de la NES.

En la arquitectura de la consola, escribir en $4016 envía la señal de Latch a ambos puertos de control simultáneamente.

Definición de la rom de la nes

nrom.cfg

MEMORY {
    # Memoria RAM del NES
    ZP:     start = $0000, size = $0100, type = rw; # Zero Page
    RAM:    start = $0200, size = $0600, type = rw; # RAM general
    # Memoria ROM del Cartucho
    HEADER: start = $0000, size = $0010,
      type = ro, file = %O, fill = yes, fillval = $00;
    PRG:    start = $C000, size = $4000,
      type = ro, file = %O, fill = yes, fillval = $FF;
}
SEGMENTS {
    # Mapeo de los bloques lógicos del código a la memoria física
    HEADER:   load = HEADER, type = ro;
    ZEROPAGE: load = ZP,     type = zp;
    CODE:     load = PRG,    type = ro, start = $C000;
    VECTORS:  load = PRG,    type = ro, start = $FFFA;
}

Makefile

PROJECT = control
AS = ca65
LD = ld65
CFG = nrom.cfg
SRC = main.asm
OBJ = main.o
OUT = $(PROJECT).nes
# --- Reglas ---
# La regla por defecto que se ejecuta al escribir "make"
all: $(OUT)
# Cómo crear el archivo .nes (Enlazado)
$(OUT): $(OBJ)
    $(LD) -C $(CFG) -o $(OUT) $(OBJ)
# Cómo crear el archivo .o (Ensamblado)
$(OBJ): $(SRC)
    $(AS) $(SRC) -o $(OBJ)
# Regla para limpiar los archivos generados
clean:
    rm -f $(OBJ) $(OUT)
# Declaración de reglas que no son archivos reales
.PHONY: all clean

Explorando la adaptación y modificaciones en RetroArch de RetroPie frente a RetroArch de Libretro

En este artículo, examinaremos el código de RetroArch en RetroPie y Libretro, para descubrir las diferencias entre la adaptación de RetroArch 1.18.0 de RetroPie y la versión oficial de RetroArch 1.18.0 de Libretro.

Nuestro objetivo es comprender a fondo estas implementaciones para obtener el conocimiento necesario que nos permita adaptar futuras versiones de RetroArch en RetroPie, o para agregar nuevo soporte a la versión actual.

A pesar de que ambas versiones ofrecen soporte para shaders y tienen similitudes, exploraremos en detalle las particularidades que las distinguen.

Es importante entender que RetroArch de RetroPie es una versión adaptada del RetroArch oficial desarrollado por The Libretro Team.

Nosotros descargaremos ambas implementaciones en su versión 1.18.0 y a continuación detallaré el proceso para identificar sus diferencias, que no son muchas.

Cabe mencionar que no es necesario tener conocimientos técnicos para seguir este artículo. Las porciones de código que proporcionaré serán explicadas a alto nivel para que sea fácil de entender por cualquier persona.

Todos los comandos que se utilizarán a continuación se pueden ejecutar tanto en entornos Windows como en Linux, pero como usuario de Linux que soy, me centraré en explicarlos como si estuvieras ejecutando los comandos en la propia Raspberry Pi.

Si estás en Windows, tu tarea es utilizar Git Bash o equivalente si quieres seguir mis pasos.

Sin más que decir, empecemos.

Descargando los proyectos

Lo primero es crear una carpeta donde descargaremos ambos proyectos para su posterior análisis.

mkdir retroarch
cd retroarch

El comando mkdir retroarch significa crear un directorio con el nombre retroarch. Mientras que con el comando cd retroarch nosotros entramos al directorio retroarch.

Ahora vamos a descargar ambos proyectos de RetroArch. Para ello necesitamos tener instalado Git en nuestro equipo.

Si estás en Windows ya te he mandado a instalarlo antes, y si estás en Linux, y estás leyendo este artículo, es muy probable que ya lo tengas instalado y si no es así, ya te estás tardando, ve e instala Git para que podamos continuar.

Ahora que ya tienes Git instalado, clonamos el repositorio de RetroArch de Libretro, que es la versión original, y le dejamos el mismo nombre.

git clone --branch v1.18.0 --single-branch https://github.com/libretro/RetroArch.git

Ahora que hemos descargado la versión de Libretro, continuemos con la de RetroPie ejecutando el siguiente comando. En este caso la nombraremos retropie-retroarch-v1.18.0 para evitar confusiones entre ambas versiones.

git clone --branch retropie-v1.18.0 --single-branch https://github.com/RetroPie/RetroArch.git retropie-retroarch-v1.18.0

Una vez descargados ambos proyectos, podemos ver ambas carpetas haciendo un simple ls -lh y deberías ver el siguiente resultado.

ls -lh

Salida esperada

total 24K
drwxr-xr-x 54 user group 12K Jun  6 19:06 RetroArch
drwxr-xr-x 54 user group 12K Jun  6 19:15 retropie-retroarch-v1.18.0

Buscando las diferencias

Comencemos a buscar las diferencias dentro del código.

Para esto, utilizaremos la herramienta del sistema Unix llamada diff, la cual es precisa para nuestra tarea de análisis.

diff --exclude .git -qr RetroArch retropie-retroarch-v1.18.0

Salida esperada

Files RetroArch/configuration.c and retropie-retroarch-v1.18.0/configuration.c differ
Files RetroArch/configuration.h and retropie-retroarch-v1.18.0/configuration.h differ
Files RetroArch/frontend/drivers/platform_unix.c and retropie-retroarch-v1.18.0/frontend/drivers/platform_unix.c differ
Files RetroArch/gfx/video_shader_parse.c and retropie-retroarch-v1.18.0/gfx/video_shader_parse.c differ

En la salida del comando anterior se pueden identificar los archivos con diferencias, los cuales se listan a continuación:

  • configuration.h
  • configuration.c
  • frontend/drivers/platform_unix.c
  • gfx/video_shader_parse.c

Ahora procedemos a analizar cada uno de ellos, para entender mejor la adaptación que ha realizado RetroPie e intentar entender el porqué de ello.

Veamos primero la diferencia del archivo gfx/video_shader_parse.c.

gfx/video_shader_parse.c

diff RetroArch/gfx/video_shader_parse.c retropie-retroarch-v1.18.0/gfx/video_shader_parse.c

Salida esperada

2785,2787c2785,2788
< 
<   fill_pathname_join(s, shader_directory, special_name, len);
<   strlcat(s, video_shader_get_preset_extension(types[i]), len);
---
>   if (strcmp(special_name, "config")!=0) {
>       fill_pathname_join(s, shader_directory, special_name, len);
>       strlcat(s, video_shader_get_preset_extension(types[i]), len);
>   }
2890a2892,2902
>       goto success;
>    }
>    /* Configuration file shader found ? */
>    strlcpy(shader_path, settings->paths.path_shader, PATH_MAX_LENGTH);
>    if (!string_is_empty(shader_path)) {
>       RARCH_LOG("[Shaders/RetroPie]: Configuration file shader path found.\n");
>       if(video_shader_load_shader_preset_internal(
>             shader_path,
>             sizeof(shader_path),
>             NULL, NULL,
>             "config"))

Vamos a explicar un poco la salida del comando diff.

Para no bajar tanto de nivel, explicaré un poco la primera línea 2785,2787c2785,2788.

La línea 2788 en el archivo gfx/video_shader_parse.c es donde se encuentra la primera diferencia.

El signo < indica que es código eliminado y el signo > indica que es código agregado.

Ahora, en la segunda diferencia que podemos observar 2890a2892,2902, que se encuentra en la línea 2902.

Sin embargo, creo que esta porción de código se entiende mejor si cambiamos el orden y lo separamos un poco para explicar qué es cada parte.

Vamos a verlo.

/* Configuration file shader found ? */
strlcpy(shader_path, settings->paths.path_shader, PATH_MAX_LENGTH);
if (!string_is_empty(shader_path)) {
    RARCH_LOG("[Shaders/RetroPie]: Configuration file shader path found.\n");
    if(video_shader_load_shader_preset_internal(
            shader_path,
            sizeof(shader_path),
            NULL, NULL,
            "config"))
         goto success;
    }

He copiado el código del archivo original para explicarlo por partes. Esta sección representa la segunda diferencia encontrada con el comando diff.

La siguiente parte del código valida si se ha configurado una ruta para los shaders de RetroPie.

Si no estás familiarizado con este término, puedes obtener más información en la documentación oficial de shaders.

strlcpy(shader_path, settings->paths.path_shader, PATH_MAX_LENGTH);
if (!string_is_empty(shader_path))

Si se encuentra una ruta válida configurada para los shaders en RetroPie, se aplicará el shader en RetroArch.

video_shader_load_shader_preset_internal(
            shader_path,
            sizeof(shader_path),
            NULL, NULL,
            "config"))

Podemos ver que el último argumento de la función es config y es un valor especial que ha asignado RetroPie para soportar su propia estructura de carpetas para sus shaders.

Podemos ver en la primera diferencia donde se valida que RetroArch solo ajuste la ruta del shader si el special_name es diferente de config.

if (strcmp(special_name, "config")!=0) {
  fill_pathname_join(s, shader_directory, special_name, len);
  strlcat(s, video_shader_get_preset_extension(types[i]), len);
}

Entonces, en el primer archivo analizado y modificado por RetroPie, se ha agregado soporte para una nueva ubicación para los shaders.

Según mi lógica, los archivos configuration.h y configuration.c deberían ser modificados para agregar el nuevo parámetro de configuración para esa nueva ruta de shaders.

Vamos a verlo.

configuration.h

diff RetroArch/configuration.h retropie-retroarch-v1.18.0/configuration.h

Salida esperada

548a549
>       char path_shader[PATH_MAX_LENGTH];

configuration.c

diff RetroArch/configuration.c retropie-retroarch-v1.18.0/configuration.c

Salida esperada

1582a1583
>    SETTING_PATH("video_shader",                  settings->paths.path_shader, false, NULL, true);
2952a2954
>    *settings->paths.path_shader            = '\0';
5169a5172,5175
>          /* blacklist the 'video_shader' configuration path, better handled by shader presets */
>          if (string_is_equal(path_settings[i].ident, "video_shader"))
>              continue;

Como esperábamos, los archivos configuration.h y configuration.c simplemente añaden soporte a la nueva variable que controla la ubicación actualizada de los shaders.

Además, limpiaré un poco la última salida para ustedes, copiándola del archivo original.

SETTING_PATH("video_shader", settings->paths.path_shader, false, NULL, true);
*settings->paths.path_shader = '\0';
/* blacklist the 'video_shader' configuration path, better handled by shader presets */
if (string_is_equal(path_settings[i].ident, "video_shader"))
    continue;

frontend/drivers/platform_unix.c

Ya solo falta un archivo por analizar: frontend/drivers/platform_unix.c.

Vamos a utilizar al protagonista de este artículo, diff, para realizar esta tarea.

diff RetroArch/frontend/drivers/platform_unix.c retropie-retroarch-v1.18.0/frontend/drivers/platform_unix.c

Salida esperada

1891,1896d1890
<    fill_pathname_join(g_defaults.dirs[DEFAULT_DIR_SRAM], base_path,
<          "saves", sizeof(g_defaults.dirs[DEFAULT_DIR_SRAM]));
<    fill_pathname_join(g_defaults.dirs[DEFAULT_DIR_SAVESTATE], base_path,
<          "states", sizeof(g_defaults.dirs[DEFAULT_DIR_SAVESTATE]));
<    fill_pathname_join(g_defaults.dirs[DEFAULT_DIR_SYSTEM], base_path,
<          "system", sizeof(g_defaults.dirs[DEFAULT_DIR_SYSTEM]));

Con la última salida, RetroPie ha eliminado las carpetas predeterminadas para saves, states y system, lo cual tiene total sentido, ya que buscan crear su propio ecosistema y dejar las rutas por defecto de RetroArch podría confundir al usuario final.

Conclusión

En resumen, la adaptación de RetroArch por RetroPie solo modifica la ruta de los shaders y elimina las rutas por defecto saves, states y system, logrando un entorno más limpio para todo tipo de usuarios.

Además, es fácil adaptar la nueva versión 1.19.1 de RetroArch a RetroPie para aprovechar las mejoras que trae consigo, como el soporte a botones multimedia del teclado, permitiendo usar periféricos como la Wireless Sensor DolphinBar en modo 2 con total compatibilidad.

Para finalizar, aquí están los enlaces a los commits originales realizados por el equipo de RetroPie y la descripción real del cambio:

  • Revert «(Linux) Set default saves/save states/system paths»
  • Add back the video_shader parameter.

Actualmente están trabajando en la adaptación de la versión 1.19.1 que cuenta con los siguientes cambios relevantes:

  • Agrega soporte para teclas multimedia del teclado, necesario para el soporte total de DolphinBar.
  • Implementación de input test para el Remote RetroPad.
  • Fixed a crash that occurred when using threaded video.

También me gustaría compartir mi adaptación hasta que el equipo de RetroPie publique la versión oficial soportada por ellos mismos.

https://github.com/lowlevel-1989/RetroArch/tree/retropie-v1.19.1
https://github.com/RetroPie/RetroArch/pull/8

Ingeniería inversa y ROM patching en Contra (NES)

El objetivo de esta guía es analizar y modificar el comportamiento interno de la versión japonesa de Contra mediante ingeniería inversa básica. A lo largo del artículo exploramos cómo localizar variables clave en la memoria RAM (vidas y armamento), rastrear las subrutinas de la CPU 6502 que interactúan con esas direcciones y aplicar parches permanentes directamente en el binario ROM.

file: Contra (Japan).nes
md5: 0e40bc1b049c16c5d7246cc28399cb5d
sha1: 376836361f404c815d404e1d5903d5d11f4eff0e
cpu: 6502
mapper: 23 konami vrc2/vrc4 c  

nescartdb.com/profile/view/3986/contra

Para este proyecto utilizaremos unicamente, FCEUX y Radare2 y se puede realizar desde cualquier sistema operativo.

Este documento explica como se descubrieron y modificaron los valores que se muestran en la siguente tabla.

addrs offset Tipo descripción
0032 – RAM Vidas
00AA – RAM Arma
07:C2E9 1C2F9 ROM Asigna 2 vidas en ram
07:C2EF 1C2FF ROM Asigna 29 vidas en ram

Las vidas la he buscado utilizando el buscador de memoria incluido en FCEUX 2.6.6, posteriormente he agregado un breakpoint de escritura en la dirección 0x0032 para encontrar la logica que asigna las vidas.

Radare2 y NES

Quiero utilizar radare2 para parchear el juego, pero no tengo claro como realizar un cambio del banco de memoria y si realizo una busqueda en el binario no tendremos resultados si esta en otro banco. Lo mejor es trabajarlo en modo raw.

Abrimos el juego en modo raw.

r2 -a 6502 -w -n Contra\ \(Japan\).nes

Ahora vamos a buscar la siguiente instrucción 95 32 STA $32,X que luego de investigar en FCEUX sabemos que es la instrucción que utilizan para asignar la vida en la dirección 0x32.

deberia tener un resultado como el siguiente.

[0x00000000]> /x 95 32
0x0001c301 hit0_0 9532
0x0001c3b7 hit0_1 9532

disassembly la siguiente dirección, un poco antes de la que encontramos para ver donde se asigna el registro A que es el que se cargara en la dirección 0x32.

[0x00000000]> pd 10 @ 0x0001c2f0
            0x0001c2f0      03             slo (0xc3, x)
            0x0001c2f1      c3             dcp (0x85, x)
            0x0001c2f2      851d           sta 0x1d
            0x0001c2f4      bd05c3         lda 0xc305,x
            0x0001c2f7      8539           sta 0x39
            0x0001c2f9      a902           lda 0x02
            0x0001c2fb      a424           ldy 0x24
        ┌─< 0x0001c2fd      f002           beq 0x01c301
        │   0x0001c2ff      a91d           lda 0x1d
        │   ;-- hit0_0:
        └─> 0x0001c301      9532           sta 0x32,x

Aqui podemos ver donde se carga a memoria y donde se asigna el valor de las vidas a modificar, el codigo konami se valida con el beq, a nosotros nos interesa cambiar las vidas de 2 a 5 y lo realizamos con el siguente comando.

wx 05 @ 0x0001c2fa
Podemos salirnos con q en radare2 para probar nuestro cambio.

:: info
Si queremos saber la dirección que muestra radare desde FCEUX solo tenemos que colocar el cursor sobre la dirección de memoria y dira la dirección en el offset.

::

Buscando las armas por defecto

Para este proyecto decidí trabajar completamente offline, sin realizar búsquedas en internet. Como no conozco el identificador de cada arma en Contra, tuve que hacer la búsqueda manualmente, revisando los valores desde 00 hasta FF para localizar la dirección de memoria asociada al arma seleccionada y determinar su valor.

El método que me resultó más sencillo fue reemplazar una línea completa por el valor 00 e ir probando hasta identificar en qué línea se encontraba el arma, para luego localizar el byte exacto.

La siguente tabla muestra el valor correspondiente a la dirección 0xAA que corresponde al arma seleccionada.

Valor Letra Descripción
00 – Gun
01 M Machine Gun Falcon
02 F Fire Ball Falcon
03 S Spread Gun Falcon
04 L Laser Falcon

Para el Rapid Fire Falcon es un caso especial, es simplemente activar el bit de Rapid Fire, sumando 0x10 al valor base del arma seleccionada.

tabla resultante.

Valor Letra Descripción
10 – Gun
11 M Machine Gun Falcon
12 F Fire Ball Falcon
13 S Spread Gun Falcon
14 L Laser Falcon

Buscando logica para la asignación de arma al inicio de la partida

Como ya tenemos la dirección de memoria 0xAA podemos activar un breakpoint de escritura en FCEUX y ver que rutina escribe en esa dirección. Asignamos el breakpoint y realizamos un hard reset y le damos continuar al debugger hasta que presionemos el boton start para empezar el juego y ver donde se llama la logica que asigna el arma.

El primer resultado es el que buscamos y es el siguiente:

CPU -> A: 00 X: AA
07:C303     ORA ($07, X)
07:C305     ORA ($00, X)
07:C307     LDX #$28          ; Asigna X = 0x28
07:C309     LDA #$00          ; Asigna A = 0
-> 07:C30B  STA $00,X         ; RAM[X] = A
07:C30D     INX               ; Incrementa X en 1.
07:C30E     CPX #$F0          ; Compara X == 0xF0
07:C310     BNE $C30B         ; Si X no es 0xF0 salta a C30B

La logica anterior es un bucle que asigna a 0 todos los valores de la ram desde 0x28 hasta 0xf0 es decir que esta incluido la vida que es la dirección 0x32 y el arma que es la dirección 0xAA .

¿Que problemas tenemos ahora?

No podemos modificar esta subrutina porque es la encargada de reiniciar la memoria al iniciar el juego. Además, se aprovecha de que el valor del arma por defecto es 0, de manera que al inicio ya está asignada esa arma. Sin embargo, ocurre algo diferente con la vida: esta se asigna a 0, lo que significa que, después de ejecutar esta subrutina, se ejecuta otra que asigna el valor correcto de vida. Por eso, el juego no comienza con 0 vidas.

Esta subrutina ya nos resulta familiar, pues fue el primer cambio que realizamos al inicio de este documento. ¿Y si aprovechamos la asignación de vida, que se ejecuta después del reinicio de memoria, para asignar también el arma en la dirección 0xAA? Eso es precisamente lo que vamos a hacer.

veamos esta subrutina desde radare2.

[0x00000000]> pd 16 @ 0x0001c2f9
        ┌─> 0x0001c2f9      a905           lda 0x05
        ╎   0x0001c2fb      a424           ldy 0x24
       ┌──< 0x0001c2fd      f002           beq 0x01c301
       │╎   0x0001c2ff      a91d           lda 0x1d
       └──> 0x0001c301      9532           sta 0x32,x
        ╎   0x0001c303      ca             dex
        └─< 0x0001c304      10f3           bpl 0x01c2f9
            0x0001c306      a9c8           lda 0xc8
            0x0001c308      853c           sta 0x3c
            0x0001c30a      853e           sta 0x3e
            0x0001c30c      a900           lda 0x00
            0x0001c30e      853d           sta 0x3d
            0x0001c310      853f           sta 0x3f
            0x0001c312      60             rts

vemos solo los puntos importantes, para lo que queremos lograr, documento un poco el código

veamos esta subrutina desde radare2.

     0x0001c2f9     a905   lda 0x05     ; A = 5
     0x0001c2fb     a424   ldy 0x24
                                        ; Valida el codigo konami.
┌──< 0x0001c2fd     f002   beq 0x01c301          
│    0x0001c2ff     a91d   lda 0x1d     ; A = 29 (Konami Code)
└──> 0x0001c301     9532   sta 0x32,x   ; [0x32]Vidas = A

El código anterior asigna la cantidad de vidas según se utilice o no el código Konami. En nuestra modificación previa, si no se usa el código Konami se comienza con 5 vidas, y si se usa, se inicia con 29. Es importante recordar que en el juego la vida 0 cuenta, por lo que tener 5 vidas equivale realmente a tener 6.

En nuestro caso, no nos interesa conservar la lógica del código Konami; en su lugar, la sustituiremos para asignar el arma con la que deseamos que el juego inicie. Esto se puede lograr de la siguiente manera.

Iniciar el juego con el arma Machine Gun Falcon, su valor es 1.

0x0001c2f9     a901   lda 0x01     ; A = 1
0x0001c2fb     a424   ldy 0x24
0x0001c2fd     95aa   sta 0xaa,x   ; [0xff]Arma = 1      
0x0001c2ff     a905   lda 0x05     ; A = 5
0x0001c301     9532   sta 0x32,x   ; [0x32]Vidas = A = 5

Como asignamos los cambios a nuestro juego?

primero asignamos el codigo de la dirección 0x0001c2f9, que asigna A = 1

wx a901 @ 0x0001c2f9

segundo modificamos el salto para que ahora guarde el valor de A en la dirección de memoria del arma.

wx 95aa @ 0x0001c2fd

tercero, asignamos el valor de las vidas a A de nuevo en la dirección 0x0001c2ff

wx a905 @ 0x0001c2ff

para validar los cambios podemos utilizar el siguiente comando

[0x00000000]> pd 6 @ 0x0001c2f7
            0x0001c2f7      8539           sta 0x39
            0x0001c2f9      a901           lda 0x01
            0x0001c2fb      a424           ldy 0x24
            0x0001c2fd      95aa           sta 0xaa,x
            0x0001c2ff      a905           lda 0x05
            0x0001c301      9532           sta 0x32,x

Como conservar el arma luego de la muerte?

Con lo aplicado en los puntos anteriores esto deberia ser bastante sencillo, lo unico que tenemos que hacer para encontrar la logica encargada de asignar el arma tras nuestra muerte es agregar un breakpoint de escritura en la dirección 0xAA desde el emulador ya que ahi es donde se registra el arma asignada, posteriormente nos dejamos matar y vemos la dirección que aparece en el debugger.

deberiamos ver lo siguiente.

07:DA07   LDA A, #$00     ; A = 0
07:DA09   STA $AA, X      ; [0xaa]Arma = 0 = Gun

Esta es la logica que asigna el arma cuando nos matan, la documentación de la derecha es agregada por mi como siempre, no es visible en el emulador, para saber cual es la dirección que tenemos que modificar en radare, ya que el emulador nos muestra que esta en el banco 7 y la dirección 0xda07, solo posicionamos el cursor sobre la dirección que queremos y anotamos el offset.

ahora vamos a radare.

[0x00000000]> pd 2 @ 0x1da17
            0x0001da17      a900           lda 0x00
            0x0001da19      95aa           sta 0xaa,x

podemos ver que en la dirección 0x1da17 que encontramos con el emulador, tenemos el codigo a modificar, solo tenemos que cambier el valor que se asigna, con el valor que nosotros deseamos de la siguiente manera.

Asignar el Laser Falcon cuando nos maten, su valor es 4, podemos escribirlo con el siguiente comando.

wx a904 @ 0x0001da17

ya sabemos como validarlo, de igual manera lo explico de nuevo, simplemente vamos a ver el código de la subrutina de nuevo.

[0x00000000]> pd 2 @ 0x1da17
            0x0001da17      a904           lda 0x04
            0x0001da19      95aa           sta 0xaa,x

Quieres intentarlo?

Pues te dejo algunas tareas para practicar lo aprendido.

  1. Asigna vidas infinitas.
  2. Asigna que todos los power up asignen el mismo arma.
  3. Haz que tu jugador nunca muera aunque reciba disparos.