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)
@Rypie109
Copy link

@romhacking I have some questions. What made you create this script? Any motive? Are you also interested in datamining this game?

@romhacking
Copy link

@Rypie109 The script dxt_to_png.py was made for the Russian translation of the game Coraline, that's all. I mostly do small hacks for NES games.

@Rypie109
Copy link

@romhacking That is very interesting! I am assuming this was a fan translation?

@romhacking
Copy link

@Rypie109 Of course it's an unofficial translation. I only made this script.

@Rypie109
Copy link

Hi, it's me again. Is there a way to make the dxt_to_png script work with a game that has the exact same form
l_ben1_t_2F1100
at, yet has a slightly different formatting? Here is a file I copied out, same header same everthing, and when converted it looks like this. Heres a sample dxt file, just rename it from .svg to .dxt
l_ben1_t_2F1100 dxt

@Rypie109
Copy link

The image I attached is not properly converted, open it in a new tab or save it to see

@romhacking
Copy link

Hi, it's me again. Is there a way to make the dxt_to_png script work with a game that has the exact same form ![l_ben1_t_2F1100](https://private-user-images.githubusercontent.com/162040187/398184470-dd6c86d1-8e14-4dff-a39a-d09fbf729d80.svg?

This file contains several textures (mipmaps). The palette is strange. First 0x000-0x1FF palette bytes - 256 colors of 2 bytes in RGB565 Big-Endian format, then 0x200-0x3FF palette bytes - 128 colors of 4 bytes in RGBA8888 Big-Endian format, which coincide with color indices 128-255 of the first palette, most likely garbage (the developers wrote the palette in RGBA8888 format, then wrote over it in RGB565 format). I changed the script for this file, the color channels may be rearranged.
Change in dxt_to_png.py script from line 57:

            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

New code:

            pal = []
            for i in range(256):
                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))

Also change from line 148:

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)

New code:

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]
    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).to_png(png_path)
            offset += w_cur * h_cur

398184470-dd6c86d1-8e14-4dff-a39a-d09fbf729d80 dxt_0

@Rypie109
Copy link

Wow thank you so dang much. Appreciate it.

@Rypie109
Copy link

I am getting an error.

C:\ddrive\romfs4cnptexl\DATA\files\l_ben1\Tex>dxt_to_png.py
Palette offset: $2BA0
Width, Height: 128, 64. Position: $100. Size: $2000.
Traceback (most recent call last):
  File "C:\ddrive\romfs4cnptexl\DATA\files\l_ben1\Tex\dxt_to_png.py", line 185, in <module>
    dxt_to_png(p)
  File "C:\ddrive\romfs4cnptexl\DATA\files\l_ben1\Tex\dxt_to_png.py", line 169, in dxt_to_png
    Texture(path, offset, w_cur, h_cur, pal_offset).to_png(png_path)
  File "C:\ddrive\romfs4cnptexl\DATA\files\l_ben1\Tex\dxt_to_png.py", line 59, in to_png
    c = (b_pal[i * 2] << 8) | b_pal[i * 2 + 1]
         ~~~~~^^^^^^^
IndexError: index out of range

@Rypie109
Copy link

Rypie109 commented Mar 27, 2025

Heres the script edited to the new code.

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):
                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))

            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]
    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).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

Removed unnecessary sections so github doesnt take any of it as markdown

@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