Last active
October 5, 2024 12:52
-
-
Save FelixWolf/f0f1b47a531f5d80f012e4c7a5647163 to your computer and use it in GitHub Desktop.
3DS icon generator #Homebrew #3DS
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env python3 | |
""" | |
Copyright (c) 2024 Kyler "Félix" Eastridge | |
This software is provided 'as-is', without any express or implied | |
warranty. In no event will the authors be held liable for any damages | |
arising from the use of this software. | |
Permission is granted to anyone to use this software for any purpose, | |
including commercial applications, and to alter it and redistribute it | |
freely, subject to the following restrictions: | |
1. The origin of this software must not be misrepresented; you must not | |
claim that you wrote the original software. If you use this software | |
in a product, an acknowledgment in the product documentation would be | |
appreciated but is not required. | |
2. Altered source versions must be plainly marked as such, and must not be | |
misrepresented as being the original software. | |
3. This notice may not be removed or altered from any source distribution. | |
""" | |
import struct | |
import io | |
from PIL import Image | |
def unpackFromStream(strct, stream): | |
return strct.unpack_from(stream.read(strct.size)) | |
# Icon decoding | |
def rgb888_to_rgb565(rgb888): | |
r, g, b = rgb888 | |
return ((r >> 3) << 11) | ((g >> 2) << 5) | (b >> 3) | |
def rgb565_to_rgb888(rgb565): | |
r = (rgb565 >> 11) & 0x1F | |
g = (rgb565 >> 5) & 0x3F | |
b = rgb565 & 0x1F | |
r = (r << 3) | (r >> 2) | |
g = (g << 2) | (g >> 4) | |
b = (b << 3) | (b >> 2) | |
return (r, g, b) | |
def interleave_bits(x, y): | |
result = 0 | |
for i in range(3): # 8x8 tile, so max of 3 bits for each coordinate | |
result |= ((x >> i) & 1) << (2 * i) # Interleave x bits | |
result |= ((y >> i) & 1) << (2 * i + 1) # Interleave y bits | |
return result | |
def decode_tiled_image(buffer): | |
tile_width, tile_height = 8, 8 | |
order = [] | |
# Generate Morton/Z-order for an 8x8 tile | |
for y in range(tile_height): | |
for x in range(tile_width): | |
order.append(interleave_bits(x, y)) | |
# Reorder the buffer according to Morton order | |
decoded_buffer = [buffer[order[i]] for i in range(len(order))] | |
return decoded_buffer | |
def reverse_morton_order(buffer): | |
order = [] | |
# Generate Morton/Z-order for an 8x8 tile | |
for y in range(8): | |
for x in range(8): | |
order.append(interleave_bits(x, y)) | |
# Reverse the Morton ordering | |
reversed_buffer = [0] * len(buffer) | |
for i, pixel in enumerate(buffer): | |
reversed_buffer[order[i]] = pixel | |
return reversed_buffer | |
#https://www.3dbrew.org/wiki/SMDH | |
# Magic, Version, Reserved | |
sIconHeader = struct.Struct(">4s H 2x") | |
# ShortName, LongName, Publisher | |
sApplicationTitle = struct.Struct(">128s 256s 128s") | |
# CERO, ESRB, Reserved, USK, PEGI GEN, Reserved, PEGI PRT, PEGI PRT, PEGI BBFC, COB, GRB, CGSRR, Reserved... | |
sApplicationRating = struct.Struct(">16B") | |
# RegionMask, JoinGameID, JoinGameMask, FlagsMask, EULAVersion, Reserved, ThumbnailFrame, CecId | |
sApplicationSettings = struct.Struct(">I IQ I 2B 2x f I") | |
sIconTile = struct.Struct("<64H") | |
class CTRIcon: | |
# Languages | |
LANG_JP = 0 | |
LANG_EN = 1 | |
LANG_FR = 2 | |
LANG_GE = 3 | |
LANG_IT = 4 | |
LANG_SP = 5 | |
LANG_CN = 6 | |
LANG_KR = 7 | |
LANG_DU = 8 | |
LANG_PO = 9 | |
LANG_RU = 10 | |
LANG_TW = 11 | |
# LANG_RESERVED = 12 | |
# LANG_RESERVED = 13 | |
# LANG_RESERVED = 14 | |
# LANG_RESERVED = 15 | |
# LANG_RESERVED = 16 | |
LANG_MAX = 16 | |
# Ratings | |
RATING_CERO = 0 | |
RATING_ESRB = 1 | |
# RATING_RESERVED = 2 | |
RATING_USK = 3 | |
RATING_PEGIGEN = 4 | |
# RATING_RESERVED = 5 | |
RATING_PEGIPRT = 6 | |
RATING_PEGIBBFC = 7 | |
RATING_COB = 8 | |
RATING_GRB = 9 | |
RATING_CGSRR = 10 | |
# RATING_RESERVED = 11 | |
# RATING_RESERVED = 12 | |
# RATING_RESERVED = 13 | |
# RATING_RESERVED = 14 | |
# RATING_RESERVED = 15 | |
RATING_MAX = 16 | |
RATING_NONE = 0x20 | |
RATING_PENDING = 0x40 | |
RATING_SET = 0x80 | |
# Regions | |
REGION_JP = 0x01 | |
REGION_NA = 0x02 | |
REGION_EU = 0x04 | |
REGION_AU = 0x08 | |
REGION_CN = 0x10 | |
REGION_KR = 0x20 | |
REGION_TW = 0x40 | |
# Flags | |
FLAG_ULCD = 0x0004 | |
FLAG_AGREEEULA = 0x0008 | |
FLAG_AUTOSAVE = 0x0010 | |
FLAG_AUTOBOOT = 0x0002 | |
FLAG_EXBANNER = 0x0020 | |
FLAG_SAVEDATA = 0x0080 | |
FLAG_DISABLESAVEDATABACKUP = 0x0400 | |
FLAG_RATINGREQUIRED = 0x0040 | |
FLAG_SNAKEONLY = 0x1000 | |
FLAG_ENABLEMIIVERSEJUMPARGS = 0x0100 | |
MAGIC = b"SMDH" | |
def __init__(self): | |
self.version = 0 | |
self.shortName = [""] * self.LANG_MAX | |
self.longName = [""] * self.LANG_MAX | |
self.publisher = [""] * self.LANG_MAX | |
self.ratings = [0] * self.RATING_MAX | |
self.region = 0 | |
self.joinGameID = 0 | |
self.joinGameMask = 0 | |
self.flags = 0 | |
self.EULAVersion = [0, 0] | |
self.thumbnailFrame = 0 | |
self.cecUniqueID = 0 | |
self.ISBN = "" | |
self.HTDJH = "" | |
self.XCSZ_L = "" | |
self.XCSZ_R = "" | |
self.littleIcon = Image.new("RGB", (24, 24)) | |
self.bigIcon = Image.new("RGB", (48, 48)) | |
# Decode | |
@classmethod | |
def fromBytes(cls, data): | |
return cls.fromStream(io.BytesIO(data)) | |
@classmethod | |
def fromStream(cls, f): | |
self = cls() | |
magic, version = unpackFromStream(sIconHeader, f) | |
if magic != self.MAGIC: | |
raise ValueError("Magic did not match!") | |
if version != 0: | |
raise ValueError("Unknown version") | |
self.version = 0 | |
for i in range(self.LANG_MAX): | |
short, long, publisher = unpackFromStream(sApplicationTitle, f) | |
if i == self.LANG_CN: | |
#Special case for China region | |
self.ISBN = long[218:235].decode("ascii").rstrip("\0") | |
self.HTDJH = long[235:247].decode("ascii").rstrip("\0") | |
self.XCSZ_L = long[247:251].decode("ascii").rstrip("\0") | |
self.XCSZ_R = long[251:255].decode("ascii").rstrip("\0") | |
long = long[:216] | |
self.shortName[i], self.longName[i], self.publisher[i] = [ | |
x.decode("UTF-16-LE").rstrip("\0") | |
for x in [short, long, publisher] | |
] | |
ratings = unpackFromStream(sApplicationRating, f) | |
for i in range(self.RATING_MAX): | |
self.ratings[i] = ratings[i] | |
self.region, self.joinGameID, self.joinGameMask, \ | |
self.flags, EULAVersionA, EULAVersionB, self.thumbnailFrame, \ | |
self.cecUniqueID = unpackFromStream(sApplicationSettings, f) | |
self.EULAVersion = [EULAVersionA, EULAVersionB] | |
f.read(0x8) # Reserved | |
tileBuffer = Image.new("RGB", (8,8)) | |
tileOffset = 0 | |
for i in range(0, self.littleIcon.size[0] * self.littleIcon.size[1], 8 * 8): | |
tile_data_rgb565 = unpackFromStream(sIconTile, f) | |
buffer = [rgb565_to_rgb888(pixel) for pixel in tile_data_rgb565] | |
decoded_buffer = decode_tiled_image(buffer) | |
tileBuffer.putdata(decoded_buffer) | |
self.littleIcon.paste(tileBuffer, ((tileOffset % 3) * 8, (tileOffset // 3) * 8)) | |
tileOffset += 1 | |
tileOffset = 0 | |
for i in range(0, self.bigIcon.size[0] * self.bigIcon.size[1], 8 * 8): | |
tile_data_rgb565 = unpackFromStream(sIconTile, f) | |
buffer = [rgb565_to_rgb888(pixel) for pixel in tile_data_rgb565] | |
decoded_buffer = decode_tiled_image(buffer) | |
tileBuffer.putdata(decoded_buffer) | |
self.bigIcon.paste(tileBuffer, ((tileOffset % 6) * 8, (tileOffset // 6) * 8)) | |
tileOffset += 1 | |
return self | |
# Encode | |
def __bytes__(self): | |
return self.toBytes() | |
def toBytes(self): | |
f = io.BytesIO() | |
self.toStream(f) | |
return f.getvalue() | |
def toStream(self, f): | |
f.write(sIconHeader.pack(self.MAGIC, self.version)) | |
for i in range(self.LANG_MAX): | |
short = self.shortName[i].encode("UTF-16-LE").ljust(128, b"\0")[:128] | |
if i == self.LANG_CN: | |
long = self.longName[i].encode("UTF-16-LE").ljust(218, b"\0")[:218] \ | |
+ self.HTDJH.encode("ascii").rjust(17, b"\0")[:17] \ | |
+ self.XCSZ_L.encode("ascii").rjust(12, b"\0")[:12] \ | |
+ self.XCSZ_L.encode("ascii").rjust(4, b"\0")[:4] \ | |
+ self.XCSZ_R.encode("ascii").rjust(4, b"\0")[:4] | |
else: | |
long = self.longName[i].encode("UTF-16-LE").ljust(256, b"\0")[:256] | |
publisher = self.publisher[i].encode("UTF-16-LE").ljust(128, b"\0")[:128] | |
f.write(sApplicationTitle.pack(short, long, publisher)) | |
f.write(sApplicationRating.pack(*self.ratings)) | |
f.write(sApplicationSettings.pack( | |
self.region, self.joinGameID, self.joinGameMask, | |
self.flags, self.EULAVersion[0], self.EULAVersion[1], self.thumbnailFrame, | |
self.cecUniqueID | |
)) | |
f.write(b"\0" * 8) # Reserved | |
if self.littleIcon.size != (24, 24) or self.littleIcon.mode != "RGB": | |
raise ValueError("LittleIcon must be RGB 24x24") | |
tileOffset = 0 | |
for tile_y in range(0, 24, 8): | |
for tile_x in range(0, 24, 8): | |
tile = self.littleIcon.crop((tile_x, tile_y, tile_x + 8, tile_y + 8)) | |
tile_data_rgb888 = list(tile.getdata()) | |
for pixel in reverse_morton_order(tile_data_rgb888): | |
rgb565 = rgb888_to_rgb565(pixel) | |
f.write(rgb565.to_bytes(2, byteorder="little")) | |
tileOffset += 1 | |
if self.bigIcon.size != (48, 48) or self.bigIcon.mode != "RGB": | |
raise ValueError("BigIcon must be RGB 48x48") | |
tileOffset = 0 | |
for tile_y in range(0, 48, 8): | |
for tile_x in range(0, 48, 8): | |
tile = self.bigIcon.crop((tile_x, tile_y, tile_x + 8, tile_y + 8)) | |
tile_data_rgb888 = list(tile.getdata()) | |
for pixel in reverse_morton_order(tile_data_rgb888): | |
rgb565 = rgb888_to_rgb565(pixel) | |
f.write(rgb565.to_bytes(2, byteorder="little")) | |
tileOffset += 1 | |
# Dicting | |
@classmethod | |
def fromDict(cls, d): | |
self = cls() | |
if d.get("BigIcon", None): | |
if d["BigIcon"].startswith("0x"): | |
Image.frombytes("RGB", (48, 48), bytes.fromhex(d["BigIcon"][2:])) | |
else: | |
self.bigIcon = Image.open(d["BigIcon"]) | |
if d.get("LittleIcon", None): | |
if d["LittleIcon"].startswith("0x"): | |
Image.frombytes("RGB", (24, 24), bytes.fromhex(d["LittleIcon"][2:])) | |
else: | |
self.littleIcon = Image.open(d["LittleIcon"]) | |
self.joinGameID = d.get("JoinGameID", 0) | |
self.joinGameMask = d.get("JoinGameMask", 0) | |
self.cecUniqueID = d.get("CecUniqueID", 0) | |
self.region = d.get("Region", 0) | |
if d.get("Ulcd", False): | |
self.flags |= self.FLAG_ULCD | |
if d.get("AgreeEula", False): | |
self.flags |= self.FLAG_AGREEEULA | |
if d.get("AutoSave", False): | |
self.flags |= self.FLAG_AUTOSAVE | |
if d.get("AutoBoot", False): | |
self.flags |= self.FLAG_AUTOBOOT | |
if d.get("ExBanner", False): | |
self.flags |= self.FLAG_EXBANNER | |
if d.get("SaveData", False): | |
self.flags |= self.FLAG_SAVEDATA | |
if d.get("DisableSaveDataBackup", False): | |
self.flags |= self.FLAG_DISABLESAVEDATABACKUP | |
if d.get("SNAKEOnly", False): | |
self.flags |= self.FLAG_SNAKEONLY | |
if d.get("EnableMiiverseJumpArgs", False): | |
self.flags |= self.FLAG_ENABLEMIIVERSEJUMPARGS | |
self.longName[self.LANG_JP] = d.get("JPLongName", "") | |
self.longName[self.LANG_EN] = d.get("ENLongName", "") | |
self.longName[self.LANG_FR] = d.get("FRLongName", "") | |
self.longName[self.LANG_GE] = d.get("GELongName", "") | |
self.longName[self.LANG_IT] = d.get("ITLongName", "") | |
self.longName[self.LANG_SP] = d.get("SPLongName", "") | |
self.longName[self.LANG_DU] = d.get("DULongName", "") | |
self.longName[self.LANG_PO] = d.get("POLongName", "") | |
self.longName[self.LANG_RU] = d.get("RULongName", "") | |
self.longName[self.LANG_CN] = d.get("CNLongName", "") | |
self.longName[self.LANG_KR] = d.get("KRLongName", "") | |
self.longName[self.LANG_TW] = d.get("TWLongName", "") | |
self.shortName[self.LANG_JP] = d.get("JPShortName", "") | |
self.shortName[self.LANG_EN] = d.get("ENShortName", "") | |
self.shortName[self.LANG_FR] = d.get("FRShortName", "") | |
self.shortName[self.LANG_GE] = d.get("GEShortName", "") | |
self.shortName[self.LANG_IT] = d.get("ITShortName", "") | |
self.shortName[self.LANG_SP] = d.get("SPShortName", "") | |
self.shortName[self.LANG_DU] = d.get("DUShortName", "") | |
self.shortName[self.LANG_PO] = d.get("POShortName", "") | |
self.shortName[self.LANG_RU] = d.get("RUShortName", "") | |
self.shortName[self.LANG_CN] = d.get("CNShortName", "") | |
self.shortName[self.LANG_KR] = d.get("KRShortName", "") | |
self.shortName[self.LANG_TW] = d.get("TWShortName", "") | |
self.publisher[self.LANG_JP] = d.get("JPPublisher", "") | |
self.publisher[self.LANG_EN] = d.get("ENPublisher", "") | |
self.publisher[self.LANG_FR] = d.get("FRPublisher", "") | |
self.publisher[self.LANG_GE] = d.get("GEPublisher", "") | |
self.publisher[self.LANG_IT] = d.get("ITPublisher", "") | |
self.publisher[self.LANG_SP] = d.get("SPPublisher", "") | |
self.publisher[self.LANG_DU] = d.get("DUPublisher", "") | |
self.publisher[self.LANG_PO] = d.get("POPublisher", "") | |
self.publisher[self.LANG_RU] = d.get("RUPublisher", "") | |
self.publisher[self.LANG_CN] = d.get("CNPublisher", "") | |
self.publisher[self.LANG_KR] = d.get("KRPublisher", "") | |
self.publisher[self.LANG_TW] = d.get("TWPublisher", "") | |
if d.get("RatingRequired", False): | |
self.flags |= self.FLAG_RATINGREQUIRED | |
self.ratings[self.RATING_CERO] = d.get("CERO", "") | |
self.ratings[self.RATING_ESRB] = d.get("ESRB", "") | |
self.ratings[self.RATING_USK] = d.get("USK", "") | |
self.ratings[self.RATING_PEGIGEN] = d.get("PEGI_GEN", "") | |
self.ratings[self.RATING_PEGIPRT] = d.get("PEGI_PRT", "") | |
self.ratings[self.RATING_PEGIBBFC] = d.get("PEGI_BBFC", "") | |
self.ratings[self.RATING_COB] = d.get("COB", "") | |
self.ratings[self.RATING_GRB] = d.get("GRB", "") | |
self.ratings[self.RATING_CGSRR] = d.get("CGSRR", "") | |
self.ISBN = d.get("ISBN", "") | |
self.HTDJH = d.get("HTDJH", "") | |
self.XCSZ_L = d.get("XCSZ_L", "") | |
self.XCSZ_R = d.get("XCSZ_R", "") | |
return self | |
def toDict(self, bigIcon = None, littleIcon = None): | |
if bigIcon == None: | |
if self.bigIcon.mode == "RGB" and self.bigIcon.size == (48, 48): | |
pixel_data = list(self.bigIcon.getdata()) | |
if not all(pixel == (0, 0, 0) for pixel in pixel_data): | |
bigIcon = "0x" + self.bigIcon.tobytes().hex().upper() | |
if littleIcon == None: | |
if self.littleIcon.mode == "RGB" and self.littleIcon.size == (24, 24): | |
pixel_data = list(self.littleIcon.getdata()) | |
if not all(pixel == (0, 0, 0) for pixel in pixel_data): | |
littleIcon = "0x" + self.littleIcon.tobytes().hex().upper() | |
return { | |
**({"BigIcon": bigIcon} if bigIcon else {}), | |
**({"LittleIcon": littleIcon} if littleIcon else {}), | |
"JoinGameID": self.joinGameID, | |
"JoinGameMask": self.joinGameMask, | |
"CecUniqueID": self.cecUniqueID, | |
"Region": self.region, | |
"Ulcd": self.flags & self.FLAG_ULCD == self.FLAG_ULCD, | |
"AgreeEula": self.flags & self.FLAG_AGREEEULA == self.FLAG_AGREEEULA, | |
"AutoSave": self.flags & self.FLAG_AUTOSAVE == self.FLAG_AUTOSAVE, | |
"AutoBoot": self.flags & self.FLAG_AUTOBOOT == self.FLAG_AUTOBOOT, | |
"ExBanner": self.flags & self.FLAG_EXBANNER == self.FLAG_EXBANNER, | |
"SaveData": self.flags & self.FLAG_SAVEDATA == self.FLAG_SAVEDATA, | |
"DisableSaveDataBackup": self.flags & self.FLAG_DISABLESAVEDATABACKUP == self.FLAG_DISABLESAVEDATABACKUP, | |
"SNAKEOnly": self.flags & self.FLAG_SNAKEONLY == self.FLAG_SNAKEONLY, | |
"EnableMiiverseJumpArgs": self.flags & self.FLAG_ENABLEMIIVERSEJUMPARGS == self.FLAG_ENABLEMIIVERSEJUMPARGS, | |
"JPLongName": self.longName[self.LANG_JP], | |
"ENLongName": self.longName[self.LANG_EN], | |
"FRLongName": self.longName[self.LANG_FR], | |
"GELongName": self.longName[self.LANG_GE], | |
"ITLongName": self.longName[self.LANG_IT], | |
"SPLongName": self.longName[self.LANG_SP], | |
"DULongName": self.longName[self.LANG_DU], | |
"POLongName": self.longName[self.LANG_PO], | |
"RULongName": self.longName[self.LANG_RU], | |
"CNLongName": self.longName[self.LANG_CN], | |
"KRLongName": self.longName[self.LANG_KR], | |
"TWLongName": self.longName[self.LANG_TW], | |
"JPShortName": self.shortName[self.LANG_JP], | |
"ENShortName": self.shortName[self.LANG_EN], | |
"FRShortName": self.shortName[self.LANG_FR], | |
"GEShortName": self.shortName[self.LANG_GE], | |
"ITShortName": self.shortName[self.LANG_IT], | |
"SPShortName": self.shortName[self.LANG_SP], | |
"DUShortName": self.shortName[self.LANG_DU], | |
"POShortName": self.shortName[self.LANG_PO], | |
"RUShortName": self.shortName[self.LANG_RU], | |
"CNShortName": self.shortName[self.LANG_CN], | |
"KRShortName": self.shortName[self.LANG_KR], | |
"TWShortName": self.shortName[self.LANG_TW], | |
"JPPublisher": self.publisher[self.LANG_JP], | |
"ENPublisher": self.publisher[self.LANG_EN], | |
"FRPublisher": self.publisher[self.LANG_FR], | |
"GEPublisher": self.publisher[self.LANG_GE], | |
"ITPublisher": self.publisher[self.LANG_IT], | |
"SPPublisher": self.publisher[self.LANG_SP], | |
"DUPublisher": self.publisher[self.LANG_DU], | |
"POPublisher": self.publisher[self.LANG_PO], | |
"RUPublisher": self.publisher[self.LANG_RU], | |
"CNPublisher": self.publisher[self.LANG_CN], | |
"KRPublisher": self.publisher[self.LANG_KR], | |
"TWPublisher": self.publisher[self.LANG_TW], | |
"RatingRequired": self.flags & self.FLAG_RATINGREQUIRED == self.FLAG_RATINGREQUIRED, | |
"CERO": self.ratings[self.RATING_CERO], | |
"ESRB": self.ratings[self.RATING_ESRB], | |
"USK": self.ratings[self.RATING_USK], | |
"PEGI_GEN": self.ratings[self.RATING_PEGIGEN], | |
"PEGI_PRT": self.ratings[self.RATING_PEGIPRT], | |
"PEGI_BBFC": self.ratings[self.RATING_PEGIBBFC], | |
"COB": self.ratings[self.RATING_COB], | |
"GRB": self.ratings[self.RATING_GRB], | |
"CGSRR": self.ratings[self.RATING_CGSRR], | |
"ISBN": self.ISBN, | |
"HTDJH": self.HTDJH, | |
"XCSZ_L": self.XCSZ_L, | |
"XCSZ_R": self.XCSZ_R, | |
} | |
if __name__ == "__main__": | |
import argparse | |
import json | |
parser = argparse.ArgumentParser( | |
prog='IconGen', | |
description='Generate a CTR ICN file') | |
parser.add_argument('spec', help="Input JSON", type=argparse.FileType('r')) | |
parser.add_argument('out', help="Output ICN", type=argparse.FileType('wb')) | |
args = parser.parse_args() | |
icn = CTRIcon.fromDict(json.load(args.spec)) | |
icn.toStream(args.out) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment