|
#!/usr/bin/env python3 |
|
|
|
# Requirements: |
|
# pip3 install libscrc pillow |
|
|
|
# For example files, see Wordle DS: |
|
# https://github.com/Epicpkmn11/WordleDS/tree/main/resources/icon |
|
# python3 dsibanner.py -i icon.*.png -d icon.0.png -t Wordle DS;Pk11 -a icon.json -o banner.bin |
|
|
|
""" |
|
This is free and unencumbered software released into the public domain. |
|
|
|
Anyone is free to copy, modify, publish, use, compile, sell, or |
|
distribute this software, either in source code form or as a compiled |
|
binary, for any purpose, commercial or non-commercial, and by any |
|
means. |
|
|
|
In jurisdictions that recognize copyright laws, the author or authors |
|
of this software dedicate any and all copyright interest in the |
|
software to the public domain. We make this dedication for the benefit |
|
of the public at large and to the detriment of our heirs and |
|
successors. We intend this dedication to be an overt act of |
|
relinquishment in perpetuity of all present and future rights to this |
|
software under copyright law. |
|
|
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, |
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF |
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. |
|
IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR |
|
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, |
|
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR |
|
OTHER DEALINGS IN THE SOFTWARE. |
|
|
|
For more information, please refer to <http://unlicense.org/> |
|
""" |
|
|
|
import json |
|
|
|
from argparse import ArgumentParser, FileType |
|
from libscrc import modbus |
|
from PIL import Image |
|
from struct import pack |
|
|
|
|
|
def convertIcon(icon: Image.Image) -> bytes: |
|
if icon.size != (32, 32): |
|
raise Exception("Icon not 32×32") |
|
elif icon.mode != "P": |
|
raise Exception("Icon not paletted") |
|
elif len(icon.palette.palette) > 16 * 3: # * 3 for RGB |
|
raise Exception("Icon has too many colors") |
|
|
|
data = b"" |
|
for ty in range(4): |
|
for tx in range(4): |
|
for y in range(8): |
|
for x in range(4): |
|
byte = icon.getpixel((tx * 8 + x * 2, ty * 8 + y)) |
|
byte |= icon.getpixel((tx * 8 + x * 2 + 1, ty * 8 + y)) << 4 |
|
data += pack("B", byte) |
|
|
|
return data |
|
|
|
|
|
def convertPalette(icon: Image.Image) -> bytes: |
|
if icon.mode != "P": |
|
raise Exception("Icon not paletted") |
|
elif len(icon.palette.palette) > 16 * 3: # * 3 for RGB |
|
raise Exception("Icon has too many colors") |
|
|
|
data = b"" |
|
for i in range(len(icon.palette.palette) // 3): |
|
r, g, b = [round(x * 0x1f / 0xff) & 0x1f for x in icon.palette.palette[i * 3:i * 3 + 3]] |
|
data += pack("<H", b << 10 | g << 5 | r) |
|
|
|
return data |
|
|
|
|
|
def dsibanner(icons, titles, animation, output, dsIcon=None): |
|
""" |
|
Creates a DS(i) banner file |
|
|
|
Parameters |
|
---------- |
|
icons |
|
list of Pillow Images for the icon |
|
titles |
|
list of titles (ja, en, fr, de, it, es, cn, kr) (; = newline) |
|
animation |
|
animation sequence in a list of lists as follows: |
|
[duration (frames), icon index, palette index, flip vertically (bool), flip horizontally (bool)] |
|
output |
|
"wb+" file to output to |
|
dsIcon |
|
(optional) Pillow Image of icon to use for DS mode, else icons[0] will be used |
|
""" |
|
|
|
# A couple sanity checks |
|
if len(icons) > 8: |
|
raise Exception("Too many icon frames") |
|
elif len(titles) > 8: |
|
raise Exception("Too many titles") |
|
elif animation and len(animation) > 0x40: |
|
raise Exception("Animaition sequence is too long") |
|
|
|
if len(icons) > 1: |
|
version = 0x0103 |
|
titleCount = 8 |
|
elif len(titles) == 8: |
|
version = 0x0003 |
|
titleCount = 8 |
|
elif len(titles) == 7: |
|
version = 0x0002 |
|
titleCount = 7 |
|
else: |
|
version = 0x0001 |
|
titleCount = 6 |
|
|
|
# Write icon(s) and palette(s) |
|
output.seek(0x20) |
|
output.write(convertIcon(dsIcon if dsIcon else icons[0])) |
|
output.write(convertPalette(dsIcon if dsIcon else icons[0])) |
|
if version == 0x0103: # DSi (animated icon) |
|
output.seek(0x1240) |
|
for icon in [convertIcon(icon) for icon in icons]: |
|
output.write(icon) |
|
|
|
output.seek(0x2240) |
|
palettes = [] |
|
for palette in [convertPalette(icon) for icon in icons]: |
|
if palette not in palettes: |
|
palettes.append(palette) |
|
for palette in palettes: |
|
output.write(palette.ljust(0x20, b"\0")) |
|
|
|
# Write animation sequence |
|
sequence = b"" |
|
if animation: |
|
for frame in animation: |
|
duration = frame[0] & 0xFF |
|
iconIndex = frame[1] & 7 |
|
palIndex = frame[2] & 7 |
|
hFlip = frame[3] & 1 |
|
vFlip = frame[4] & 1 |
|
sequence += pack("<H", duration | (iconIndex << 8) | (palIndex << 11) | (hFlip << 14) | (vFlip << 15)) |
|
else: |
|
sequence = b"\1\0\0\1" # 1 frame duration for first frame, then for some reason 0x0100 for second |
|
output.seek(0x2340) |
|
output.write(sequence.ljust(0x80, b"\0")) |
|
|
|
# Write titles |
|
for i in range(titleCount): |
|
title = (titles[i] if i < len(titles) else titles[0]).replace(";", "\n") |
|
invalidChars = [x for x in title if ord(x) > 0xFFFF] |
|
if len(invalidChars) > 0: |
|
raise Exception(f"Invalid character(s) in title {i}: {', '.join(invalidChars)}") |
|
|
|
output.seek(0x240 + (i * 0x100)) |
|
output.write(title.encode("utf-16-le").ljust(0x100, b"\0")) |
|
|
|
# Calculate checksums |
|
output.seek(0x20) |
|
if version == 0x0001: |
|
data = output.read(0x820) |
|
checksums = (modbus(data), 0, 0, 0) |
|
elif version == 0x0002: |
|
data = output.read(0x920) |
|
checksums = (modbus(data[:0x820]), modbus(data), 0, 0) |
|
elif version == 0x0003: |
|
data = output.read(0xA20) |
|
checksums = (modbus(data[:0x820]), modbus(data[:0x920]), modbus(data), 0) |
|
else: |
|
data = output.read(0x23A0) |
|
checksums = (modbus(data[:0x820]), modbus(data[:0x920]), modbus(data[:0xA20]), modbus(data[0x1220:])) |
|
|
|
output.seek(0) |
|
output.write(pack("<HHHHH", version, *checksums)) |
|
|
|
|
|
if __name__ == "__main__": |
|
parser = ArgumentParser(description="Creates a DS(i) banner file") |
|
parser.add_argument("-i", "--icons", metavar="icon.0.png", nargs="+", required=True, type=Image.open, help="icon image(s)") |
|
parser.add_argument("-d", "--dsicon", metavar="icon.png", type=Image.open, help="DS mode icon (optional)") |
|
parser.add_argument("-t", "--titles", required=True, type=str, nargs="+", help="application title (ja, en, fr, de, it, es, cn, kr) (; = newline)") |
|
parser.add_argument("-a", "--animation", metavar="icon.json", type=FileType("r"), help="animation sequence JSON") |
|
parser.add_argument("-o", "--output", metavar="banner.bin", default="banner.bin", type=FileType("wb+"), help="output banner.bin") |
|
|
|
args = parser.parse_args() |
|
|
|
dsibanner(args.icons, args.titles, json.load(args.animation) if args.animation else None, args.output, args.dsicon) |
Do you think exporting to APNG/WebP/JPEG XL could be possible to preserve the correct framerates of the animated DSi icons?