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)

Leave a Reply

Your email address will not be published. Required fields are marked *

Are you human? Please solve:Captcha