Okay! About 4 years later and a couple braincells wiser, I've successfully converted the Delphi code to Python, made it so the palette isn't hard-coded into the program itself, and outputting to a directory named after the IMG file, and outputting to PNG instead of BMP (using Pillow). Works perfectly for V1 (Meen), and I've managed to almost get a working version for V2. Ignores mipmaps, because those were annoying to constantly correct for, only extracts the full-sized textures. Accounts for variable pixel height even.
However, running into a problem still with padded textures.
Right now for v2 padded IMG files, it just outputs solid magenta. Works perfect for v1 I.M. Meen and anything not padded for v2
Edit: Also, since this is based on WRS's original Delphi 7 code, credits given where credit is due!
Edit2: Getting closer to the problem, I feel. I think it was because the old RLE decode didn't account for the mips, The padded tile count is coming up correct.
Edit3: Cleaned the code up a little of testing stuff.
Edit4: BIG thanks to ogarvey over at The Spriters Resource the code is now complete! It now no longer needs a -v1 or -v2 pass as it autodetects the structures, outputs masked sprites to transparent PNGs!
Code: Select all
# Python code based on Delphi 7 program written by WRS, would link to original thread but... RIP Xentax
import sys
import os
import struct
from PIL import Image
# ---------- Palette ----------
def read_palette(pal_path):
"""Read 256*3-byte RGB palette (8-bit per channel)."""
with open(pal_path, "rb") as f:
data = f.read(768)
if len(data) < 768:
raise ValueError("Palette file must be 768 bytes (256*3).")
pal = [(data[i], data[i+1], data[i+2]) for i in range(0, 768, 3)]
return pal
# ---------- Helpers ----------
def ensure_dir_for(img_path):
base = os.path.splitext(os.path.basename(img_path))[0]
outdir = os.path.join(os.getcwd(), base)
os.makedirs(outdir, exist_ok=True)
return outdir
def save_rgba_png(width, height, rgb_pixels, out_path):
"""Save with magenta -> transparent alpha."""
rgba_pixels = []
for r, g, b in rgb_pixels:
if (r, g, b) == (255, 0, 255):
rgba_pixels.append((0, 0, 0, 0)) # transparent
else:
rgba_pixels.append((r, g, b, 255))
img = Image.new("RGBA", (width, height))
img.putdata(rgba_pixels)
img.save(out_path)
def color_from_palette(palette, idx):
r, g, b = palette[idx]
return (r, g, b)
# ---------- V1 decoding ----------
def parse_v1_header(f):
f.seek(0, os.SEEK_SET)
header_flag = struct.unpack("<H", f.read(2))[0]
if header_flag == 1:
headers = struct.unpack("<H", f.read(2))[0]
padded = 0
offsets_pos = f.tell()
else:
headers = 0
headers += struct.unpack("<H", f.read(2))[0]
headers += struct.unpack("<H", f.read(2))[0]
padded = struct.unpack("<H", f.read(2))[0]
offsets_pos = 2 + (header_flag * 2)
f.seek(offsets_pos, os.SEEK_SET)
return header_flag, headers, padded, offsets_pos
def read_offsets(f, count):
return [struct.unpack("<I", f.read(4))[0] for _ in range(count)]
def decode_v1_simple_sprite(f, offset, palette, out_path):
width = 64
height = 64
f.seek(offset, os.SEEK_SET)
px = f.read(width * height)
if len(px) != width * height:
return False
out = []
for y in range(height):
for x in range(width):
idx = px[(x * height) + y]
out.append(color_from_palette(palette, idx))
save_rgba_png(width, height, out, out_path)
return True
def decode_padded_block(
f,
block_start,
palette,
out_path,
tall_ok=False,
block_end=None,
mode="v1"
):
file_size = os.fstat(f.fileno()).st_size
def in_bounds(addr):
if addr < 0 or addr >= file_size:
return False
if block_end is not None and addr >= block_end:
return False
return True
f.seek(block_start, os.SEEK_SET)
head = f.read(4)
if len(head) < 4:
return False
left, right = struct.unpack("<HH", head)
col_count = (right - left + 1) if right >= left else 0
if col_count <= 0 or col_count > 64:
col_count = max(0, min(col_count, 64))
footers = [struct.unpack("<H", f.read(2))[0] for _ in range(col_count)]
width = 64
height = 64
magenta = (255, 0, 255)
pixels = [magenta] * (width * height)
painted_any = False
for i, off in enumerate(footers):
target = block_start + off
if not in_bounds(target) or target <= block_start:
continue
f.seek(target, os.SEEK_SET)
if mode == "v1":
while True:
raw = f.read(2)
if len(raw) < 2:
break
eol_u = struct.unpack("<H", raw)[0]
if eol_u == 0:
break
sol = struct.unpack("<h", f.read(2))[0]
pix = struct.unpack("<h", f.read(2))[0]
for j in range(sol, eol_u):
addr = block_start + pix + (j - sol)
if not in_bounds(addr):
continue
here = f.tell()
f.seek(addr, os.SEEK_SET)
b = f.read(1)
f.seek(here, os.SEEK_SET)
if not b:
continue
idx = b[0]
x = left + i
y = j
if 0 <= x < width and 0 <= y < height:
col = color_from_palette(palette, idx)
pixels[y * width + x] = col
if col != magenta:
painted_any = True
save_rgba_png(width, height, pixels, out_path)
return painted_any
# ---------- V2 decoding ----------
def read_v2_subindex(f, entry_start):
f.seek(entry_start, os.SEEK_SET)
raw = f.read(7 * 4)
if len(raw) < 7 * 4:
return None
vals = list(struct.unpack("<7I", raw))
start_rel = vals[0]
boundaries = []
prev = entry_start + start_rel
boundaries.append(prev)
for i in range(1, 7):
if vals[i] == 0:
break
cur = entry_start + vals[i]
if cur <= prev:
break
boundaries.append(cur)
prev = cur
if len(boundaries) < 2:
return None
return boundaries
def decode_v2_simple_block(f, seg_start, seg_end, palette, out_path):
data_len = max(0, seg_end - seg_start)
if data_len == 0:
return False
f.seek(seg_start, os.SEEK_SET)
px = f.read(data_len)
if len(px) != data_len:
return False
candidate_widths = [64, 32, 16, 8, 4, 2, 1]
pick_w, pick_h = None, None
for w in candidate_widths:
if data_len % w == 0:
h = data_len // w
if 1 <= h <= 128:
pick_w, pick_h = w, h
break
if pick_w is None:
pick_w = 64
pick_h = max(1, data_len // 64)
width, height = pick_w, pick_h
out = []
for y in range(height):
for x in range(width):
idx = px[(x * height) + y]
out.append(color_from_palette(palette, idx))
save_rgba_png(width, height, out, out_path)
return True
# ---------- V2 padded/masked decoder ----------
# Full credits to ogarvey from The Spriters Resource!
def decode_v2_masked_block(f, seg_start, seg_end, palette, out_path):
f.seek(seg_start, os.SEEK_SET)
img_data = f.read(seg_end - seg_start)
if len(img_data) < 0xE:
return False
height = struct.unpack("<H", img_data[0:2])[0]
width = struct.unpack("<H", img_data[2:4])[0]
left_pad = struct.unpack("<H", img_data[4:6])[0]
top_pad = struct.unpack("<H", img_data[8:10])[0]
footer_offset_count = struct.unpack("<H", img_data[0xA:0xC])[0] - top_pad
first_footer = struct.unpack("<H", img_data[0xC:0xE])[0]
footer_bytes = img_data[0xE:0xE + footer_offset_count*2]
footer_offsets = [first_footer] + [
struct.unpack("<H", footer_bytes[i:i+2])[0]
for i in range(0, len(footer_bytes), 2)
]
magenta = (255, 0, 255)
pixels = [[magenta for _ in range(width)] for _ in range(height)]
for j, cur_off in enumerate(footer_offsets):
nxt_off = footer_offsets[j+1] if j+1 < len(footer_offsets) else len(img_data)
line = img_data[cur_off:nxt_off]
if not line or line[0] == 0xFF:
continue
img_x = left_pad
img_y = top_pad + j
idx = 0
pixels_remaining = 0
if line[0] == 0:
pixels_remaining = 128
idx += 1
while idx < len(line) and img_x < width and img_y < height:
b = line[idx]
if b & 0x80 and pixels_remaining == 0:
count = b & 0x7F
img_x += count
idx += 1
elif b < 0x80 and pixels_remaining == 0:
pixels_remaining = b if b > 0 else width - img_x
idx += 1
else:
col = color_from_palette(palette, b)
pixels[img_y][img_x] = col
img_x += 1
idx += 1
pixels_remaining -= 1
img = Image.new("RGBA", (width, height))
flat = []
for row in pixels:
for r,g,b in row:
if (r,g,b) == (255,0,255):
flat.append((0,0,0,0))
else:
flat.append((r,g,b,255))
img.putdata(flat)
img = img.transpose(Image.ROTATE_270)
img.save(out_path)
return True
# ---------- Auto runner ----------
def run_autodetect(img_path, pal, outdir):
with open(img_path, "rb") as f:
header_flag, headers, padded, offsets_pos = parse_v1_header(f)
f.seek(offsets_pos, os.SEEK_SET)
simple_offsets = read_offsets(f, headers)
padded_offsets = read_offsets(f, padded)
is_v2 = False
if simple_offsets:
test = read_v2_subindex(f, simple_offsets[0])
if test:
is_v2 = True
if is_v2:
print(f"> Detected V2: {headers} simple, {padded} padded")
for i, entry_off in enumerate(simple_offsets, start=1):
bounds = read_v2_subindex(f, entry_off)
if not bounds:
continue
base_start, base_end = bounds[0], bounds[1]
out = os.path.join(outdir, f"Sprite_{i:04d}.png")
decode_v2_simple_block(f, base_start, base_end, pal, out)
for i, entry_off in enumerate(padded_offsets, start=1):
sprite_index = headers + i
bounds = read_v2_subindex(f, entry_off)
if not bounds:
continue
base_start, base_end = bounds[0], bounds[1]
out = os.path.join(outdir, f"Sprite_{sprite_index:04d}.png")
decode_v2_masked_block(f, base_start, base_end, pal, out)
else:
print(f"> Detected V1: {headers} simple, {padded} padded")
for i, off in enumerate(simple_offsets, start=1):
out = os.path.join(outdir, f"Sprite_{i:04d}.png")
decode_v1_simple_sprite(f, off, pal, out)
for i, off in enumerate(padded_offsets, start=1):
out = os.path.join(outdir, f"Sprite_{headers + i:04d}.png")
decode_padded_block(f, off, pal, out, tall_ok=False, block_end=None)
# ---------- CLI ----------
def main():
if len(sys.argv) != 3:
print(f"Usage: {os.path.basename(sys.argv[0])} <IMAGES.IMG> <PALETTE.PAL>")
sys.exit(1)
img_path = sys.argv[1]
pal_path = sys.argv[2]
outdir = ensure_dir_for(img_path)
palette = read_palette(pal_path)
run_autodetect(img_path, palette, outdir)
print(f"> Done. PNGs are in: {outdir}")
if __name__ == "__main__":
main()