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 es0x0FEE)
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)