sotn-decomp/tools/png2s.py
Luciano Ciccariello ac61a3139c
Extract BIN/F_GAME.BIN (#886)
Mostly a proof of concept to extract resources from `BIN/F_GAME.BIN`.
There are tons of hacks to make this work:
* Tweaked png2s to decode binaries into PNGs
* Add a `config/gfx.game.json` that describes how to extract different
sprites out of a binary file
* Coded the palette location by reading the `clut` parameter in the
source code. A clut value of `0x196` corresponds to `"palette": "0x96"`
or `"palette": 150`.
* Coded the bits per pixel for a couple of 8-bit images
* Coded the palette location of those graphics that expects the palette
from DRA.BIN instead of F_GAME.BIN
* For all the undiscovered palette, I called the files `unk` and
defaulted their palette to grey

The expectation is that sprites will slowly be documented and added into
`config/gfx.game.json` by modders and enthusiasts. This does not yet
pack back these PNGs into the binary file. The way these assets are
extracted is probably not final or perfect, but we have to start from
somewhere.


![image](https://github.com/Xeeynamo/sotn-decomp/assets/6128729/ce12ad2d-d95d-41e8-ac31-186cd36ac965)
2023-12-25 19:23:25 +00:00

290 lines
9.7 KiB
Python
Executable File

#!/usr/bin/python3
import argparse
import json
import png
import os
def ensure_dir(file_path: str):
dir_path = os.path.dirname(file_path)
if not os.path.exists(dir_path):
os.makedirs(dir_path)
def get_num(param: int | str | None) -> int:
if not param:
return 0
if isinstance(param, int):
return int(param)
elif isinstance(param, str):
if param.startswith("0x"):
return int(param[2:], 16)
else:
return int(param)
else:
raise Exception(f"type '{type(param)}' for '{param}' unhandled")
def encode_from_png(
in_file, out_file_bitmap, name_bitmap, out_file_pal, name_pal, is_append
):
def encode_pal(dst, idx, palette):
for i in range(0, len(palette)):
color = palette[i]
r = color[0]
g = color[1]
b = color[2]
a = 0x8000 if color[3] >= 64 else 0x0000
c = (r >> 3) | ((g >> 3) << 5) | ((b >> 3) << 10) | a
dst[idx + i * 2 + 0] = c & 0xFF
dst[idx + i * 2 + 1] = (c >> 8) & 0xFF
def encode_bitmap(data):
out = bytearray(int(len(data) / 2))
for i in range(0, len(out)):
out[i] = data[i * 2 + 0]
out[i] |= data[i * 2 + 1] << 4
return out
mode = "a" if is_append else "w"
img = png.Reader(in_file).read()
data = encode_bitmap(bytearray().join(img[2]))
info = img[3]
if info["planes"] != 1:
return f"'{in_file}' must be an indexed image"
palette = info["palette"]
if len(palette) > 16:
return f"'{in_file}' palette must be of 16 colors or less but found {len(palette)} colors instead"
with open(out_file_bitmap, mode) as f_out:
f_out.write(f".section .data\n")
f_out.write(f".global {name_bitmap}\n")
f_out.write(f"{name_bitmap}:\n")
for x in data:
f_out.write(f".byte 0x{x:02X}\n")
f_out.write("\n")
pal_data = bytearray(0x10 * 2)
encode_pal(pal_data, 0, palette)
with open(out_file_pal, mode) as f_out:
f_out.write(f".section .data\n")
f_out.write(f".global {name_pal}\n")
f_out.write(f"{name_pal}:\n")
f_out.write(f".short 0x{pal_data[0x01]:02X}{pal_data[0x00]:02X}\n")
f_out.write(f".short 0x{pal_data[0x03]:02X}{pal_data[0x02]:02X}\n")
f_out.write(f".short 0x{pal_data[0x05]:02X}{pal_data[0x04]:02X}\n")
f_out.write(f".short 0x{pal_data[0x07]:02X}{pal_data[0x06]:02X}\n")
f_out.write(f".short 0x{pal_data[0x09]:02X}{pal_data[0x08]:02X}\n")
f_out.write(f".short 0x{pal_data[0x0B]:02X}{pal_data[0x0A]:02X}\n")
f_out.write(f".short 0x{pal_data[0x0D]:02X}{pal_data[0x0C]:02X}\n")
f_out.write(f".short 0x{pal_data[0x0F]:02X}{pal_data[0x0E]:02X}\n")
f_out.write(f".short 0x{pal_data[0x11]:02X}{pal_data[0x10]:02X}\n")
f_out.write(f".short 0x{pal_data[0x13]:02X}{pal_data[0x12]:02X}\n")
f_out.write(f".short 0x{pal_data[0x15]:02X}{pal_data[0x14]:02X}\n")
f_out.write(f".short 0x{pal_data[0x17]:02X}{pal_data[0x16]:02X}\n")
f_out.write(f".short 0x{pal_data[0x19]:02X}{pal_data[0x18]:02X}\n")
f_out.write(f".short 0x{pal_data[0x1B]:02X}{pal_data[0x1A]:02X}\n")
f_out.write(f".short 0x{pal_data[0x1D]:02X}{pal_data[0x1C]:02X}\n")
f_out.write(f".short 0x{pal_data[0x1F]:02X}{pal_data[0x1E]:02X}\n")
f_out.write("\n")
return None
def decode_to_png(bmp_name, png_name, x, y, w, h, bpp, stride, bmp_offset, pal):
size = int(stride * h)
with open(bmp_name, "rb") as f_bmp:
f_bmp.seek(get_num(bmp_offset) + stride * y)
bmp_data = f_bmp.read(size)
pixels = []
if bpp == 4:
src = bmp_data
x >>= 1
for j in range(0, h):
row = []
for i in range(0, w >> 1):
lo = src[x + i] & 15
hi = src[x + i] >> 4
row.extend([lo, hi])
src = src[stride:]
pixels.append(row)
else:
src = bmp_data
for j in range(0, h):
pixels.append(src[:w])
src = src[w:]
if pal:
pal_name, pal_offset, alpha = pal
with open(pal_name, "rb") as f_pal:
f_pal.seek(get_num(pal_offset))
pal_data = f_pal.read(0x20 if bpp == 4 else 0x200)
palette = []
for i in range(0, int(len(pal_data) / 2)):
color = pal_data[i * 2] + pal_data[i * 2 + 1] * 256
r = (color & 31) << 3
g = ((color >> 5) & 31) << 3
b = ((color >> 10) & 31) << 3
r |= r >> 5 # enrich the colors by
g |= g >> 5 # filling the lowest three bits
b |= b >> 5 # with the upper bits
a = 0 if color < 0x8000 and alpha == True else 255
palette.append((r, g, b, a))
else:
if bpp == 4:
palette = [(i * 16 + i, i * 16 + i, i * 16 + i) for i in range(16)]
elif bpp == 8:
palette = [(i, i, i) for i in range(0, 256)]
with open(png_name, "wb") as f_png:
writer = png.Writer(width=w, height=h, bitdepth=bpp, palette=palette)
writer.write(f_png, pixels)
return None
def decode_batch(input, source_dir, output):
with open(input, "r") as f:
config = json.load(f)
source = os.path.join(source_dir, config["source"])
clut = get_num(config["clut"])
stride = get_num(config["stride"])
for item in config["items"]:
name = os.path.join(output, item["name"] + ".png")
ensure_dir(name)
if "palette" in item and "brute" not in item["palette"]:
palette = item["palette"]
if "source" in palette:
pal_source = os.path.join(source_dir, palette["source"])
offset = get_num(palette["offset"])
else:
pal_source = source
offset = clut + get_num(palette["offset"]) * 0x20
use_alpha = "alpha" in palette and palette["alpha"] == True
pal = (pal_source, offset, use_alpha)
else:
pal = None
err = decode_to_png(
source,
name,
get_num(item["x"]) if "x" in item else 0,
get_num(item["y"]) if "y" in item else 0,
item["width"],
item["height"],
item["bpp"],
stride,
get_num(item["offset"]),
pal,
)
if "palette" in item and "brute" in item["palette"]:
for i in range(0, 256):
err = decode_to_png(
source,
os.path.join(output, item["name"] + f"_{i}" + ".png"),
get_num(item["x"]) if "x" in item else 0,
get_num(item["y"]) if "y" in item else 0,
item["width"],
item["height"],
item["bpp"],
stride,
get_num(item["offset"]),
(source, clut + i * 0x20, False),
)
if err:
return err
return None
if __name__ == "__main__":
parser = argparse.ArgumentParser(
description="convert a PNG to an assemblable GNU-friendly data file"
)
subparsers = parser.add_subparsers(dest="command")
encode_parser = subparsers.add_parser(
"encode",
description="Encode a PNG file and its palette into binary or assemblable data",
)
encode_parser.add_argument("input")
encode_parser.add_argument("output_bmp")
encode_parser.add_argument("name_bmp")
encode_parser.add_argument("output_pal")
encode_parser.add_argument("name_pal")
decode_parser = subparsers.add_parser(
"decode",
description="Decode a binary file into a PNG file",
)
decode_parser.add_argument(
"input",
type=str,
help="the binary file that contains the bitmap to decode as PNG",
)
decode_parser.add_argument("output", type=str, help="output name of the PNG")
decode_parser.add_argument(
"width", type=int, help="width in pixel of the expected PNG output"
)
decode_parser.add_argument(
"height", type=int, help="height in pixel of the expected PNG output"
)
decode_parser.add_argument(
"depth", type=int, choices=[4, 8], help="how many bits per pixel"
)
decode_parser.add_argument(
"offset", help="source of the bitmap image to convert from"
)
decode_parser.add_argument(
"-p",
"--palette",
nargs=3,
metavar=("palette.bin", "offset", "alpha"),
help="load palette from the specified file and a given offset",
)
bdecode_parser = subparsers.add_parser(
"bdecode",
description="Decode assets in batch using a configuration file",
)
bdecode_parser.add_argument("input", type=str, help="configuration file name")
bdecode_parser.add_argument("source", type=str, help="location of original assets")
bdecode_parser.add_argument("output", type=str, help="output directory")
args = parser.parse_args()
if args.command == "encode":
err = encode_from_png(
args.input,
args.output_bmp,
args.name_bmp,
args.output_pal,
args.name_pal,
False,
)
elif args.command == "decode":
stride = args.width
if args.depth == 4:
stride >>= 1
err = decode_to_png(
args.input,
args.output,
0,
0,
args.width,
args.height,
args.depth,
stride,
args.offset,
args.palette,
)
elif args.command == "bdecode":
err = decode_batch(args.input, args.source, args.output)
if err:
raise Exception(err)