#!/usr/bin/env python3 """Generate a LetterBomb tree directly in Dolphin's virtual SD folder.""" from __future__ import annotations import argparse import hashlib import hmac import shutil import zipfile from datetime import datetime, timedelta from pathlib import Path, PurePosixPath REPO_ROOT = Path(__file__).resolve().parents[1] DEFAULT_SOURCE = REPO_ROOT / ".starlet_check" / "letterbomb-web" / "public" def parse_mac(text: str) -> bytes: compact = text.replace(":", "").replace("-", "") if len(compact) != 12: raise argparse.ArgumentTypeError("MAC must contain exactly 12 hexadecimal digits") try: return bytes.fromhex(compact) except ValueError as error: raise argparse.ArgumentTypeError("MAC contains a non-hexadecimal character") from error def safe_extract(archive: zipfile.ZipFile, destination: Path) -> None: for member in archive.infolist(): relative = PurePosixPath(member.filename) if relative.is_absolute() or ".." in relative.parts: raise RuntimeError(f"Unsafe archive member: {member.filename!r}") target = destination.joinpath(*relative.parts) if member.is_dir(): target.mkdir(parents=True, exist_ok=True) continue target.parent.mkdir(parents=True, exist_ok=True) with archive.open(member) as source, target.open("wb") as output: shutil.copyfileobj(source, output) def generate(template: bytes, mac: bytes, message_time: datetime) -> tuple[bytes, Path]: payload = bytearray(template) key = hashlib.sha1(mac + b"uyy").digest() payload[8:16] = key[:8] payload[176:196] = bytes(20) epoch = datetime(2000, 1, 1) timestamp = int((message_time - epoch).total_seconds()) payload[124:128] = timestamp.to_bytes(4, "big") payload[128:138] = f"{timestamp:010d}".encode("ascii") payload[176:196] = hmac.new(key[8:], payload, hashlib.sha1).digest() relative_path = Path( "private", "wii", "title", "HAEA", key[:4].hex().upper(), key[4:8].hex().upper(), f"{message_time.year:04d}", f"{message_time.month - 1:02d}", f"{message_time.day:02d}", f"{message_time.hour:02d}", f"{message_time.minute:02d}", "HABA_#1", "txt", f"{timestamp:08X}.000", ) return bytes(payload), relative_path def main() -> None: parser = argparse.ArgumentParser() parser.add_argument("--mac", required=True, type=parse_mac) parser.add_argument("--region", required=True, choices=("E", "U", "J", "K")) parser.add_argument("--output", required=True, type=Path) parser.add_argument("--source", type=Path, default=DEFAULT_SOURCE) parser.add_argument( "--date", help="message date as YYYY-MM-DD; defaults to yesterday in local Wii time", ) args = parser.parse_args() source = args.source.resolve() output = args.output.resolve() template_path = source / f"template{args.region}.bin" bundle_path = source / "hackmii.zip" if not template_path.is_file() or not bundle_path.is_file(): raise RuntimeError(f"LetterBomb assets are missing from {source}") if args.date: message_time = datetime.strptime(args.date, "%Y-%m-%d") else: message_time = datetime.now().replace(second=0, microsecond=0) - timedelta(days=1) payload, relative_path = generate(template_path.read_bytes(), args.mac, message_time) output.mkdir(parents=True, exist_ok=True) target = output / relative_path target.parent.mkdir(parents=True, exist_ok=True) target.write_bytes(payload) with zipfile.ZipFile(bundle_path) as archive: safe_extract(archive, output) mac_text = ":".join(f"{byte:02X}" for byte in args.mac) (output / "LETTERBOMB-INFO.txt").write_text( "LetterBomb virtual Wii test card\n" f"System Menu: 4.3{args.region}\n" f"Emulated Wi-Fi MAC: {mac_text}\n" f"Message date: {message_time:%Y-%m-%d %H:%M}\n" f"Message file: {relative_path.as_posix()}\n" "Close Dolphin before changing files in this folder.\n", encoding="utf-8", ) print(f"LetterBomb generated at {target}") print(f"Bundled HackMii Installer extracted to {output}") if __name__ == "__main__": main()