UZ.- Posted March 27 Posted March 27 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. +UQ+ Cursors.rar
Members MrIkso Posted March 27 Members Posted March 27 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
UZ.- Posted March 28 Author Posted March 28 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)
Members MrIkso Posted March 28 Members Posted March 28 @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
UZ.- Posted March 28 Author Posted March 28 @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 1
Members Solution MrIkso Posted March 29 Members Solution Posted March 29 (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 March 29 by MrIkso added .gif save version
UZ.- Posted March 29 Author Posted March 29 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!!
Recommended Posts
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 accountSign in
Already have an account? Sign in here.
Sign In Now