Tibia 1.03 (Alpha) Primer
Introduction
Tibia is one of the oldest MMORPGs still online and being developed. Created in 1997 by students at a German university (who later founded CipSoft), it went on to achieve great success, especially in Brazil.
A few days ago, I stumbled upon an old copy of the Alpha client, version 1.03. As far as I know, this was the first version released to the general public.
Currently, there is no server available for this version. The client is written in C++ (Borland C++ 4.50), targeting 16-bit Windows (Windows 3.1 and 95, though it can run on versions up to 2000).
Well, I’m currently on vacation and don’t have much to do. Why not start another project that will, hopefully, not end up in the “unfinished projects” drawer?
Sprite File (MUDOBJ.SPR)
File Header
- Sprite Count (2 bytes): defines the total number of sprite chunks that follow.
Data Chunk
- Sprite ID (2 bytes): unique identifier for the sprite.
- Chunk Size (2 bytes): total size of the sprite data in bytes. This includes the 2 bytes for the size itself, but excludes the Sprite ID.
- Raw Pixel Data (Chunk Size - 2 bytes): the payload containing the compressed pixel information.
Pixel Data Compression
The raw pixel data uses a custom Run-Length Encoding scheme. The decompression logic interprets the byte stream as alternating sequences of Transparent and Color blocks until the entire sprite is processed.
The decompression loop follows this pattern:
- Transparent Block:
- Read: 2 bytes.
- Calculation:
transparentBytes = value % 0x5A0pixelsToSkip = transparentBytes / 3
- Action:
- Advance the current pixel cursor by
pixelsToSkip. These pixels remain transparent (alpha = 0).
- Advance the current pixel cursor by
- Colored Block:
- Read: 2 bytes (representing the number of colored pixel bytes).
- Calculation:
coloredPixels = value / 3
- Action:
- Iterate
coloredPixelstimes. - For each iteration, read 3 bytes.
- Pixel Format: BGR (Blue, Green, Red), 1 byte per channel.
- Write the opaque pixel (alpha = 255) to the current cursor position. Note: the sprite data stores pixels in a left-to-right, bottom-to-top order (start at bottom-left, end at top-right).
- Iterate
Sprite Extraction Script
import struct
import os
import sys
from PIL import Image
def extract_sprites(filename, output_dir="output"):
# 1. Create output directory if it doesn't exist
if not os.path.exists(output_dir):
os.makedirs(output_dir)
print(f"Created directory: {output_dir}")
try:
with open(filename, "rb") as f:
print(f"Processing {filename}...")
# 2. Read Sprite Count (uint16)
header_data = f.read(2)
if len(header_data) < 2:
print("Error: File is too short.")
return
sprite_count = struct.unpack("<H", header_data)[0]
print(f"Found {sprite_count} sprites. Extracting...")
sprites_extracted = 0
# 3. Iterate through all sprites
for _ in range(sprite_count):
# Read ID (uint16) and Size (uint16)
header = f.read(4)
if len(header) < 4:
break
sprite_id, sprite_size = struct.unpack("<HH", header)
# The 'size' field includes the 2 bytes of the size itself.
# So the actual pixel data payload is size - 2.
payload_size = sprite_size - 2
if payload_size <= 0:
# Empty or invalid sprite, skip logic
continue
# Read the full RLE payload for this sprite
sprite_data = f.read(payload_size)
if len(sprite_data) != payload_size:
print(f"Warning: Unexpected end of file at sprite {sprite_id}")
break
# 4. Decompress RLE Data
# Create a blank 32x32 transparent image
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. Transparent Block
# We need at least 2 bytes for the transparent count
if read_offset + 2 > payload_size:
break
trans_raw = struct.unpack_from("<H", sprite_data, read_offset)[0]
read_offset += 2
# Apply the modulo logic (Legacy Tibia format requirement)
trans_val = trans_raw % 0x5A0
# Convert byte count to pixel count
skip_pixels = trans_val // 3
current_pixel += skip_pixels
# B. Colored Block
# We need at least 2 bytes for the colored count
if read_offset + 2 > payload_size:
break
colored_bytes = struct.unpack_from("<H", sprite_data, read_offset)[0]
read_offset += 2
# Read the color bytes (B, G, R sequence)
if read_offset + colored_bytes > payload_size:
break
# Extract the chunk of color data
color_chunk = sprite_data[read_offset: read_offset + colored_bytes]
read_offset += colored_bytes
# Process the chunk in groups of 3 bytes (B, G, R)
for i in range(0, len(color_chunk), 3):
# Ensure we have a full triplet
if i + 2 < len(color_chunk):
b = color_chunk[i]
g = color_chunk[i + 1]
r = color_chunk[i + 2]
if current_pixel < 1024:
# Calculate X, Y
x = current_pixel % 32
y = current_pixel // 32
# Flip Y-axis
flipped_y = 31 - y
# Write Pixel
pixels[x, flipped_y] = (r, g, b, 255)
current_pixel += 1
# 5. Save Sprite
output_path = os.path.join(output_dir, f"{sprite_id}.png")
img.save(output_path)
sprites_extracted += 1
print(f"Done! Extracted {sprites_extracted} sprites to '{output_dir}/'.")
except FileNotFoundError:
print(f"Error: Could not find file '{filename}'")
except Exception as e:
print(f"An error occurred: {e}")
if __name__ == "__main__":
# You can change the filename here or pass it as an argument
file_to_process = "MUDOBJ.SPR"
if len(sys.argv) > 1:
file_to_process = sys.argv[1]
extract_sprites(file_to_process)