Created
March 28, 2023 07:52
-
-
Save gynvael/bc7473c4862045a071c877014ca18188 to your computer and use it in GitHub Desktop.
Stargate Online SPR file decoder
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/python3 | |
# SPR decoder from Stargate Online, by Gynvael Coldwind. | |
# Note: This decoder was made based on 3 SPR files I've received, so it might | |
# not handle all the cases. | |
import struct | |
from struct import unpack | |
import sys | |
from PIL import Image | |
DEBUG_MODE = False | |
""" | |
File format as much as I could decode it: | |
* Everything is encoded as Little Endian. | |
* You're supposed to read the file from the back, since that's where the index | |
is located. | |
General file structure: | |
[image 0] | |
[image 1] | |
[image ...] | |
[index] | |
[number of images] | |
The "number of images" field is a single DWORD which says how many images there | |
are in this file. | |
The "index" is an array of DWORDs with offsets to the start of the each image | |
in the file. Of course the "number of images" field says how many DWORDs there | |
are in the index. | |
Image file structure: | |
[DWORD width] | |
[DWORD height] | |
[DWORD noidea_just_zeroes] | |
[DWORD noidea_more_zeroes] | |
[DWORD*width scanline_table] | |
[scanlines...] | |
Each scanline is encoded using a very simple compression scheme. Each "chunk" | |
begins with a single WORD, which is optionally followed by more data. | |
This WORD is actually a bit field: | |
bottom 14 bits: length of a run | |
bit 14: if set, the next byte contains alpha value for the whole run | |
otherwise alpha is 100% (full opaque) | |
bit 15: if set, the run of pixels is skipped (they are fully transparent) | |
A special case if where bit 15 is set, but run length is 0 - this denotes an | |
image end. | |
Example (note: more on color decoding below example): | |
02 80 Skip 2 pixels | |
01 40 20 00 FF FF Emit one pixel 0xFFFF with alpha 0x20 | |
01 40 68 00 FF FF Emit one pixel 0xFFFF with alpha 0x68 | |
01 40 A8 00 FF FF Emit one pixel 0xFFFF with alpha 0xA8 | |
01 40 C8 00 FF FF Emit one pixel 0xFFFF with alpha 0xC8 | |
01 40 B0 00 FF FF Emit one pixel 0xFFFF with alpha 0xB0 | |
02 40 A0 00 FF FF FF FF Emit two pixels 0xFFFF and 0xFFFF with alpha 0xA0 | |
01 40 48 00 FF FF Emit one pixel 0xFFFF with alpha 0x48 | |
0A 80 Skip 10 pixels | |
01 40 40 00 86 31 Emit one pixel 0x3186 with alpha 0x40 | |
01 40 F0 00 EC 5A Emit one pixel 0x5AEC with alpha 0xF0 | |
02 00 38 CE 10 8C Emit two fully opaque pixels: 0xCE38 and 0x8C10 | |
00 80 End of scanline. | |
Colors are 16bpp little endian 5-6-5 format, so decoding to RGB goes somewhat | |
like this: | |
b = int(((px16bpp & 0x001f) / 31) * 255) | |
g = int((((px16bpp & 0x07e0) >> 5) / 63) * 255) | |
r = int((((px16bpp & 0xf800) >> 11) / 31) * 255) | |
Still unknown: | |
* Why does alpha have 2 bytes instead of one? It seems the top byte is always | |
0 which makes sense, but seems wasteful. | |
""" | |
def decode_single(data, start_offset, fname): | |
print(f"--- {fname}") | |
print(f"Start offset: 0x{start_offset:x}") | |
offset = start_offset | |
w, h = unpack("<II", data[offset:offset+8]) | |
offset += 8 | |
print(f"Resolution: {w}x{h}") | |
noidea1, noidea2 = unpack("<II", data[offset:offset+8]) | |
print(f"No idea: {noidea1}, {noidea2}") | |
offset += 8 | |
scanline_offset_table = [] | |
last = 0 | |
for i in range(h): | |
scanline_offset_table.append(unpack("<I", data[offset:offset+4])[0]) | |
offset += 4 | |
#print(scanline_offset_table[i], hex(scanline_offset_table[i]), | |
# scanline_offset_table[i] - last) | |
last = scanline_offset_table[i] | |
image_data = bytearray(w * h * 4) | |
for y, scanline_offset in enumerate(scanline_offset_table): | |
if DEBUG_MODE: | |
print("SCANLINE:", hex(scanline_offset)) | |
offset = scanline_offset | |
x = 0 | |
while True: | |
run = unpack("<H", data[offset:offset+2])[0] | |
is_pixel_skip = bool(run & 0x8000) | |
is_alpha_specified = bool(run & 0x4000) | |
run = run & 0x3fff | |
offset += 2 | |
if is_pixel_skip and not is_alpha_specified and run == 0: | |
# End of run. | |
break | |
if is_pixel_skip and not is_alpha_specified and run > 0: | |
# Skip N pixels. | |
x += run | |
continue | |
# Make sure both don't appear at the same time, as it would make no sense. | |
assert not (is_pixel_skip and is_alpha_specified) | |
alpha = 0xff | |
if is_alpha_specified: | |
if DEBUG_MODE: | |
print("ALPHA", is_pixel_skip, is_alpha_specified, hex(run), hex(offset)) | |
alpha = unpack("<H", data[offset:offset+2])[0] | |
assert alpha < 256 | |
offset += 2 | |
assert not is_pixel_skip | |
# Just a pixel run. | |
if DEBUG_MODE: | |
print("RAW", is_pixel_skip, is_alpha_specified, hex(run)) | |
pixels16bpp = unpack(f"<{run}H", data[offset:offset+run*2]) | |
offset += run*2 | |
for px16bpp in pixels16bpp: | |
b = int(((px16bpp & 0x001f) / 31) * 255) | |
g = int((((px16bpp & 0x07e0) >> 5) / 63) * 255) | |
r = int((((px16bpp & 0xf800) >> 11) / 31) * 255) | |
image_data[(x + y * w) * 4 + 0] = r | |
image_data[(x + y * w) * 4 + 1] = g | |
image_data[(x + y * w) * 4 + 2] = b | |
image_data[(x + y * w) * 4 + 3] = alpha | |
x += 1 | |
img = Image.frombytes("RGBA", (w, h), bytes(image_data)) | |
img.save(fname) | |
def decode(fname): | |
with open(fname, "rb") as f: | |
data = f.read() | |
number_of_images = unpack("<I", data[-4:])[0] | |
print(f"Number of images: {number_of_images}") | |
image_offset_table = unpack( | |
f"<{number_of_images}I", data[-4 - number_of_images*4:-4] | |
) | |
for i, offset in enumerate(image_offset_table): | |
decode_single(data, offset, f"{fname}.{i:04}.png") | |
def main(): | |
if len(sys.argv) == 1: | |
sys.exit("usage: decoder.py <filename.spr>") | |
decode(sys.argv[1]) | |
if __name__ == "__main__": | |
main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment