IOS: add Starlet LLE milestone through HBC

Boot original Wii firmware through boot0, boot1, boot2 and IOS on an emulated ARM Starlet. Model the required IPC, memory, SD, USB and Bluetooth hardware behavior, including IOS reload into IOS58, and add focused tests, launch utilities and architecture documentation.
This commit is contained in:
2026-08-25 17:11:49 +02:00
parent 38e70fda65
commit a77c156bc8
46 changed files with 10556 additions and 158 deletions
+45
View File
@@ -0,0 +1,45 @@
param(
[Parameter(Mandatory = $true)] [int] $ProcessId,
[Parameter(Mandatory = $true)] [UInt64] $Address,
[Parameter(Mandatory = $true)] [int] $Length,
[Parameter(Mandatory = $true)] [string] $OutputPath
)
Add-Type -TypeDefinition @'
using System;
using System.Runtime.InteropServices;
public static class ProcessMemoryDumpNative
{
[DllImport("kernel32.dll", SetLastError = true)]
public static extern IntPtr OpenProcess(uint access, bool inheritHandle, int processId);
[DllImport("kernel32.dll", SetLastError = true)]
public static extern bool ReadProcessMemory(IntPtr process, UIntPtr address, byte[] buffer,
UIntPtr size, out UIntPtr bytesRead);
[DllImport("kernel32.dll")]
public static extern bool CloseHandle(IntPtr handle);
}
'@
$handle = [ProcessMemoryDumpNative]::OpenProcess(0x410, $false, $ProcessId)
if ($handle -eq [IntPtr]::Zero) {
throw "OpenProcess failed: $([Runtime.InteropServices.Marshal]::GetLastWin32Error())"
}
try {
$buffer = [byte[]]::new($Length)
$bytesRead = [UIntPtr]::Zero
if (-not [ProcessMemoryDumpNative]::ReadProcessMemory(
$handle, [UIntPtr]::new($Address), $buffer, [UIntPtr]::new([UInt64]$Length), [ref]$bytesRead)) {
throw "ReadProcessMemory failed: $([Runtime.InteropServices.Marshal]::GetLastWin32Error())"
}
if ($bytesRead.ToUInt64() -ne [UInt64]$Length) {
throw "Short read: requested $Length bytes, got $($bytesRead.ToUInt64())"
}
[IO.File]::WriteAllBytes((Join-Path (Get-Location) $OutputPath), $buffer)
}
finally {
[void][ProcessMemoryDumpNative]::CloseHandle($handle)
}
+160
View File
@@ -0,0 +1,160 @@
param(
[Parameter(Mandatory = $true)]
[int]$ProcessId,
[Parameter(Mandatory = $true)]
[string]$OutputPath
)
$source = @'
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.IO;
using System.Runtime.InteropServices;
public static class StarletSramScanner
{
[StructLayout(LayoutKind.Sequential)]
private struct MEMORY_BASIC_INFORMATION
{
public IntPtr BaseAddress;
public IntPtr AllocationBase;
public uint AllocationProtect;
public ushort PartitionId;
public UIntPtr RegionSize;
public uint State;
public uint Protect;
public uint Type;
}
[DllImport("kernel32.dll", SetLastError = true)]
private static extern IntPtr OpenProcess(uint access, bool inherit, int processId);
[DllImport("kernel32.dll", SetLastError = true)]
private static extern bool CloseHandle(IntPtr handle);
[DllImport("kernel32.dll", SetLastError = true)]
private static extern UIntPtr VirtualQueryEx(IntPtr process, IntPtr address,
out MEMORY_BASIC_INFORMATION information,
UIntPtr length);
[DllImport("kernel32.dll", SetLastError = true)]
private static extern bool ReadProcessMemory(IntPtr process, IntPtr address, byte[] buffer,
UIntPtr size, out UIntPtr bytesRead);
private const uint PROCESS_VM_READ = 0x0010;
private const uint PROCESS_QUERY_INFORMATION = 0x0400;
private const uint MEM_COMMIT = 0x1000;
private const uint PAGE_NOACCESS = 0x01;
private const uint PAGE_GUARD = 0x100;
private const int SRAM_SIZE = 0x18000;
private const int SIGNATURE_OFFSET = 0x540;
private static readonly byte[] Signature =
{
0xe5, 0x93, 0x00, 0x00, 0xe1, 0x51, 0x08, 0x20, 0x0a, 0xff, 0xff, 0xfc
};
private static int Find(byte[] haystack, int count)
{
for (int i = 0; i <= count - Signature.Length; ++i)
{
int j = 0;
while (j < Signature.Length && haystack[i + j] == Signature[j])
++j;
if (j == Signature.Length)
return i;
}
return -1;
}
public static long Dump(int processId, string outputPath)
{
IntPtr process = OpenProcess(PROCESS_VM_READ | PROCESS_QUERY_INFORMATION, false, processId);
if (process == IntPtr.Zero)
throw new Win32Exception(Marshal.GetLastWin32Error(), "OpenProcess failed");
try
{
ulong address = 0x10000;
ulong maximum = 0x00007fffffff0000UL;
int mbiSize = Marshal.SizeOf<MEMORY_BASIC_INFORMATION>();
while (address < maximum)
{
MEMORY_BASIC_INFORMATION mbi;
UIntPtr queried = VirtualQueryEx(process, new IntPtr(unchecked((long)address)),
out mbi, new UIntPtr((uint)mbiSize));
if (queried == UIntPtr.Zero)
break;
ulong baseAddress = unchecked((ulong)mbi.BaseAddress.ToInt64());
ulong regionSize = mbi.RegionSize.ToUInt64();
if (regionSize == 0)
break;
bool readable = mbi.State == MEM_COMMIT && (mbi.Protect & PAGE_NOACCESS) == 0 &&
(mbi.Protect & PAGE_GUARD) == 0;
if (readable)
{
const int chunkSize = 4 * 1024 * 1024;
ulong offset = 0;
while (offset < regionSize)
{
int requested = (int)Math.Min((ulong)chunkSize, regionSize - offset);
byte[] chunk = new byte[requested];
UIntPtr bytesRead;
if (ReadProcessMemory(process,
new IntPtr(unchecked((long)(baseAddress + offset))),
chunk, new UIntPtr((uint)requested), out bytesRead))
{
int hit = Find(chunk, checked((int)bytesRead.ToUInt64()));
if (hit >= 0)
{
ulong signatureAddress = baseAddress + offset + (uint)hit;
if (signatureAddress < SIGNATURE_OFFSET)
break;
ulong sramAddress = signatureAddress - SIGNATURE_OFFSET;
byte[] sram = new byte[SRAM_SIZE];
UIntPtr sramRead;
if (ReadProcessMemory(process,
new IntPtr(unchecked((long)sramAddress)), sram,
new UIntPtr(SRAM_SIZE), out sramRead) &&
sramRead.ToUInt64() == SRAM_SIZE && FindAt(sram, SIGNATURE_OFFSET))
{
File.WriteAllBytes(outputPath, sram);
return unchecked((long)sramAddress);
}
}
}
offset += (ulong)requested;
}
}
address = baseAddress + regionSize;
if (address <= baseAddress)
break;
}
}
finally
{
CloseHandle(process);
}
throw new InvalidOperationException("Starlet SRAM signature was not found");
}
private static bool FindAt(byte[] bytes, int offset)
{
if (offset < 0 || offset + Signature.Length > bytes.Length)
return false;
for (int i = 0; i < Signature.Length; ++i)
{
if (bytes[offset + i] != Signature[i])
return false;
}
return true;
}
}
'@
Add-Type -TypeDefinition $source -Language CSharp
$resolvedOutput = [System.IO.Path]::GetFullPath($OutputPath)
$address = [StarletSramScanner]::Dump($ProcessId, $resolvedOutput)
"Starlet SRAM dumped from host address 0x{0:x16} to {1}" -f $address, $resolvedOutput
+121
View File
@@ -0,0 +1,121 @@
#!/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()
+82
View File
@@ -0,0 +1,82 @@
param(
[Parameter(Mandatory = $true)]
[int]$ProcessId,
[int64]$MinimumSize = 0x1000000
)
$source = @'
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Runtime.InteropServices;
public static class ProcessMemoryRegions
{
[StructLayout(LayoutKind.Sequential)]
public struct Region
{
public IntPtr BaseAddress;
public IntPtr AllocationBase;
public uint AllocationProtect;
public ushort PartitionId;
public UIntPtr RegionSize;
public uint State;
public uint Protect;
public uint Type;
}
[DllImport("kernel32.dll", SetLastError = true)]
private static extern IntPtr OpenProcess(uint access, bool inherit, int processId);
[DllImport("kernel32.dll", SetLastError = true)]
private static extern bool CloseHandle(IntPtr handle);
[DllImport("kernel32.dll", SetLastError = true)]
private static extern UIntPtr VirtualQueryEx(IntPtr process, IntPtr address,
out Region information, UIntPtr length);
public static Region[] List(int processId, long minimumSize)
{
IntPtr process = OpenProcess(0x0400, false, processId);
if (process == IntPtr.Zero)
throw new Win32Exception(Marshal.GetLastWin32Error(), "OpenProcess failed");
var result = new List<Region>();
try
{
ulong address = 0x10000;
const ulong maximum = 0x00007fffffff0000UL;
int size = Marshal.SizeOf<Region>();
while (address < maximum)
{
Region region;
if (VirtualQueryEx(process, new IntPtr(unchecked((long)address)), out region,
new UIntPtr((uint)size)) == UIntPtr.Zero)
break;
ulong baseAddress = unchecked((ulong)region.BaseAddress.ToInt64());
ulong regionSize = region.RegionSize.ToUInt64();
if (regionSize == 0)
break;
if (region.State == 0x1000 && regionSize >= (ulong)minimumSize)
result.Add(region);
address = baseAddress + regionSize;
if (address <= baseAddress)
break;
}
}
finally
{
CloseHandle(process);
}
return result.ToArray();
}
}
'@
Add-Type -TypeDefinition $source -Language CSharp
[ProcessMemoryRegions]::List($ProcessId, $MinimumSize) | ForEach-Object {
[pscustomobject]@{
Base = '0x{0:x16}' -f [uint64]$_.BaseAddress.ToInt64()
AllocationBase = '0x{0:x16}' -f [uint64]$_.AllocationBase.ToInt64()
Size = '0x{0:x}' -f $_.RegionSize.ToUInt64()
Protect = '0x{0:x}' -f $_.Protect
Type = '0x{0:x}' -f $_.Type
}
}
+53
View File
@@ -0,0 +1,53 @@
param(
[Parameter(Mandatory = $true)]
[int]$ProcessId,
[Parameter(Mandatory = $true)]
[uint64]$Address,
[int]$Length = 64
)
$source = @'
using System;
using System.ComponentModel;
using System.Runtime.InteropServices;
public static class ProcessMemoryReader
{
[DllImport("kernel32.dll", SetLastError = true)]
private static extern IntPtr OpenProcess(uint access, bool inherit, int processId);
[DllImport("kernel32.dll", SetLastError = true)]
private static extern bool CloseHandle(IntPtr handle);
[DllImport("kernel32.dll", SetLastError = true)]
private static extern bool ReadProcessMemory(IntPtr process, IntPtr address, byte[] buffer,
UIntPtr size, out UIntPtr bytesRead);
public static byte[] Read(int processId, ulong address, int length)
{
IntPtr process = OpenProcess(0x0010 | 0x0400, false, processId);
if (process == IntPtr.Zero)
throw new Win32Exception(Marshal.GetLastWin32Error(), "OpenProcess failed");
try
{
byte[] bytes = new byte[length];
UIntPtr read;
if (!ReadProcessMemory(process, new IntPtr(unchecked((long)address)), bytes,
new UIntPtr((uint)length), out read) ||
read.ToUInt64() != (ulong)length)
throw new Win32Exception(Marshal.GetLastWin32Error(), "ReadProcessMemory failed");
return bytes;
}
finally
{
CloseHandle(process);
}
}
}
'@
Add-Type -TypeDefinition $source -Language CSharp
$bytes = [ProcessMemoryReader]::Read($ProcessId, $Address, $Length)
for ($offset = 0; $offset -lt $bytes.Length; $offset += 16) {
$count = [Math]::Min(16, $bytes.Length - $offset)
$hex = ($bytes[$offset..($offset + $count - 1)] | ForEach-Object { '{0:x2}' -f $_ }) -join ' '
'0x{0:x16}: {1}' -f ($Address + [uint64]$offset), $hex
}
+75
View File
@@ -0,0 +1,75 @@
#!/usr/bin/env python3
import argparse
import ctypes
import pathlib
import sys
REPOSITORY_ROOT = pathlib.Path(__file__).resolve().parents[1]
sys.path.insert(0, str(REPOSITORY_ROOT / "capstone_local"))
from capstone import CS_ARCH_ARM, CS_ARCH_PPC, CS_MODE_32, CS_MODE_ARM, CS_MODE_BIG_ENDIAN, Cs
PROCESS_VM_READ = 0x0010
PROCESS_QUERY_INFORMATION = 0x0400
def read_process_memory(process_id: int, address: int, length: int) -> bytes:
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
kernel32.OpenProcess.argtypes = [ctypes.c_uint32, ctypes.c_bool, ctypes.c_uint32]
kernel32.OpenProcess.restype = ctypes.c_void_p
kernel32.ReadProcessMemory.argtypes = [
ctypes.c_void_p,
ctypes.c_void_p,
ctypes.c_void_p,
ctypes.c_size_t,
ctypes.POINTER(ctypes.c_size_t),
]
kernel32.ReadProcessMemory.restype = ctypes.c_bool
kernel32.CloseHandle.argtypes = [ctypes.c_void_p]
kernel32.CloseHandle.restype = ctypes.c_bool
process = kernel32.OpenProcess(PROCESS_VM_READ | PROCESS_QUERY_INFORMATION, False, process_id)
if not process:
raise ctypes.WinError(ctypes.get_last_error())
try:
buffer = ctypes.create_string_buffer(length)
bytes_read = ctypes.c_size_t()
if not kernel32.ReadProcessMemory(
process, ctypes.c_void_p(address), buffer, length, ctypes.byref(bytes_read)
):
raise ctypes.WinError(ctypes.get_last_error())
if bytes_read.value != length:
raise RuntimeError(f"short read: requested {length} bytes, got {bytes_read.value}")
return buffer.raw
finally:
kernel32.CloseHandle(process)
def main() -> None:
parser = argparse.ArgumentParser(
description="Disassemble big-endian 32-bit PowerPC code from a live Windows process."
)
parser.add_argument("process_id", type=int)
parser.add_argument("host_address", type=lambda value: int(value, 0))
parser.add_argument("guest_address", type=lambda value: int(value, 0))
parser.add_argument("length", type=lambda value: int(value, 0))
parser.add_argument("--arch", choices=("ppc", "arm"), default="ppc")
args = parser.parse_args()
code = read_process_memory(args.process_id, args.host_address, args.length)
if args.arch == "arm":
disassembler = Cs(CS_ARCH_ARM, CS_MODE_ARM | CS_MODE_BIG_ENDIAN)
else:
disassembler = Cs(CS_ARCH_PPC, CS_MODE_32 | CS_MODE_BIG_ENDIAN)
for instruction in disassembler.disasm(code, args.guest_address):
operands = f" {instruction.op_str}" if instruction.op_str else ""
print(f"{instruction.address:08x}: {instruction.bytes.hex(' '):11} "
f"{instruction.mnemonic}{operands}")
if __name__ == "__main__":
main()