178 lines
5.9 KiB
Python
178 lines
5.9 KiB
Python
#!/usr/bin/env python3
|
|
"""Export preview PNGs for simple Radium section 3 bitmap records.
|
|
|
|
Ghidra xrefs show section 3 is a qword pointer table with count id 1. The
|
|
records addressed by that table start with:
|
|
|
|
uint32 record_id
|
|
uint32 flags
|
|
uint16 width
|
|
uint16 height
|
|
|
|
Most small records then contain width*height bytes of indexed pixel data plus
|
|
padding. This exporter writes grayscale/alpha PNG previews only for those
|
|
straightforward records and leaves compact 128x32-style payloads as undecoded.
|
|
It is static and does not execute target binaries.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import csv
|
|
import json
|
|
import struct
|
|
import zlib
|
|
from pathlib import Path
|
|
|
|
|
|
PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
|
|
|
|
|
|
FIELDS = [
|
|
"logical_index",
|
|
"record_id",
|
|
"flags",
|
|
"record_offset",
|
|
"next_record_offset",
|
|
"record_length",
|
|
"width",
|
|
"height",
|
|
"pixel_payload_length",
|
|
"decoder_status",
|
|
"png_path",
|
|
]
|
|
|
|
|
|
def u64(data: bytes, offset: int) -> int:
|
|
return struct.unpack_from("<Q", data, offset)[0]
|
|
|
|
|
|
def png_chunk(kind: bytes, payload: bytes) -> bytes:
|
|
crc = zlib.crc32(kind + payload) & 0xFFFFFFFF
|
|
return struct.pack(">I", len(payload)) + kind + payload + struct.pack(">I", crc)
|
|
|
|
|
|
def write_rgba_png(path: Path, width: int, height: int, rgba: bytes) -> None:
|
|
rows = []
|
|
stride = width * 4
|
|
for y in range(height):
|
|
rows.append(b"\x00" + rgba[y * stride : (y + 1) * stride])
|
|
ihdr = struct.pack(">IIBBBBB", width, height, 8, 6, 0, 0, 0)
|
|
path.write_bytes(
|
|
PNG_SIGNATURE
|
|
+ png_chunk(b"IHDR", ihdr)
|
|
+ png_chunk(b"IDAT", zlib.compress(b"".join(rows), level=9))
|
|
+ png_chunk(b"IEND", b"")
|
|
)
|
|
|
|
|
|
def pixel_to_rgba(value: int) -> tuple[int, int, int, int]:
|
|
if value == 0xFF:
|
|
return (0, 0, 0, 0)
|
|
if value <= 0x0F:
|
|
gray = value * 17
|
|
else:
|
|
gray = value
|
|
return (gray, gray, gray, 255)
|
|
|
|
|
|
def read_header(path: Path) -> dict[str, object]:
|
|
with path.open(encoding="utf-8") as f:
|
|
return json.load(f)
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--image", type=Path, default=Path("games/pokemon_pro/image.bin"))
|
|
parser.add_argument("--header", type=Path, default=Path("analysis/radium-image/header.json"))
|
|
parser.add_argument("--out-dir", type=Path, default=Path("analysis/radium-image/section3-bitmaps"))
|
|
parser.add_argument(
|
|
"--manifest",
|
|
type=Path,
|
|
default=Path("analysis/radium-image/section3_bitmap_manifest.tsv"),
|
|
)
|
|
args = parser.parse_args()
|
|
|
|
data = args.image.read_bytes()
|
|
header = read_header(args.header)
|
|
section_offsets = {key: int(value) for key, value in header["section_offsets"].items()}
|
|
section3_offset = section_offsets["section_3"]
|
|
section8_offset = section_offsets["section_8"]
|
|
count = int(header["counts_from_header"]["count_1_header10_high32"])
|
|
pointers = [u64(data, section3_offset + index * 8) for index in range(count)]
|
|
sorted_pointers = sorted(set(pointers))
|
|
next_by_pointer = {
|
|
pointer: sorted_pointers[index + 1] if index + 1 < len(sorted_pointers) else section8_offset
|
|
for index, pointer in enumerate(sorted_pointers)
|
|
}
|
|
|
|
args.out_dir.mkdir(parents=True, exist_ok=True)
|
|
rows: list[dict[str, object]] = []
|
|
exported = 0
|
|
for logical_index, pointer in enumerate(pointers):
|
|
next_pointer = next_by_pointer[pointer]
|
|
record_length = next_pointer - pointer
|
|
row = {
|
|
"logical_index": logical_index,
|
|
"record_id": "",
|
|
"flags": "",
|
|
"record_offset": f"0x{pointer:x}",
|
|
"next_record_offset": f"0x{next_pointer:x}",
|
|
"record_length": record_length,
|
|
"width": "",
|
|
"height": "",
|
|
"pixel_payload_length": "",
|
|
"decoder_status": "truncated_header",
|
|
"png_path": "",
|
|
}
|
|
if pointer + 12 > len(data) or record_length < 12:
|
|
rows.append(row)
|
|
continue
|
|
|
|
record_id, flags = struct.unpack_from("<II", data, pointer)
|
|
width, height = struct.unpack_from("<HH", data, pointer + 8)
|
|
area = width * height
|
|
payload_length = max(0, record_length - 12)
|
|
row.update(
|
|
{
|
|
"record_id": record_id,
|
|
"flags": flags,
|
|
"width": width,
|
|
"height": height,
|
|
"pixel_payload_length": payload_length,
|
|
}
|
|
)
|
|
if width == 0 or height == 0 or width > 512 or height > 512:
|
|
row["decoder_status"] = "invalid_dimensions"
|
|
elif payload_length < area:
|
|
row["decoder_status"] = "undecoded_compact_payload"
|
|
else:
|
|
pixels = data[pointer + 12 : pointer + 12 + area]
|
|
rgba = bytearray()
|
|
for value in pixels:
|
|
rgba.extend(pixel_to_rgba(value))
|
|
png_path = args.out_dir / f"section3_{logical_index:04d}_id{record_id:04d}_{width}x{height}_f{flags}.png"
|
|
write_rgba_png(png_path, width, height, bytes(rgba))
|
|
row["decoder_status"] = "raw_indexed_preview"
|
|
row["png_path"] = png_path.as_posix()
|
|
exported += 1
|
|
rows.append(row)
|
|
|
|
args.manifest.parent.mkdir(parents=True, exist_ok=True)
|
|
with args.manifest.open("w", newline="", encoding="utf-8") as f:
|
|
writer = csv.DictWriter(f, fieldnames=FIELDS, delimiter="\t", extrasaction="ignore")
|
|
writer.writeheader()
|
|
writer.writerows(rows)
|
|
status_counts: dict[str, int] = {}
|
|
for row in rows:
|
|
status = str(row["decoder_status"])
|
|
status_counts[status] = status_counts.get(status, 0) + 1
|
|
print(f"wrote {args.manifest} rows={len(rows)} exported_pngs={exported}")
|
|
for status, count_value in sorted(status_counts.items()):
|
|
print(f"{status}: {count_value}")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|