Skip to content

Instantly share code, notes, and snippets.

@gynvael
Created March 28, 2023 07:52
Show Gist options
  • Save gynvael/bc7473c4862045a071c877014ca18188 to your computer and use it in GitHub Desktop.
Save gynvael/bc7473c4862045a071c877014ca18188 to your computer and use it in GitHub Desktop.
Stargate Online SPR file decoder
#!/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