Contents

Tibia 1.03 (Alpha) Primer

WIP!
This article is still a work in progress - stay tuned for more updates.

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:

  1. Transparent Block:
    • Read: 2 bytes.
    • Calculation:
      • transparentBytes = value % 0x5A0
      • pixelsToSkip = transparentBytes / 3
    • Action:
      • Advance the current pixel cursor by pixelsToSkip. These pixels remain transparent (alpha = 0).
  2. Colored Block:
    • Read: 2 bytes (representing the number of colored pixel bytes).
    • Calculation:
      • coloredPixels = value / 3
    • Action:
      • Iterate coloredPixels times.
      • 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).

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)