Jump to content

101st Airborne in Normandy '98 .gpk unpack decompress


Go to solution Solved by MrIkso,

Recommended Posts

Posted

Hello everyone, I need some help extracting/unpacking this .gpk file. It belongs to the game mentioned in the title and is quite old. I haven’t been able to unpack it, even with the help of AI.

Here is the latest summary from Claude, and I’ve attached the QuickBMS script it generated—but the extracted images are corrupted.

Thanks in advance, I really appreciate any help!

+Q+
 

File Structure

Offset 0x00: DWORD = 15 (number of entries in the table)

Offset 0x04: DWORD = 0 (unknown / padding)

Offset 0x08: DWORD[15] → absolute offsets to cursors 1–15
(cursor 0 is implicit, located right after the header at 0x44)

From 0x44 onward: 16 cursor data blocks

Each block contains encoded cursor data. Cursor 0 includes a sub-header with frame count, hotspot, and per-frame offsets. Cursors 1–15 start directly with encoded image data.

The 15 cursors (1–15) share a 68-byte identical prefix, which appears to be a shared palette (contains recognizable RGB values: ff 00 00 = red, ff ff 00 = yellow, etc.).

QuickBMS Script

Here is a script to extract the 16 blocks from the file:

# QuickBMS script for Cursors.gpk - 101st Airborne in Normandy
# Interactive Simulations
# Extracts the 16 cursor data blocks as separate binary files

get FILESIZE asize

# Read global header
get COUNT long # number of offsets in table (= 15)
get UNK long # unknown (= 0)

# Read offset table (15 entries)
for i = 0 < COUNT
get OFFSET_TABLE long
next i

# --- Extract cursor 0 (implicit, right after header) ---
# Header size = 8 + COUNT*4 bytes = 68 bytes (0x44)
math ENTRY_START = 0x44
get ENTRY_END = OFFSET_TABLE[0]
math ENTRY_SIZE = ENTRY_END - ENTRY_START

savepos POS
goto ENTRY_START
log "cursor_00.bin" ENTRY_START ENTRY_SIZE
goto POS

# --- Extract cursors 1 to COUNT-1 ---
for i = 0 < COUNT
get CURR_OFF = OFFSET_TABLE

if i < COUNT - 1
get NEXT_OFF = OFFSET_TABLE[i + 1]
else
get NEXT_OFF = FILESIZE
endif

math BLK_SIZE = NEXT_OFF - CURR_OFF

string NAME p= "cursor_%02d.bin" i + 1
log NAME CURR_OFF BLK_SIZE
next i

Usage

quickbms cursors_gpk.bms Cursors.gpk output_folder/

This will extract 16 files: cursor_00.bin to cursor_15.bin.

Important Notes

The internal format of each block is proprietary and compressed/encoded. The data is not standard BMP/CUR and cannot be used directly.

Cursor 0 contains 7 animation frames (32×32 pixels) with internal offsets.

Cursors 1–15 contain 1 frame each and share a fixed color palette (first 68 bytes).

Converting them into viewable images requires an additional decoding step of the proprietary pixel format used by Interactive Simulations, which appears to combine RLE with engine-specific opcodes.

Reverse Engineering Progress

I attempted to decode the pixel format to visualize the cursors.

Complete File Structure (Refined)

Offset 0x00: DWORD = 15 → number of groups (excluding implicit group 0)

Offset 0x04: DWORD = 0 → padding

Offset 0x08: DWORD[15] → absolute offsets to groups

Each cursor group contains:

68 bytes → shared palette (22 RGB colors = 66 bytes + 2 terminator bytes)

4 bytes → n_frames

4 bytes → hotspot_x (always 0)

4 bytes → hotspot_y (always 0)

4 bytes → width = 32

4 bytes → height = 32

4 bytes → reserved = 0

n_frames * 4 bytes → frame offsets (relative to group start)

0x2000 = sentinel ("no frame data")

RLE frame data

Cursor Groups Identified

Group 0 → 7 frames (animated, no palette prefix)

Groups 1 → 7 frames (animated)

Groups 2, 6, 8, 11–14 → 1 frame (static, palette-only)

Group 3 → 26 frames (long animation)

Groups 4, 7, 9, 10 → 8 frames (animated)

Group 5 → 20 frames (complex animation)

Pixel Encoding Format (Proprietary RLE)

Each frame appears to use 3-byte triplets:

[skip] [count]

skip = number of transparent pixels

count = number of pixels to draw

color = palette index

Special cases:

count = 0 → end of row

0x20 alone → fully transparent row

Palette

22-color palette stored in the first 68 bytes

Includes military greens, earth tones, and UI colors (red, yellow, cyan, blue)

Consistent with a WWII strategy game aesthetic

Final QuickBMS Script

# QuickBMS script for Cursors.gpk
# 101st Airborne in Normandy - Interactive Simulations
# Extracts the 15 cursor groups

get FILESIZE asize

get COUNT long # = 15
get UNK long # = 0

for i = 0 < COUNT
get OFF long
next i

# Group 0 (implicit)
math HEADER_SIZE = 8
math HEADER_SIZE + COUNT * 4 # = 68

math START_0 = HEADER_SIZE
math END_0 = OFF[0]
math SIZE_0 = END_0 - START_0

log "cursor_group_00.bin" START_0 SIZE_0

# Groups 1+
for i = 0 < COUNT
math CURR = OFF

if i < COUNT - 1
math NEXT = OFF[i + 1]
else
math NEXT = FILESIZE
endif

math BLK_SIZE = NEXT - CURR

string NAME p= "cursor_group_%02d.bin" i + 1
log NAME CURR BLK_SIZE
next i

Decoder Attempt & Results

I created a Python decoder (decode_cursors.py) to convert the .gpk into PNGs and animated GIFs (requires Pillow).

However, the images do not render correctly — they appear corrupted.

Root Cause

The RLE format is more complex than initially assumed:

Values like 197 (0xC5) and 218 (0xDA) are not counts, but color indices

Only small values (0–14) behave as actual pixel counts

The format is context-dependent, not fixed triplets

This indicates a variable encoding scheme, likely tied to engine-specific opcodes.

PREVIEW.PNG.4847728af8ff84c15beb76ed897b3767.PNG

+UQ+

Cursors.rar

  • Members
Posted
import struct
import os
import sys
from PIL import Image

def parse_gpk(file_path):
    if not os.path.exists(file_path):
        print(f"Error: File '{file_path}' not found.")
        return

    with open(file_path, "rb") as f:
        raw_data = f.read()

    # read the main GPK Archive Header
    # u32 count (number of images in the archive)
    img_count = struct.unpack('<I', raw_data[0:4])[0]
    print(f"File: {file_path}")
    print(f"Images found: {img_count}")

    # read the global offset table
    # u32 offsets[img_count] - absolute offsets to each ImageData block
    global_offsets = []
    for i in range(img_count):
        off = struct.unpack('<I', raw_data[4 + i * 4: 8 + i * 4])[0]
        global_offsets.append(off)

    # count (4 bytes) + offsets array (img_count * 4) + dataSize field (4 bytes)
    archive_header_size = 4 + (img_count * 4) + 4

    output_dir = "extracted_images"
    if not os.path.exists(output_dir):
        os.makedirs(output_dir)

    for idx, base_offset in enumerate(global_offsets):
        # calculate start position of the current image data block
        img_start_ptr = base_offset + archive_header_size

        if idx + 1 < img_count:
            img_end_ptr = global_offsets[idx + 1] + archive_header_size
        else:
            img_end_ptr = len(raw_data)

        img_data = raw_data[img_start_ptr: img_end_ptr]
        if len(img_data) < 20: 
            continue

        # read ImageData Header (20 bytes total)
        # u32 line_count (?)
        # u32 unk1, unk2
        # u32 width, height
        line_count = struct.unpack('<I', img_data[0:4])[0]
        width = struct.unpack('<I', img_data[12:16])[0]
        height = struct.unpack('<I', img_data[16:20])[0]

        dynamic_offset = 20 + (line_count * 4)
        bytes_offset = dynamic_offset

        print(f" -> img_{idx:03d}: {width}x{height}, RLE offset: {hex(bytes_offset)}")

        # extraction of Palette, last 768 bytes of the image block
        # 256 colors * 3 bytes (RGB)
        pal_raw = img_data[-768:]
        palette = []
        for i in range(0, 768, 3):
            # PIL expects a flat list: [R, G, B, R, G, B...]
            palette.extend([pal_raw[i], pal_raw[i + 1], pal_raw[i + 2]])

        pixel_data = []
        try:
            for y in range(height):
                row_pixels = 0
                while row_pixels < width:
                    if bytes_offset >= len(img_data) - 768:
                        break

                    v5 = img_data[bytes_offset]
                    bytes_offset += 1

                    if v5 != 0:
                        count = v5
                        chunk = img_data[bytes_offset: bytes_offset + count]
                        pixel_data.extend(chunk)
                        bytes_offset += count
                        row_pixels += count
                    else:
                        if bytes_offset + 1 < len(img_data):
                            count = img_data[bytes_offset]
                            color = img_data[bytes_offset + 1]
                            bytes_offset += 2
                            pixel_data.extend([color] * count)
                            row_pixels += count
                        else:
                            break
        except Exception as e:
            print(f"    Decoding error on img_{idx}: {e}")

        needed = width * height
        final_pixels = pixel_data[:needed]
        if len(final_pixels) < needed:
            final_pixels.extend([0] * (needed - len(final_pixels)))
            
        img = Image.new('P', (width, height))
        if len(palette) == 768:
            img.putpalette(palette)
        img.putdata(final_pixels)

        save_path = os.path.join(output_dir, f"img_{idx:03d}.png")
        img.save(save_path)

    print(f"\nDone! All images saved to '{output_dir}'.")


if __name__ == "__main__":
    if len(sys.argv) > 1:
        file_to_open = sys.argv[1]
    else:
        file_to_open = input("Enter path to .gpk file: ").strip().strip('"')

    parse_gpk(file_to_open)

Hi, use this code for unpack this .gpk archive with images

Posted

Hi @MrIkso 

Thank you very much!!!!! It worked perfectly, except for the color palette—but with a bit of help from AI, I was able to fix the script and now the colors are correct!

However, I noticed that it only exports a single image. Some cursors—like the clock img_007—are animated, meaning they have multiple frames (so multiple images). Would it be possible to add support for that in the script so they can be extracted as they actually are?

I look forward to your reply. Thanks again!

Code fix for color pallete

import struct
import os
import sys
from PIL import Image

def parse_gpk(file_path):
    if not os.path.exists(file_path):
        print(f"Error: File '{file_path}' not found.")
        return

    with open(file_path, "rb") as f:
        raw_data = f.read()

    img_count = struct.unpack('<I', raw_data[0:4])[0]
    print(f"File: {file_path}")
    print(f"Images found: {img_count}")

    global_offsets = []
    for i in range(img_count):
        off = struct.unpack('<I', raw_data[4 + i * 4: 8 + i * 4])[0]
        global_offsets.append(off)

    archive_header_size = 4 + (img_count * 4) + 4

    output_dir = "extracted_images"
    if not os.path.exists(output_dir):
        os.makedirs(output_dir)

    for idx, base_offset in enumerate(global_offsets):
        img_start_ptr = base_offset + archive_header_size

        if idx + 1 < img_count:
            img_end_ptr = global_offsets[idx + 1] + archive_header_size
        else:
            img_end_ptr = len(raw_data)

        img_data = raw_data[img_start_ptr: img_end_ptr]
        if len(img_data) < 20:
            continue

        line_count = struct.unpack('<I', img_data[0:4])[0]
        width = struct.unpack('<I', img_data[12:16])[0]
        height = struct.unpack('<I', img_data[16:20])[0]

        dynamic_offset = 20 + (line_count * 4)
        bytes_offset = dynamic_offset

        print(f" -> img_{idx:03d}: {width}x{height}, RLE offset: {hex(bytes_offset)}")

        # === FIX DE PALETA ===
        pal_raw = img_data[-768:]
        palette = []

        for i in range(0, 768, 3):
            # leer como BGR (formato común en juegos viejos)
            b = pal_raw
            g = pal_raw[i + 1]
            r = pal_raw[i + 2]

            # detectar si está en 6-bit (0–63)
            if max(b, g, r) <= 63:
                b = int(b * 255 / 63)
                g = int(g * 255 / 63)
                r = int(r * 255 / 63)

            palette.extend([r, g, b])
        # =====================

        pixel_data = []
        try:
            for y in range(height):
                row_pixels = 0
                while row_pixels < width:
                    if bytes_offset >= len(img_data) - 768:
                        break

                    v5 = img_data[bytes_offset]
                    bytes_offset += 1

                    if v5 != 0:
                        count = v5
                        chunk = img_data[bytes_offset: bytes_offset + count]
                        pixel_data.extend(chunk)
                        bytes_offset += count
                        row_pixels += count
                    else:
                        if bytes_offset + 1 < len(img_data):
                            count = img_data[bytes_offset]
                            color = img_data[bytes_offset + 1]
                            bytes_offset += 2
                            pixel_data.extend( * count)
                            row_pixels += count
                        else:
                            break
        except Exception as e:
            print(f"    Decoding error on img_{idx}: {e}")

        needed = width * height
        final_pixels = pixel_data[:needed]
        if len(final_pixels) < needed:
            final_pixels.extend([0] * (needed - len(final_pixels)))

        img = Image.new('P', (width, height))
        if len(palette) == 768:
            img.putpalette(palette)
        img.putdata(final_pixels)

        save_path = os.path.join(output_dir, f"img_{idx:03d}.png")
        img.save(save_path)

    print(f"\nDone! All images saved to '{output_dir}'.")


if __name__ == "__main__":
    if len(sys.argv) > 1:
        file_to_open = sys.argv[1]
    else:
        file_to_open = input("Enter path to .gpk file: ").strip().strip('"')

    parse_gpk(file_to_open)

imagen_2026-03-27_215801184.png

  • Members
Posted

@UZ.- you mean to generate a .gif from these images. I was just curious to see what RLE algorithm was used here. Anyway you can merge these images into one, Pillow (PIL) library can do that

Posted

@MrIkso Nope, what I mean is that the script only extracted one image per cursor in this case, but from my own gameplay I know there are several. For example, the clock cursor is animated in-game, so I’m sure it has multiple frames, and here I only got one :c

  • Confused 1
  • Members
  • Solution
Posted (edited)
Spoiler
import struct
import os
import sys
from PIL import Image


def decode_rle(data: bytes, expected_pixels: int) -> list[int]:
    pixels = []
    pos = 0
    limit = len(data)

    while pos < limit and len(pixels) < expected_pixels:
        v5 = data[pos]
        pos += 1

        if v5 != 0:
            # literal run
            chunk = data[pos: pos + v5]
            pixels.extend(chunk)
            pos += v5
        else:
            # packed run
            if pos + 1 >= limit:
                break
            count = data[pos]
            color = data[pos + 1]
            pos += 2
            pixels.extend([color] * count)

    return pixels


def parse_palette(palette_bytes: bytes) -> list[int]:
    palette = []
    values = list(palette_bytes[:768])

    is_6bit = max(values) <= 63

    for i in range(256):
        b, g, r = values[i * 3], values[i * 3 + 1], values[i * 3 + 2]
        if is_6bit:
            b = int(b * 255 / 63)
            g = int(g * 255 / 63)
            r = int(r * 255 / 63)
        palette.extend([r, g, b])

    return palette


def save_frame(pixels: list[int], width: int, height: int,
               palette: list[int], path: str):
    needed = width * height
    frame = pixels[:needed]
    if len(frame) < needed:
        frame.extend([0] * (needed - len(frame)))

    img = Image.new('P', (width, height))
    if len(palette) == 768:
        img.putpalette(palette)
    img.putdata(frame)
    img.save(path)


def parse_gpk(file_path: str):
    if not os.path.exists(file_path):
        print(f"Error: File '{file_path}' not found.")
        return

    with open(file_path, "rb") as f:
        raw_data = f.read()

    # u32 imageCount
    # u32 offsets[imageCount]
    # u32 allDataSize
    image_count = struct.unpack_from('<I', raw_data, 0)[0]
    print(f"File : {file_path}")
    print(f"Images: {image_count}")

    global_offsets = [
        struct.unpack_from('<I', raw_data, 4 + i * 4)[0]
        for i in range(image_count)
    ]

    archive_header_size = 4 + image_count * 4 + 4   # count + offsets[] + allDataSize

    output_dir = os.path.splitext(os.path.basename(file_path))[0] + "_extracted"
    os.makedirs(output_dir, exist_ok=True)

    for idx, base_offset in enumerate(global_offsets):
        img_start = base_offset + archive_header_size
        img_end   = (global_offsets[idx + 1] + archive_header_size
                     if idx + 1 < image_count
                     else len(raw_data))

        img_data = raw_data[img_start:img_end]

        if len(img_data) < 20 + 768:
            print(f"  img_{idx:03d}: too small, skip")
            continue

        frame_count = struct.unpack_from('<I', img_data,  0)[0]
        unk1        = struct.unpack_from('<I', img_data,  4)[0]
        unk2        = struct.unpack_from('<I', img_data,  8)[0]
        width       = struct.unpack_from('<I', img_data, 12)[0]
        height      = struct.unpack_from('<I', img_data, 16)[0]

        if width == 0 or height == 0 or frame_count == 0:
            print(f"  img_{idx:03d}: invalid dimensions {width}x{height} frames={frame_count}, skip")
            continue

        if 20 + frame_count * 4 > len(img_data):
            print(f"  img_{idx:03d}: offset table out of bounds, skip")
            continue

        frame_offsets = [
            struct.unpack_from('<I', img_data, 20 + i * 4)[0]
            for i in range(frame_count)
        ]

        print(f"  img_{idx:03d}: {width}x{height}  frames={frame_count}"
              f"  unk=({unk1},{unk2})  offsets={frame_offsets[:4]}{'...' if frame_count>4 else ''}")

        palette = parse_palette(img_data[-768:])

        for f_idx in range(frame_count):
            rle_start = frame_offsets[f_idx] + 20 + frame_count * 4
            if f_idx + 1 < frame_count:
                rle_end = frame_offsets[f_idx + 1] + 20 + frame_count * 4
            else:
                rle_end = len(img_data) - 768

            if rle_start >= rle_end or rle_start >= len(img_data):
                print(f"    frame_{f_idx:02d}: bad offset range [{rle_start}:{rle_end}], skip")
                continue

            rle_chunk = img_data[rle_start:rle_end]
            pixels    = decode_rle(rle_chunk, width * height)

            if frame_count == 1:
                fname = f"img_{idx:03d}.png"
            else:
                fname = f"img_{idx:03d}_frame_{f_idx:02d}.png"

            save_path = os.path.join(output_dir, fname)
            save_frame(pixels, width, height, palette, save_path)

    print(f"\nDone! Saved to '{output_dir}/'")


if __name__ == "__main__":
    if len(sys.argv) > 1:
        target = sys.argv[1]
    else:
        target = input("Enter path to .gpk file: ").strip().strip('"')

    parse_gpk(target)

 

@UZ.-im updated unpacker, now it extract all images and their anim frames
or this variant, generates animation to .gif

Spoiler

 

import struct
import os
import sys
from PIL import Image


def decode_rle(data: bytes, expected_pixels: int) -> list[int]:
    pixels = []
    pos = 0
    limit = len(data)

    while pos < limit and len(pixels) < expected_pixels:
        v5 = data[pos]
        pos += 1

        if v5 != 0:
            # literal run
            chunk = data[pos: pos + v5]
            pixels.extend(chunk)
            pos += v5
        else:
            # packed run
            if pos + 1 >= limit:
                break
            count = data[pos]
            color = data[pos + 1]
            pos += 2
            pixels.extend([color] * count)

    return pixels


def parse_palette(palette_bytes: bytes) -> list[int]:
    palette = []
    values = list(palette_bytes[:768])

    is_6bit = max(values) <= 63

    for i in range(256):
        b, g, r = values[i * 3], values[i * 3 + 1], values[i * 3 + 2]
        if is_6bit:
            b = int(b * 255 / 63)
            g = int(g * 255 / 63)
            r = int(r * 255 / 63)
        palette.extend([r, g, b])
    return palette


def create_frame_image(pixels: list[int], width: int, height: int, palette: list[int]) -> Image.Image:
    needed = width * height
    frame_data = pixels[:needed]
    if len(frame_data) < needed:
        frame_data.extend([0] * (needed - len(frame_data)))

    img = Image.new('P', (width, height))
    if len(palette) == 768:
        img.putpalette(palette)
    img.putdata(frame_data)
    return img


def parse_gpk(file_path: str):
    if not os.path.exists(file_path):
        print(f"Error: File '{file_path}' not found.")
        return

    with open(file_path, "rb") as f:
        raw_data = f.read()

    image_count = struct.unpack_from('<I', raw_data, 0)[0]
    print(f"File : {file_path}")
    print(f"Images in archive: {image_count}")

    global_offsets = [
        struct.unpack_from('<I', raw_data, 4 + i * 4)[0]
        for i in range(image_count)
    ]

    archive_header_size = 4 + (image_count * 4) + 4

    output_dir = os.path.splitext(os.path.basename(file_path))[0] + "_extracted"
    os.makedirs(output_dir, exist_ok=True)

    for idx, base_offset in enumerate(global_offsets):
        img_start = base_offset + archive_header_size
        if idx + 1 < image_count:
            img_end = global_offsets[idx + 1] + archive_header_size
        else:
            img_end = len(raw_data)

        img_data = raw_data[img_start:img_end]

        if len(img_data) < 20 + 768:
            continue

        frame_count = struct.unpack_from('<I', img_data, 0)[0]
        width = struct.unpack_from('<I', img_data, 12)[0]
        height = struct.unpack_from('<I', img_data, 16)[0]

        if width == 0 or height == 0 or frame_count == 0:
            continue

        frame_offsets = [
            struct.unpack_from('<I', img_data, 20 + i * 4)[0]
            for i in range(frame_count)
        ]

        print(f"  img_{idx:03d}: {width}x{height}, frames={frame_count}")

        palette = parse_palette(img_data[-768:])
        frames_list = []

        rle_section_start = 20 + (frame_count * 4)

        for f_idx in range(frame_count):
            rle_start = frame_offsets[f_idx] + rle_section_start

            if f_idx + 1 < frame_count:
                rle_end = frame_offsets[f_idx + 1] + rle_section_start
            else:
                rle_end = len(img_data) - 768

            rle_chunk = img_data[rle_start:rle_end]
            pixels = decode_rle(rle_chunk, width * height)

            frame_img = create_frame_image(pixels, width, height, palette)
            frames_list.append(frame_img)

        if frame_count == 1:
            save_path = os.path.join(output_dir, f"img_{idx:03d}.png")
            frames_list[0].save(save_path)
        else:
            save_path = os.path.join(output_dir, f"img_{idx:03d}_animated.gif")
            frames_list[0].save(
                save_path,
                save_all=True,
                append_images=frames_list[1:],
                duration=100,
                loop=0
            )

     print(f"\nDone! Saved to '{output_dir}/'")


if __name__ == "__main__":
    if len(sys.argv) > 1:
        target = sys.argv[1]
    else:
        target = input("Enter path to .gpk file: ").strip().strip('"')

    parse_gpk(target)

 

Edited by MrIkso
added .gif save version
Posted

Hi @MrIkso, you’re the real GOAT! Now the GIFs are there and I can convert them. I truly appreciate it from the bottom of my heart—thank you so much!!

Create an account or sign in to comment

You need to be a member in order to leave a comment

Create an account

Sign up for a new account in our community. It's easy!

Register a new account

Sign in

Already have an account? Sign in here.

Sign In Now
×
×
  • Create New...