Help with Chill Manor (PC-DOS) IMG format

Discuss game modding
MartinVole
4-bit nibble
Posts: 19
Joined: August 30th, 2012, 9:13 pm

Re: Help with Chill Manor (PC-DOS) IMG format

Post by MartinVole »

arcnor wrote: July 3rd, 2019, 5:16 pm And after a bit more work, offsetting (more than centering, wrong word) is fixed as well.

I can provide all the extracted files, or explain what I did (the format of the compressed textures is basically RLE encoding plus some extra code to handle transparency), or both :).

There are a few bytes I haven't been able to decypher yet, but they are probably not needed to generate the images (I can't see any image "wrong").
A couple years later (though feels like longer due to COVID) I come back to this thread and somebody had basically finished this. RLE makes sense since Animation Magic uses it for basically everything! And... looking back, that was missing from the notes I made long ago... actually looking back on my old notes they were a real mess! "multi-dimensional"? Sounds like sci-fi talk.

But getting back to it, if you still have anything lying around for this it'd be very appreciated.
MartinVole
4-bit nibble
Posts: 19
Joined: August 30th, 2012, 9:13 pm

Re: Help with Chill Manor (PC-DOS) IMG format

Post by MartinVole »

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.

Usage is: python3 IMG2PNG.py <-v1/-v2> [IMG file] [PALETTE file]

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()
Post Reply