IOS LLE: checkpoint NAND conformance and diagnostic tooling
Preserve formatted NAND boot and Wii Remote pairing, add DMA/SRAM conformance tests and a Wii comparison probe. Keep P1 profiling tools and native update A/B evidence; reject the unhelpful targeted SRAM shortcut.
This commit is contained in:
1 parent
b931671fde
commit
c230971f07
64 files changed
+6488
-238
No files matched your search
@@ -0,0 +1,11 @@
|
||||
build/
|
||||
__pycache__/
|
||||
*.elf
|
||||
*.map
|
||||
/build/
|
||||
/apps/
|
||||
/captures/
|
||||
/__pycache__/
|
||||
/wii-conformance.dol
|
||||
/wii-conformance.elf
|
||||
/wii-conformance.map
|
||||
@@ -0,0 +1,45 @@
|
||||
.SUFFIXES:
|
||||
|
||||
ifeq ($(strip $(DEVKITPPC)),)
|
||||
$(error Set DEVKITPPC to your devkitPPC installation)
|
||||
endif
|
||||
|
||||
include $(DEVKITPPC)/wii_rules
|
||||
|
||||
TARGET := wii-conformance
|
||||
BUILD := build
|
||||
SOURCES := source
|
||||
|
||||
CFLAGS := -O2 -g -Wall -Wextra $(MACHDEP) $(INCLUDE)
|
||||
LDFLAGS = -g $(MACHDEP) -Wl,-Map,$(OUTPUT).map
|
||||
LIBS := -lwiiuse -lbte -lfat -logc -lm
|
||||
|
||||
ifneq ($(BUILD),$(notdir $(CURDIR)))
|
||||
export OUTPUT := $(CURDIR)/$(TARGET)
|
||||
export VPATH := $(CURDIR)/source
|
||||
export DEPSDIR := $(CURDIR)/$(BUILD)
|
||||
export OFILES := main.o
|
||||
export INCLUDE := -I$(LIBOGC_INC)
|
||||
export LIBPATHS := -L$(LIBOGC_LIB)
|
||||
export LD := $(CC)
|
||||
|
||||
.PHONY: all clean
|
||||
all: | $(BUILD)
|
||||
@$(MAKE) --no-print-directory -C $(BUILD) -f $(CURDIR)/Makefile
|
||||
@mkdir -p apps/wii-conformance
|
||||
@cp $(TARGET).dol apps/wii-conformance/boot.dol
|
||||
@cp meta.xml apps/wii-conformance/meta.xml
|
||||
|
||||
$(BUILD):
|
||||
@mkdir -p $@
|
||||
|
||||
clean:
|
||||
@rm -rf $(BUILD) $(OUTPUT).elf $(OUTPUT).dol $(OUTPUT).elf.map
|
||||
else
|
||||
DEPENDS := $(OFILES:.o=.d)
|
||||
|
||||
$(OUTPUT).dol: $(OUTPUT).elf
|
||||
$(OUTPUT).elf: $(OFILES)
|
||||
|
||||
-include $(DEPENDS)
|
||||
endif
|
||||
@@ -0,0 +1,59 @@
|
||||
# Wii IOS conformance probe
|
||||
|
||||
This is the first black-box comparison probe for the original IOS running on a
|
||||
real Wii and under this fork's Starlet LLE mode. Build **one** DOL and run that
|
||||
same file in both places. It records 64 rounds of IPC-backed resource-manager
|
||||
open/close calls in all four open modes, missing-device errors, root directory
|
||||
counts, installed-title counts, and an SD write/read/hash/cleanup round trip.
|
||||
The output contains 1,860 numbered observations. The probe requests no NAND
|
||||
writes, installs no IOS, and does not blindly scan MMIO registers. It creates and
|
||||
removes only `sd:/wii-conformance-data.bin` on the SD card.
|
||||
|
||||
## Build
|
||||
|
||||
Install devkitPPC and libogc/libfat using devkitPro, set `DEVKITPRO` and
|
||||
`DEVKITPPC` in the build shell, then run `make` in this directory. The output is
|
||||
`wii-conformance.dol`. On this Windows machine, the following command was used
|
||||
successfully from this directory in PowerShell:
|
||||
|
||||
```powershell
|
||||
& 'C:\devkitPro\msys2\usr\bin\bash.exe' -c 'export DEVKITPRO=/c/devkitPro DEVKITPPC=/c/devkitPro/devkitPPC; export PATH=/c/devkitPro/devkitPPC/bin:/c/devkitPro/tools/bin:/usr/bin:/bin; make'
|
||||
```
|
||||
|
||||
The ready-to-copy Homebrew Channel folder is `apps/wii-conformance/`, containing
|
||||
`boot.dol` and `meta.xml`. This build has not yet been run on a physical Wii.
|
||||
|
||||
## Capture
|
||||
|
||||
1. Use a Wii and a Dolphin LLE profile with the same IOS version and revision.
|
||||
For exact directory/title counts, use the same NAND snapshot, starting state,
|
||||
and loaded title. The SD cards need enough free space for a 4 KiB test file
|
||||
and the CSV output.
|
||||
2. Copy `apps/wii-conformance/` to the `apps/` folder on each SD card and launch
|
||||
it through the Homebrew Channel. Use the exact same `boot.dol` on both runs.
|
||||
Dolphin LLE requires a configured writable virtual SD image.
|
||||
3. After the program returns, copy `sd:/wii-conformance.csv` from each SD card
|
||||
to separate files such as `wii.csv` and `dolphin.csv`.
|
||||
4. Compare them with `python compare.py wii.csv dolphin.csv`. Exit code 0 means
|
||||
all recorded observations agree, 1 reports differences, and 2 means the
|
||||
captures are invalid or use different IOS versions.
|
||||
|
||||
The CSV contains no keys or NAND contents. An allocated IOS file descriptor is
|
||||
normalized to success/failure because its numeric value depends on unrelated
|
||||
open handles. Call return codes and output counts remain exact. Time is not
|
||||
compared in this first suite because scheduling and output I/O introduce noise.
|
||||
`captures/` is kept local and ignored by Git until each capture's console or
|
||||
emulator provenance, IOS revision, probe binary hash, and initial NAND state
|
||||
are documented. A filename alone is not evidence that a capture came from a
|
||||
physical Wii.
|
||||
|
||||
## Interpretation and next suites
|
||||
|
||||
A mismatch identifies a reproducible behavior to investigate; it does not by
|
||||
itself locate a bad register. Trace the first failing request inside Dolphin's
|
||||
Wii IPC, Starlet memory bus, and the relevant device. The next test suites
|
||||
should use explicit stimulus/response cases for IPC flag acknowledgements,
|
||||
shared-memory cache visibility, DMA, IRQ ordering, and individual devices.
|
||||
PPC homebrew cannot inspect every Starlet register while Nintendo IOS is
|
||||
running. A separate Starlet-side probe under MINI/BootMii can test raw
|
||||
hardware registers, but its results must be labeled as MINI-mode tests.
|
||||
@@ -0,0 +1,79 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Compare two runs of the identical Wii conformance DOL."""
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
METADATA = ("schema", "rounds", "observations", "ios_version", "ios_revision")
|
||||
HEADER = ["seq", "test", "round", "value", "extra"]
|
||||
|
||||
|
||||
def read_capture(path: Path):
|
||||
with path.open(newline="", encoding="utf-8") as stream:
|
||||
rows = csv.reader(stream)
|
||||
metadata = {}
|
||||
for key in METADATA:
|
||||
row = next(rows, None)
|
||||
if row is None or len(row) != 2 or row[0] != key:
|
||||
raise ValueError(f"{path}: expected metadata row {key!r}")
|
||||
metadata[key] = int(row[1])
|
||||
if next(rows, None) != HEADER:
|
||||
raise ValueError(f"{path}: invalid observation header")
|
||||
observations = []
|
||||
for line, row in enumerate(rows, start=7):
|
||||
if len(row) != 5:
|
||||
raise ValueError(f"{path}:{line}: expected 5 columns")
|
||||
seq, test, round_number, value, extra = row
|
||||
if int(seq) != len(observations):
|
||||
raise ValueError(f"{path}:{line}: sequence is not contiguous")
|
||||
observations.append((test, int(round_number), int(value), int(extra)))
|
||||
if len(observations) != metadata["observations"]:
|
||||
raise ValueError(
|
||||
f"{path}: expected {metadata['observations']} observations, "
|
||||
f"found {len(observations)}"
|
||||
)
|
||||
return metadata, observations
|
||||
|
||||
|
||||
def compare(reference, candidate, limit=20):
|
||||
reference_meta, reference_rows = reference
|
||||
candidate_meta, candidate_rows = candidate
|
||||
for key in METADATA:
|
||||
if reference_meta[key] != candidate_meta[key]:
|
||||
print(f"Incomparable runs: {key}: Wii={reference_meta[key]}, Dolphin={candidate_meta[key]}")
|
||||
return 2
|
||||
|
||||
differences = 0
|
||||
for index in range(max(len(reference_rows), len(candidate_rows))):
|
||||
expected = reference_rows[index] if index < len(reference_rows) else None
|
||||
actual = candidate_rows[index] if index < len(candidate_rows) else None
|
||||
if expected == actual:
|
||||
continue
|
||||
differences += 1
|
||||
if differences <= limit:
|
||||
print(f"#{index} Wii={expected} Dolphin={actual}")
|
||||
|
||||
if differences:
|
||||
print(f"{differences} differences across {max(len(reference_rows), len(candidate_rows))} observations")
|
||||
return 1
|
||||
print(f"Identical: {len(reference_rows)} observations")
|
||||
return 0
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("wii", type=Path, help="CSV from the real Wii")
|
||||
parser.add_argument("dolphin", type=Path, help="CSV from Dolphin")
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
return compare(read_capture(args.wii), read_capture(args.dolphin))
|
||||
except (OSError, ValueError) as error:
|
||||
print(error, file=sys.stderr)
|
||||
return 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<app version="1">
|
||||
<name>Wii IOS Conformance Probe</name>
|
||||
<coder>Dolphin Emulator Project</coder>
|
||||
<version>0.1</version>
|
||||
<short_description>Compare IOS behavior on Wii and Dolphin</short_description>
|
||||
<long_description>Runs repeatable IPC, IOS, and SD tests and saves wii-conformance.csv to the SD card.</long_description>
|
||||
</app>
|
||||
@@ -0,0 +1,172 @@
|
||||
// Copyright 2026 Dolphin Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
#include <fat.h>
|
||||
#include <gccore.h>
|
||||
#include <ogc/es.h>
|
||||
#include <ogc/ios.h>
|
||||
#include <ogc/ipc.h>
|
||||
#include <ogc/isfs.h>
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
// The same DOL must be run on the console and in Dolphin. Do not depend on
|
||||
// timings or on allocated IOS file descriptor numbers in the comparison.
|
||||
#define SCHEMA_VERSION 1
|
||||
#define ROUNDS 64
|
||||
#define RECORDS_PER_ROUND 29
|
||||
#define SKIPPED (-32768)
|
||||
#define SD_TEST_FILE "sd:/wii-conformance-data.bin"
|
||||
|
||||
static FILE* output;
|
||||
static unsigned int sequence;
|
||||
|
||||
static void record(const char* test, unsigned int round, int value, int extra)
|
||||
{
|
||||
if (output)
|
||||
fprintf(output, "%u,%s,%u,%d,%d\n", sequence, test, round, value, extra);
|
||||
++sequence;
|
||||
}
|
||||
|
||||
static void probe_open(const char* name, const char* path, unsigned int round, unsigned int mode)
|
||||
{
|
||||
char test[48];
|
||||
const s32 fd = IOS_Open(path, mode);
|
||||
snprintf(test, sizeof(test), "open_%s_mode%u", name, mode);
|
||||
// File descriptor allocation can legitimately differ between runs.
|
||||
record(test, round, fd >= 0 ? 0 : fd, 0);
|
||||
snprintf(test, sizeof(test), "close_%s_mode%u", name, mode);
|
||||
record(test, round, fd >= 0 ? IOS_Close(fd) : SKIPPED, 0);
|
||||
}
|
||||
|
||||
static void probe_sd(unsigned int round)
|
||||
{
|
||||
static u8 expected[4096] __attribute__((aligned(32)));
|
||||
static u8 actual[4096] __attribute__((aligned(32)));
|
||||
const size_t length = 1 + ((round * 67) % sizeof(expected));
|
||||
for (size_t i = 0; i < length; ++i)
|
||||
expected[i] = (u8)(round * 131 + i * 17);
|
||||
|
||||
FILE* file = fopen(SD_TEST_FILE, "wb");
|
||||
record("sd_open_write", round, file ? 0 : -1, (int)length);
|
||||
record("sd_write_bytes", round, file ? (int)fwrite(expected, 1, length, file) : SKIPPED,
|
||||
(int)length);
|
||||
record("sd_close_write", round, file ? fclose(file) : SKIPPED, 0);
|
||||
|
||||
file = fopen(SD_TEST_FILE, "rb");
|
||||
record("sd_open_read", round, file ? 0 : -1, 0);
|
||||
const size_t read_count = file ? fread(actual, 1, length, file) : 0;
|
||||
record("sd_read_bytes", round, file ? (int)read_count : SKIPPED, (int)length);
|
||||
u32 hash = 2166136261u;
|
||||
for (size_t i = 0; i < read_count; ++i)
|
||||
hash = (hash ^ actual[i]) * 16777619u;
|
||||
record("sd_read_hash", round, file ? (int)hash : SKIPPED, 0);
|
||||
record("sd_data_equal", round,
|
||||
file ? (read_count == length && memcmp(expected, actual, length) == 0) : SKIPPED, 0);
|
||||
record("sd_close_read", round, file ? fclose(file) : SKIPPED, 0);
|
||||
record("sd_remove", round, remove(SD_TEST_FILE), 0);
|
||||
}
|
||||
|
||||
static void init_display(void)
|
||||
{
|
||||
VIDEO_Init();
|
||||
GXRModeObj* mode = VIDEO_GetPreferredMode(NULL);
|
||||
void* framebuffer = MEM_K0_TO_K1(SYS_AllocateFramebuffer(mode));
|
||||
console_init(framebuffer, 20, 20, mode->fbWidth - 40, mode->xfbHeight - 40,
|
||||
mode->fbWidth * VI_DISPLAY_PIX_SZ);
|
||||
VIDEO_Configure(mode);
|
||||
VIDEO_SetNextFramebuffer(framebuffer);
|
||||
VIDEO_SetBlack(FALSE);
|
||||
VIDEO_Flush();
|
||||
VIDEO_WaitVSync();
|
||||
if (mode->viTVMode & VI_NON_INTERLACE)
|
||||
VIDEO_WaitVSync();
|
||||
}
|
||||
|
||||
int main(void)
|
||||
{
|
||||
init_display();
|
||||
printf("Wii IOS conformance probe v%d\n", SCHEMA_VERSION);
|
||||
|
||||
if (fatInitDefault())
|
||||
output = fopen("sd:/wii-conformance.csv", "wb");
|
||||
if (!output)
|
||||
{
|
||||
printf("Cannot write sd:/wii-conformance.csv\n");
|
||||
printf("Insert a writable SD card and restart.\n");
|
||||
for (int i = 0; i < 600; ++i)
|
||||
VIDEO_WaitVSync();
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Metadata is checked before comparing individual observations.
|
||||
fprintf(output, "schema,%d\n", SCHEMA_VERSION);
|
||||
fprintf(output, "rounds,%d\n", ROUNDS);
|
||||
fprintf(output, "observations,%d\n", 4 + ROUNDS * RECORDS_PER_ROUND);
|
||||
fprintf(output, "ios_version,%d\n", IOS_GetVersion());
|
||||
fprintf(output, "ios_revision,%d\n", IOS_GetRevision());
|
||||
fprintf(output, "seq,test,round,value,extra\n");
|
||||
|
||||
const s32 fs_init = ISFS_Initialize();
|
||||
record("isfs_init", 0, fs_init, 0);
|
||||
const s32 es_init = __ES_Init();
|
||||
record("es_init", 0, es_init, 0);
|
||||
|
||||
for (unsigned int round = 0; round < ROUNDS; ++round)
|
||||
{
|
||||
// A missing resource is a useful stable IPC error-path test.
|
||||
probe_open("missing", "/dev/wii_conformance_probe_absent", round, IPC_OPEN_NONE);
|
||||
|
||||
for (unsigned int mode = IPC_OPEN_NONE; mode <= IPC_OPEN_RW; ++mode)
|
||||
{
|
||||
probe_open("fs", "/dev/fs", round, mode);
|
||||
probe_open("es", "/dev/es", round, mode);
|
||||
}
|
||||
|
||||
if (fs_init >= 0)
|
||||
{
|
||||
u32 count __attribute__((aligned(32))) = 0;
|
||||
const s32 result = ISFS_ReadDir("/", NULL, &count);
|
||||
record("isfs_root_count", round, result, result >= 0 ? (int)count : 0);
|
||||
}
|
||||
else
|
||||
{
|
||||
record("isfs_root_count", round, SKIPPED, 0);
|
||||
}
|
||||
|
||||
if (es_init >= 0)
|
||||
{
|
||||
u32 title_count __attribute__((aligned(32))) = 0;
|
||||
const s32 title_result = ES_GetNumTitles(&title_count);
|
||||
record("es_title_count", round, title_result,
|
||||
title_result >= 0 ? (int)title_count : 0);
|
||||
}
|
||||
else
|
||||
{
|
||||
record("es_title_count", round, SKIPPED, 0);
|
||||
}
|
||||
|
||||
probe_sd(round);
|
||||
}
|
||||
|
||||
if (fs_init >= 0)
|
||||
record("isfs_deinit", 0, ISFS_Deinitialize(), 0);
|
||||
else
|
||||
record("isfs_deinit", 0, SKIPPED, 0);
|
||||
if (es_init >= 0)
|
||||
record("es_deinit", 0, __ES_Close(), 0);
|
||||
else
|
||||
record("es_deinit", 0, SKIPPED, 0);
|
||||
|
||||
const int write_error = ferror(output);
|
||||
const int close_error = fclose(output);
|
||||
printf("%u observations. CSV: sd:/wii-conformance.csv\n", sequence);
|
||||
printf("Write status: %d, close status: %d\n", write_error, close_error);
|
||||
printf("Returning to loader in about 10 seconds.\n");
|
||||
for (int i = 0; i < 600; ++i)
|
||||
VIDEO_WaitVSync();
|
||||
return write_error || close_error ? 1 : 0;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import contextlib
|
||||
import io
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from compare import compare, read_capture
|
||||
|
||||
|
||||
def capture(version=58, value=0):
|
||||
return ("schema,1\nrounds,64\nobservations,1\nios_version,%d\nios_revision,6176\n"
|
||||
"seq,test,round,value,extra\n0,open_fs_mode0,0,%d,0\n" % (version, value))
|
||||
|
||||
|
||||
class CompareTest(unittest.TestCase):
|
||||
def test_identical_and_first_difference(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
wii = Path(directory) / "wii.csv"
|
||||
dolphin = Path(directory) / "dolphin.csv"
|
||||
wii.write_text(capture(), encoding="utf-8")
|
||||
dolphin.write_text(capture(), encoding="utf-8")
|
||||
with contextlib.redirect_stdout(io.StringIO()):
|
||||
self.assertEqual(compare(read_capture(wii), read_capture(dolphin)), 0)
|
||||
dolphin.write_text(capture(value=-6), encoding="utf-8")
|
||||
output = io.StringIO()
|
||||
with contextlib.redirect_stdout(output):
|
||||
self.assertEqual(compare(read_capture(wii), read_capture(dolphin)), 1)
|
||||
self.assertIn("#0", output.getvalue())
|
||||
|
||||
def test_wrong_ios_version_is_incomparable(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
wii = Path(directory) / "wii.csv"
|
||||
dolphin = Path(directory) / "dolphin.csv"
|
||||
wii.write_text(capture(), encoding="utf-8")
|
||||
dolphin.write_text(capture(version=80), encoding="utf-8")
|
||||
with contextlib.redirect_stdout(io.StringIO()):
|
||||
self.assertEqual(compare(read_capture(wii), read_capture(dolphin)), 2)
|
||||
|
||||
def test_truncated_capture_is_rejected(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = Path(directory) / "short.csv"
|
||||
path.write_text(capture().replace("observations,1", "observations,2"), encoding="utf-8")
|
||||
with self.assertRaises(ValueError):
|
||||
read_capture(path)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user