Introdução
Tibia é um dos MMORPGs mais antigos ainda online e em desenvolvimento ativo. Criado em 1997 por estudantes de uma universidade alemã – que mais tarde fundaram a CipSoft , eventualmente se tornou muito famoso, especialmente no Brasil.
Algum tempo atrás, acabei esbarrando em uma cópia do cliente Alfa (versão 1.03), que, até onde eu sei, foi a primeira versão liberada publicamente. Você pode baixá-lo aqui .
Infelizmente, porém, não há nenhum servidor (oficial ou emulador) disponível para essa versão do jogo. O cliente foi escrito em C++ (compilado com Borland C++ 4.5 para sistemas Windows de 16 bits, mais especificamente Windows 3.1 e 95, mas é possível executá-lo até no Windows 2000.
Como gosto de velharias e de quebra-cabeças de programação, resolvi explorar o cliente para entender como ele funciona e, quem sabe um dia, escrever um emulador de servidor.
Como precisava começar por algum lugar, escolhi entender como os sprites são salvos e lidos nessa versão. Ao longo deste post, busco detalhar um pouco do meu entendimento obtido por meio de engenharia reversa .
Para isso, usei algumas ferramentas, em especial:
- Máquina Virtual rodando Windows 2000
- IDA Freeware Version 5.0
- Python (para escrever scripts de teste)
Infelizmente, como o executável é compilado para sistemas de 16 bits (NE), temos que lidar com far e near pointers . Ferramentas mais modernas, como o Ghidra (ou até mesmo versões mais novas do IDA), não têm suporte completo ou simplesmente não oferecem suporte algum.
MUDOBJ.SPR
Esse é o arquivo que contém os sprites do jogo. Sua estrutura é a seguinte:

Cabeçalho (Header)
- Data Block Count (2 bytes): define a quantidade total de blocos de dados (data blocks) que vêm a seguir.
Bloco de Dados (Data Block)
- Sprite ID (2 bytes): identificador único do sprite.
- Block Size (2 bytes): tamanho total dos dados do sprite em bytes. Inclui os dois bytes do próprio campo de tamanho, mas não inclui o tamanho do Sprite ID.
- Raw Pixel Data (Block Size - 2 bytes): o payload contendo os dados dos pixels de forma comprimida.
Compressão dos Pixels
Para compressão, é utilizada uma versão customizada de Run-Length Encoding . A lógica de descompressão interpreta o fluxo de bytes como sequências alternadas de blocos Transparentes e Coloridos, até que todo o sprite seja processado.
A lógica de descompressão segue o seguinte padrão:
- Bloco Transparente:
- Lê: 2 bytes.
- Calcula:
transparentBytes = value % 0x5A0pixelsToSkip = transparentBytes / 3
- Ação:
- Avança a posição atual em
pixelsToSkip. Esses pixels permanecem transparentes (alpha = 0).
- Avança a posição atual em
- Bloco Colorido:
- Lê: 2 bytes (representando a quantidade de bytes de pixels coloridos).
- Calcula:
coloredPixels = value / 3
- Ação:
- Itera
coloredPixelsvezes. - Para cada iteração, lê 3 bytes.
- Formato do Pixel: BGR (Blue, Green, Red), 1 byte por canal.
- Escreve o pixel opaco (alpha = 255) na posição atual. Nota: os dados dos sprites são armazenados da esquerda para a direita, de baixo para cima (começam na esquerda inferior, terminam no topo direito).
- Itera
Script de extração de sprites
Para executar o script de extração abaixo, você precisará do Python instalado e da biblioteca Pillow para a manipulação de imagens.
Instale a biblioteca Pillow:
pip install PillowExecute o script: Certifique-se de que o arquivo
MUDOBJ.SPResteja no mesmo diretório do script (ou passe o caminho do arquivo como argumento) e execute:python extract_sprites.py(Caso queira passar um arquivo específico, execute
python extract_sprites.py caminho/para/arquivo.SPR).
Os sprites extraídos serão salvos automaticamente em uma pasta chamada output/ no mesmo diretório.
A seguir, o código completo do script:
import struct
import os
import sys
from PIL import Image
def extract_sprites(filename, output_dir="output"):
# 1. Cria uma pasta de saída caso ainda não exista
if not os.path.exists(output_dir):
os.makedirs(output_dir)
print(f"Diretório criado: {output_dir}")
try:
with open(filename, "rb") as f:
print(f"Processando {filename}...")
# 2. Lê a quantidade de sprites (uint16)
header_data = f.read(2)
if len(header_data) < 2:
print("Erro: Arquivo muito pequeno.")
return
sprite_count = struct.unpack("<H", header_data)[0]
print(f"{sprite_count} sprites encontrados. Extraindo...")
sprites_extracted = 0
# 3. Itera por todos os sprites
for _ in range(sprite_count):
# Lê o ID (uint16) e o Tamanho (uint16)
header = f.read(4)
if len(header) < 4:
break
sprite_id, sprite_size = struct.unpack("<HH", header)
# O campo 'tamanho' inclui os 2 bytes do campo tamanho em si,
# então o tamanho real dos dados do payload é size - 2.
payload_size = sprite_size - 2
if payload_size <= 0:
# Sprite vazio ou inválido - deve ser pulado.
continue
# Lê o payload RLE completo para o sprite.
sprite_data = f.read(payload_size)
if len(sprite_data) != payload_size:
print(f"Atenção: Fim de arquivo inesperado para o sprite {sprite_id}")
break
# 4. Descomprime os dados RLE
# Cria uma imagem 32x32 transparente
img = Image.new("RGBA", (32, 32), (0, 0, 0, 0))
pixels = img.load()
read_offset = 0
current_pixel = 0
while read_offset < payload_size and current_pixel < 1024:
# A. Bloco Transparente
# Precisamos de pelo menos 2 bytes para a contagem de pixels transparentes
if read_offset + 2 > payload_size:
break
trans_raw = struct.unpack_from("<H", sprite_data, read_offset)[0]
read_offset += 2
# Aplica lógica de módulo
trans_val = trans_raw % 0x5A0
# Converte a contagem de bytes para pixels
skip_pixels = trans_val // 3
current_pixel += skip_pixels
# B. Bloco colorido
# Precisamos de pelo menos 2 bytes para a contagem de pixels coloridos
if read_offset + 2 > payload_size:
break
colored_bytes = struct.unpack_from("<H", sprite_data, read_offset)[0]
read_offset += 2
# Lê os bytes de pixels coloridos (sequência BGR)
if read_offset + colored_bytes > payload_size:
break
# Extrai o bloco de pixels coloridos
color_chunk = sprite_data[read_offset: read_offset + colored_bytes]
read_offset += colored_bytes
# Processa o bloco em grupos de 3 bytes (BGR)
for i in range(0, len(color_chunk), 3):
# Garante que temos o trio completo
if i + 2 < len(color_chunk):
b = color_chunk[i]
g = color_chunk[i + 1]
r = color_chunk[i + 2]
if current_pixel < 1024:
# Calcula X, Y
x = current_pixel % 32
y = current_pixel // 32
# Inverte o eixo Y
flipped_y = 31 - y
# Escreve o pixel
pixels[x, flipped_y] = (r, g, b, 255)
current_pixel += 1
# 5. Salva o sprite.
output_path = os.path.join(output_dir, f"{sprite_id}.png")
img.save(output_path)
sprites_extracted += 1
print(f"Pronto! {sprites_extracted} extraídos para '{output_dir}/'.")
except FileNotFoundError:
print(f"Erro: não foi possível encontrar o arquivo '{filename}'")
except Exception as e:
print(f"Um erro inesperado ocorreu: {e}")
if __name__ == "__main__":
# Caso necessário, altere o nome do arquivo aqui, ou passe como argumento
file_to_process = "MUDOBJ.SPR"
if len(sys.argv) > 1:
file_to_process = sys.argv[1]
extract_sprites(file_to_process)