|
|
#!/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)
|