Skip to content

Instantly share code, notes, and snippets.

@infval
Last active March 27, 2025 23:28
Show Gist options
  • Save infval/3d12781f57e891d2905212f5aaebc6c5 to your computer and use it in GitHub Desktop.
Save infval/3d12781f57e891d2905212f5aaebc6c5 to your computer and use it in GitHub Desktop.
[Wii] Coraline | dxt Converter / Unpacker / Packer / Extractor | Magic: 30 81 80 00 / \x30\x81\x80\x00 / 30818000 / 308180
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
[Wii] Coraline
.dxt <-> PNG
Requirements:
pip install -U pillow
Usage:
# ./*.dxt -> ./*.dxt.png
script.py
# filename.dxt -> filename.dxt.png
script.py filename.dxt
# filename.dxt.png + filename.dxt (header) -> filename.dxt
script.py filename.dxt.png
Useful Tool:
Color quantizer - http://x128.ho.ua/color-quantizer.html
"""
__version__ = "1.1"
__author__ = "infval"
import sys
from pathlib import Path
from struct import unpack
from collections import OrderedDict
from PIL import Image
HEADER_SIZE = 0x80
class Texture:
"""
[Wii] Color Index 8-bits (C8) + Palette RGBA8
"""
def __init__(self, path, offset, w, h, pal_offset):
self.path = path
self.offset = offset
self.w = w
self.h = h
self.pal_offset = pal_offset
def to_png(self, png_path):
im = Image.new("RGBA", (self.w, self.h))
pixels = im.load()
with open(self.path, "rb") as f:
f.seek(self.offset)
b = f.read(self.w * self.h)
f.seek(self.pal_offset)
b_pal = f.read(256 * 4)
pal = []
for i in range(256):
index = i * 2
pal.append((b_pal[index+1], # R
b_pal[index+0], # G
b_pal[index+1+0x200], # B
b_pal[index+0+0x200])) # A
tw = 8
th = 4
bytes_in_tile = tw * th
bytes_in_row = self.w * th
x = 0
y = 0
for i in range(self.w * self.h):
index = i // bytes_in_row * bytes_in_row
index += x // tw * bytes_in_tile
index += (y % th) * tw + x % tw
c = b[index]
pixels[x, y] = pal[c]
x += 1
if x == self.w:
x = 0
y += 1
im.save(png_path, "PNG")
print("Unpacked: {} -> {}".format(self.path, png_path))
def from_png(self, bin_path, base_path=None):
im = Image.open(self.path).convert("RGBA")
pixels = im.load()
self.w, self.h = im.size
if not base_path:
# Raw
self.offset = 0
self.pal_offset = self.w * self.h
b = bytearray(self.w * self.h + 256 * 4)
else:
with open(base_path, "rb") as f:
b = bytearray(f.read())
pal = OrderedDict()
pal_i = 0
b_img = bytearray(self.w * self.h)
tw = 8
th = 4
bytes_in_tile = tw * th
bytes_in_row = self.w * th
i = 0
for y in range(self.h):
for x in range(self.w):
c = pixels[x, y]
if c not in pal:
if len(pal) >= 256:
print("Too many colors! Max: 256.", self.path)
sys.exit(1)
else:
pal[c] = pal_i
pal_i += 1
index = i // bytes_in_row * bytes_in_row
index += x // tw * bytes_in_tile
index += (y % th) * tw + x % tw
b_img[index] = pal[c]
i += 1
b[self.offset: self.offset + self.w * self.h] = b_img
pal = list(pal.keys())
pal += [(0, 0, 0, 0)] * (256 - len(pal)) # Unused colors
for i in range(256):
index1 = self.pal_offset + i * 2
index2 = self.pal_offset + i * 2 + 0x200
cR, cG, cB, cA = pal[i]
b[index2+0] = cA
b[index2+1] = cB
b[index1+0] = cG
b[index1+1] = cR
with open(bin_path, "wb") as f:
f.write(b)
print("Packed: {} -> {}".format(self.path, bin_path))
def dxt_to_png(path):
with open(path, "rb") as f:
f.seek(0x0A)
w, h = unpack("<BB", f.read(2))
w = 1 << w
h = 1 << h
png_path = path.with_name(path.name + ".png")
Texture(path, HEADER_SIZE, w, h, HEADER_SIZE + w * h).to_png(png_path)
def png_to_dxt(path):
bin_path = path.with_suffix('')
with open(bin_path, "rb") as f:
f.seek(0x0A)
w, h = unpack("<BB", f.read(2))
w = 1 << w
h = 1 << h
Texture(path, HEADER_SIZE, None, None, HEADER_SIZE + w * h).from_png(bin_path, bin_path)
if __name__ == '__main__':
if len(sys.argv) == 1:
path = Path(".")
for p in path.glob("*.dxt"):
dxt_to_png(p)
else:
p = Path(sys.argv[1])
if p.suffix == ".png":
png_to_dxt(p)
else:
dxt_to_png(p)
@romhacking
Copy link

I am getting an error

You changed the script correctly, but I don't have this file to fix (you didn't even write what game you use and the path to the file in the resources). It is only clear that the file is shorter than expected. Try changing header_size = 0x100 to header_size = 0x80.

@Rypie109
Copy link

Rypie109 commented Mar 27, 2025

Yeah probably should have. Game is cartoon network punchtime explosion. Here is the extracted DXT using the wii_to_dxt.py script. Header size change did not work. Heres a link to a mega folder with multiple dxt files https://mega.nz/folder/mjBFAIQS#Ep75_7ydW3twTkbOIsEz4Q

@romhacking
Copy link

@Rypie109 I didn't realize that my wii_to_dxt.py script was cutting off texture data. Updated wii_to_dxt.py script:

import sys
from pathlib import Path

PALETTE_SIZE = 0x400 # 0x200

def wii_to_dxt(path):
    print(f"# {path}")
    data = path.read_bytes()
    ind = -1
    while True:
        ind = data.find(bytes.fromhex("30 81 80 00"), ind + 1)
        if ind == -1:
            break
        w, h, mips_count = 1 << data[ind + 0xA], 1 << data[ind + 0xB], data[ind + 0x1F]
        header_size = 0x100
        if mips_count == 0:
            mips_count = 1
            header_size = 0x80
        size = header_size + PALETTE_SIZE + sum((w // (1 << i) * h // (1 << i) for i in range(mips_count)))
        p = path.with_name(path.stem + f"_{ind:06X}.dxt")
        p.write_bytes(data[ind : ind + size])
        print(f"> {p}")

if __name__ == '__main__':
    if len(sys.argv) == 1:
        path = Path(".")
        for p in path.glob("*.wii"):
            wii_to_dxt(p)
    else:
        p = Path(sys.argv[1])
        wii_to_dxt(p)

Updated dxt_to_png.py script, which now supports RGBA8888, RGB565, gray (I didn't figure it out) palette formats at the same time:

import sys
from pathlib import Path
from struct import unpack
from collections import OrderedDict

from PIL import Image

HEADER_SIZE = 0x80

class Texture:
    """
    [Wii] Color Index 8-bits (C8) + Palette RGBA8
    """

    def __init__(self, path, offset, w, h, pal_offset, pal_format=0):
        self.path = path
        self.offset = offset
        self.w = w
        self.h = h
        self.pal_offset = pal_offset
        self.pal_format = pal_format

    def to_png(self, png_path):
        im = Image.new("RGBA", (self.w, self.h))
        pixels = im.load()

        with open(self.path, "rb") as f:
            f.seek(self.offset)
            b = f.read(self.w * self.h)

            f.seek(self.pal_offset)
            b_pal = f.read(256 * 4)

            pal = []
            for i in range(256):
                if self.pal_format == 0:
                    index = i * 2
                    pal.append((b_pal[index+1],        # R
                                b_pal[index+0],        # G
                                b_pal[index+1+0x200],  # B
                                b_pal[index+0+0x200])) # A
                elif self.pal_format == 1:
                    c = (b_pal[i * 2] << 8) | b_pal[i * 2 + 1]
                    c_r = (c & 0xF800) >> 8
                    c_g = (c & 0x07E0) >> 3
                    c_b = (c & 0x001F) << 3
                    pal.append((c_r, c_g, c_b, 0xFF))
                else:
                    pal.append((i, i, i, 0xFF))

            tw = 8
            th = 4
            bytes_in_tile = tw * th
            bytes_in_row = self.w * th

            x = 0
            y = 0
            for i in range(self.w * self.h):
                index = i // bytes_in_row * bytes_in_row
                index += x // tw * bytes_in_tile
                index += (y % th) * tw + x % tw

                c = b[index]
                pixels[x, y] = pal[c]
                x += 1
                if x == self.w:
                    x = 0
                    y += 1

        im.save(png_path, "PNG")
        print("Unpacked: {} -> {}".format(self.path, png_path))

    def from_png(self, bin_path, base_path=None):
        im = Image.open(self.path).convert("RGBA")
        pixels = im.load()
        self.w, self.h = im.size

        if not base_path:
            # Raw
            self.offset = 0
            self.pal_offset = self.w * self.h
            b = bytearray(self.w * self.h + 256 * 4)
        else:
            with open(base_path, "rb") as f:
                b = bytearray(f.read())

        pal = OrderedDict()
        pal_i = 0

        b_img = bytearray(self.w * self.h)

        tw = 8
        th = 4
        bytes_in_tile = tw * th
        bytes_in_row = self.w * th

        i = 0
        for y in range(self.h):
            for x in range(self.w):
                c = pixels[x, y]
                if c not in pal:
                    if len(pal) >= 256:
                        print("Too many colors! Max: 256.", self.path)
                        sys.exit(1)
                    else:
                        pal[c] = pal_i
                        pal_i += 1

                index = i // bytes_in_row * bytes_in_row
                index += x // tw * bytes_in_tile
                index += (y % th) * tw + x % tw

                b_img[index] = pal[c]
                i += 1

        b[self.offset: self.offset + self.w * self.h] = b_img

        pal = list(pal.keys())
        pal += [(0, 0, 0, 0)] * (256 - len(pal)) # Unused colors

        for i in range(256):
            index1 = self.pal_offset + i * 2
            index2 = self.pal_offset + i * 2 + 0x200
            cR, cG, cB, cA = pal[i]
            b[index2+0] = cA
            b[index2+1] = cB
            b[index1+0] = cG
            b[index1+1] = cR

        with open(bin_path, "wb") as f:
            f.write(b)
        print("Packed: {} -> {}".format(self.path, bin_path))

def dxt_to_png(path):
    with open(path, "rb") as f:
        f.seek(0x0A)
        w, h = unpack("<BB", f.read(2))
        w = 1 << w
        h = 1 << h
        f.seek(0x1F)
        mips_count = f.read(1)[0]
        f.seek(0x04)
        pal_format = (f.read(1)[0] >> 5) & 3
    if mips_count == 0:
        png_path = path.with_name(path.name + ".png")
        Texture(path, HEADER_SIZE, w, h, HEADER_SIZE + w * h).to_png(png_path)
    else:
        header_size = 0x100
        pal_offset = header_size + sum((w // (1 << i) * h // (1 << i) for i in range(mips_count)))
        print(f"Palette offset: ${pal_offset:X}")
        offset = header_size
        for i in range(mips_count):
            w_cur = w // (1 << i)
            h_cur = h // (1 << i)
            png_path = path.with_name(path.name + f"_{i}.png")
            print(f"Width, Height: {w_cur}, {h_cur}. Position: ${offset:X}. Size: ${w_cur*h_cur:X}.")
            Texture(path, offset, w_cur, h_cur, pal_offset, pal_format).to_png(png_path)
            offset += w_cur * h_cur

def png_to_dxt(path):
    bin_path = path.with_suffix('')
    with open(bin_path, "rb") as f:
        f.seek(0x0A)
        w, h = unpack("<BB", f.read(2))
        w = 1 << w
        h = 1 << h
    Texture(path, HEADER_SIZE, None, None, HEADER_SIZE + w * h).from_png(bin_path, bin_path)

if __name__ == '__main__':
    if len(sys.argv) == 1:
        path = Path(".")
        for p in path.glob("*.dxt"):
            dxt_to_png(p)
    else:
        p = Path(sys.argv[1])
        if p.suffix == ".png":
            png_to_dxt(p)
        else:
            dxt_to_png(p)

@Rypie109
Copy link

Thank you so much! It works. Curious though, you mentioned using the software to reduce the colors for repack, but how would I get 5 different .png files extracted from 1 .dxt file back into that .dxt file after modification? Does the script have support for that? Coraline extracts 1 single image from each .dxt, making it simpler

@romhacking
Copy link

how would I get 5 different .png files extracted from 1 .dxt file back into that .dxt file after modification? Does the script have support for that?

The updated script does not support PNG -> "DXT". It is not difficult to take the main texture and create smaller versions of it, but I am not going to spend time on it.

@Rypie109
Copy link

Got it. That's fine, already helped me plenty on this as it is. I appreciate it!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment