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í.