// Copyright 2026 Dolphin Emulator Project // SPDX-License-Identifier: GPL-2.0-or-later #include #include #include #include #include #include #include #include #include "Common/MsgHandler.h" #include "Core/Core.h" #include "Core/HW/Memmap.h" #include "Core/HW/WII_IPC.h" #include "Core/IOS/Starlet/ARMCore.h" #include "Core/IOS/Starlet/BootstrapELF.h" #include "Core/IOS/Starlet/StarletMemory.h" #include "Core/System.h" #include "ProbeBroadway.h" #include "ProbeBudget.h" #include "ProbeRing.h" #include "ProbeScript.h" #include "ProbeStaging.h" #include "StarOSPanicCheck.h" namespace { using namespace StarletBootstrapProbe; enum class ExpectedOutcome { Idle, Panic, FatalData, UndefinedFault, Phase2 }; // Exit status of a run stopped by a Dolphin alert (1: failed expectation, 2: usage). constexpr int ALERT_EXIT_STATUS = 3; // Slices run once a StarOS panic path looks finished, to see that it stays so. constexpr int PANIC_SETTLE_SLICES = 2; // Dolphin's default handler shows an alert in a modal MessageBox on Windows, which blocks the // probe until someone closes it. Report the alert on stderr and fail the run at once instead. bool FailOnAlert(const char* caption, const char* text, bool, Common::MsgType) { std::cout.flush(); std::cerr << "Dolphin alert (" << caption << "): " << text << std::endl; std::_Exit(ALERT_EXIT_STATUS); } // Reads the package file for --stage-spk: its bytes, or nullopt once the reason is on stderr (a // missing or unreadable file; a length StagingRefusal refuses). std::optional> ReadPackage(const std::string& path) { std::ifstream file(path, std::ios::binary | std::ios::ate); if (!file) { std::cerr << "--stage-spk: cannot open the package file: " << path << '\n'; return std::nullopt; } const std::streamoff end = file.tellg(); if (end < 0) { std::cerr << "--stage-spk: cannot size the package file: " << path << '\n'; return std::nullopt; } if (const auto refusal = StagingRefusal(static_cast(end))) { std::cerr << "--stage-spk: refused " << path << " (" << end << " bytes): " << *refusal << '\n'; return std::nullopt; } std::vector package(static_cast(end)); file.seekg(0); if (!file.read(reinterpret_cast(package.data()), end)) { std::cerr << "--stage-spk: cannot read the package file: " << path << '\n'; return std::nullopt; } return package; } // Reads and parses the file of --ipc-script: its runner, or nullopt once the reason is on stderr. std::optional ReadScript(const std::string& path) { std::ifstream file(path, std::ios::binary | std::ios::ate); if (!file) { std::cerr << "--ipc-script: cannot open the script: " << path << '\n'; return std::nullopt; } const std::streamoff end = file.tellg(); if (end < 0 || static_cast(end) > SCRIPT_MAX_BYTES) { std::cerr << "--ipc-script: refused " << path << ": over " << SCRIPT_MAX_BYTES << " bytes\n"; return std::nullopt; } std::string text(static_cast(end), '\0'); file.seekg(0); if (!file.read(text.data(), end)) { std::cerr << "--ipc-script: cannot read the script: " << path << '\n'; return std::nullopt; } std::string error; auto steps = ParseScript(text, &error); if (!steps) { std::cerr << "--ipc-script: refused " << path << ": " << error << '\n'; return std::nullopt; } return ScriptRunner(std::move(*steps)); } // What a script reaches: PPC memory and the registers, as Broadway does. class ProbeBus final : public ScriptBus { public: ProbeBus(Memory::MemoryManager& memory, IOS::WiiIPC& ipc) : m_memory(memory), m_ipc(ipc) {} void StoreWord(u32 address, u32 word) override { m_memory.Write_U32(word, address); } u32 LoadWord(u32 address) override { return m_memory.Read_U32(address); } void WriteRegister(u32 offset, u32 value) override { m_ipc.WriteStarletRegister(offset, value); } u32 ReadRegister(u32 offset) override { return m_ipc.ReadStarletRegister(offset); } private: Memory::MemoryManager& m_memory; IOS::WiiIPC& m_ipc; }; // What the Broadway client reaches: PPC memory, by whole words (ProbeBroadway.h bounds it to the // bench window). class ProbeMemory final : public BroadwayMemory { public: explicit ProbeMemory(Memory::MemoryManager& memory) : m_memory(memory) {} u32 LoadWord(u32 address) override { return m_memory.Read_U32(address); } void StoreWord(u32 address, u32 word) override { m_memory.Write_U32(word, address); } private: Memory::MemoryManager& m_memory; }; bool Run(const std::string& path, bool jit, bool mmu_faults, SBR1Mode sbr1, ExpectedOutcome expected, u64 cycles, const std::vector* package, ScriptRunner* script, BroadwayClient* client) { std::ifstream file(path, std::ios::binary | std::ios::ate); if (!file || file.tellg() <= 0 || file.tellg() > 16 * 1024 * 1024) { std::cerr << "Expected a readable Starlet ELF of at most 16 MiB: " << path << '\n'; return false; } const size_t size = static_cast(file.tellg()); std::vector image(size); file.seekg(0); if (!file.read(reinterpret_cast(image.data()), size)) { std::cerr << "Could not read Starlet ELF: " << path << '\n'; return false; } Core::DeclareAsCPUThread(); auto& system = Core::System::GetInstance(); system.GetWiiIPC().Reset(); IOS::LLE::StarletMemory memory(system); memory.PrepareBootstrap(); IOS::LLE::BootstrapELFInfo info; std::string error; if (!IOS::LLE::LoadBootstrapELF(image, memory, &info, &error)) { std::cerr << error << '\n'; return false; } if (package) { // The image loaded, then the package at the window's base, its bytes as the file holds them // (big-endian words, as the Starlet reads them); read back before the first instruction. auto& mem2 = system.GetMemory(); mem2.CopyToEmu(SPK_WINDOW_BASE, package->data(), package->size()); std::vector staged(package->size()); mem2.CopyFromEmu(staged.data(), SPK_WINDOW_BASE, staged.size()); if (staged != *package) { std::cerr << "--stage-spk: the package window does not read back as staged\n"; return false; } std::cerr << "Staged SPK package: " << package->size() << " bytes at 0x" << std::hex << SPK_WINDOW_BASE << std::dec << '\n'; } // --broadway-h1a: StarOS's loader sets the bench window up before the jump; so does the client, // before the Starlet's first instruction. ProbeMemory client_memory(system.GetMemory()); if (client) { std::cerr << "--broadway-h1a: StarOS loader sources built in:\n" << BroadwayClient::Sources(); if (!client->Start(client_memory) || client->Failed()) { std::cerr << "--broadway-h1a: " << client->Failure() << '\n'; return false; } } IOS::LLE::ARMCore core(memory); // Declared after memory, so destroyed first. memory.AttachInterruptLines(&core); core.SetJitEnabled(jit); core.Reset(info.entry); core.SetBigEndian(true); // StarOS MMU-001: translation, domain, permission and alignment aborts (off by default). core.SetMMUFaultsEnabled(mmu_faults); constexpr u32 TRACE_MAGIC = 0x52455631; constexpr u32 TRACE_CAPACITY = 1024; auto& mem1 = system.GetMemory(); const auto read_mem1 = [&mem1](u32 address) { return mem1.Read_U32(address); }; const auto panic_unmet = [&] { return UnmetPanicCondition(memory.Read32(info.panic_state), memory.Read32(info.panic_code), sbr1, read_mem1); }; // StarOS's panic path writes no trace line. Print the trace published so far, for the // scenario's ordered checks, and the panic words. const auto report_panic = [&] { const u32 length = memory.Read32(info.trace) == TRACE_MAGIC ? std::min(memory.Read32(info.trace + 4), TRACE_CAPACITY) : 0; for (u32 index = 0; index < length; ++index) std::cout << static_cast(memory.Read8(info.trace + 8 + index)); std::cout << "PC=0x" << std::hex << core.GetRegister(15) << std::dec << " instructions=" << core.GetExecutedInstructions() << '\n'; std::cerr << "Starlet panic state " << memory.Read32(info.panic_state) << " code " << memory.Read32(info.panic_code) << '\n'; }; // The client's capture on stdout, after the trace, and its counts on stderr. The report reads the // window too: false if it reached outside (the access refused, as during the run). const auto report_client = [&](std::string_view status) { std::cout << client->Report(status); std::cerr << "--broadway-h1a: " << client->Steps() << " steps, timebase 0x" << std::hex << client->Timebase() << std::dec << ", done " << client->Done() << '\n'; if (client->Failed()) std::cerr << "--broadway-h1a: " << client->Failure() << '\n'; return !client->Failed(); }; ProbeBus script_bus(mem1, system.GetWiiIPC()); // The model's own line raises the IPC cause: the probe has no Starlet object (ProbeScript.h). if (script) system.GetWiiIPC().SetStarletAttached(true); int panic_settled = 0; for (u64 slice = 0; slice < cycles / SLICE_CYCLES; ++slice) { if (script) { script->Advance(script_bus, slice == 0 ? 0 : SLICE_CYCLES); if (script->Failed()) { std::cerr << "--ipc-script: " << script->Failure() << '\n'; return false; } } if (client) { // As the loader's monitor: one step of the bench a turn, the first at the jump. client->Advance(slice == 0 ? 0 : SLICE_CYCLES); if (client->Failed()) { std::cerr << "--broadway-h1a: " << client->Failure() << '\n'; return false; } } core.SetIRQLine(system.GetWiiIPC().IsStarletIRQAsserted()); core.SetFIQLine(system.GetWiiIPC().IsStarletFIQAsserted()); core.RunCycles(SLICE_CYCLES); const u32 fault_code = memory.Read32(info.fault_code); if (fault_code != 0) { std::cerr << "Starlet exception " << fault_code << " LR=" << std::hex << memory.Read32(info.fault_lr) << " SPSR=" << memory.Read32(info.fault_spsr) << " PC=" << core.GetRegister(15) << std::dec << '\n'; return expected == ExpectedOutcome::UndefinedFault && fault_code == 1 && memory.Read32(info.fault_lr) != 0 && memory.Read32(info.fault_spsr) != 0; } // StarOS's panic path sets its guard word first, then its code, then publishes the SBR1 // record when there is one, and halts. An expected panic passes once all of that holds // (UnmetPanicCondition) and still holds PANIC_SETTLE_SLICES slices later. if (info.panic_state != 0 && memory.Read32(info.panic_state) != 0) { if (expected != ExpectedOutcome::Panic) { report_panic(); return false; } if (!panic_unmet().empty()) { panic_settled = 0; continue; } if (++panic_settled <= PANIC_SETTLE_SLICES) continue; report_panic(); return true; } if (client) { // --broadway-h1a: the loader's monitor reports once the SBR1 record says the Starlet is done // (StarletDone) and its bench has ended; the trace, possibly full, is printed as it stands. const u32 length = memory.Read32(info.trace) == TRACE_MAGIC ? std::min(memory.Read32(info.trace + 4), TRACE_CAPACITY) : 0; std::string trace(length, '\0'); for (u32 index = 0; index < length; ++index) trace[index] = static_cast(memory.Read8(info.trace + 8 + index)); const bool trace_terminal = trace.find("REV:BOOT:IDLE\n") != std::string::npos || trace.find("REV:PANIC") != std::string::npos || trace.find("REV:FATAL") != std::string::npos; const u32 state = mem1.Read_U32(SBR1_BASE + SBR1_STARLET_STATE); if (!StarletDone(trace_terminal, state, mem1.Read_U32(SBR1_BASE + SBR1_EXI_OWNER)) || !client->Done()) { continue; } std::cout << trace; std::cout << "PC=0x" << std::hex << core.GetRegister(15) << std::dec << " instructions=" << core.GetExecutedInstructions() << '\n'; if (length == TRACE_CAPACITY) std::cerr << "--broadway-h1a: the trace is full: its last lines are in the SBR1 ring\n"; const bool reported = report_client(CLIENT_STATUS_DONE); const bool startup_ok = trace.find("REV:BOOT:ENTRY\n") != std::string::npos && trace.find("REV:BOOT:BSS_OK\n") != std::string::npos && trace.find("REV:BOOT:DATA_OK\n") != std::string::npos && trace.find("REV:BOOT:STACK_OK\n") != std::string::npos && trace.find("REV:BOOT:RUST\n") != std::string::npos; const bool idle = state == SBR1_STATE_IDLE || trace.find("REV:BOOT:IDLE\n") != std::string::npos; return reported && startup_ok && idle && trace.find("REV:PANIC") == std::string::npos && trace.find("REV:FATAL") == std::string::npos; } if (memory.Read32(info.trace) != TRACE_MAGIC) continue; const u32 length = memory.Read32(info.trace + 4); if (length > TRACE_CAPACITY) { std::cerr << "Invalid REV_TRACE length: " << length << '\n'; return false; } std::string trace(length, '\0'); for (u32 index = 0; index < length; ++index) trace[index] = static_cast(memory.Read8(info.trace + 8 + index)); if (trace.find("REV:BOOT:IDLE\n") == std::string::npos && trace.find("REV:PANIC") == std::string::npos && trace.find("REV:FATAL") == std::string::npos) { continue; } std::cout << trace; std::cout << "PC=0x" << std::hex << core.GetRegister(15) << std::dec << " instructions=" << core.GetExecutedInstructions() << '\n'; if (script && !script->Done()) { std::cerr << "--ipc-script: script unfinished at line " << script->Line() << '\n'; return false; } const bool early_startup_ok = trace.find("REV:BOOT:ENTRY\n") != std::string::npos && trace.find("REV:BOOT:BSS_OK\n") != std::string::npos; if (expected == ExpectedOutcome::FatalData) { return early_startup_ok && trace.find("REV:FATAL:DATA\n") != std::string::npos && trace.find("REV:BOOT:DATA_OK\n") == std::string::npos; } const bool startup_ok = early_startup_ok && trace.find("REV:BOOT:DATA_OK\n") != std::string::npos && trace.find("REV:BOOT:STACK_OK\n") != std::string::npos && trace.find("REV:BOOT:RUST\n") != std::string::npos; if (expected == ExpectedOutcome::Phase2) { const std::string_view required[] = { "REV:P2:START\n", "ARCH PASS\n", "CRITICAL PASS\n", "TIMER PASS\n", "MONOTONIC PASS\n", "MEM PASS\n", "IRQ PASS\n", "BARRIER PASS\n", "LOGGING PASS\n", "REV:P2:PASS\n", "REV:P2:KERNEL_READY\n", "REV:BOOT:IDLE\n"}; bool phase2_ok = true; for (const std::string_view marker : required) phase2_ok &= trace.find(marker) != std::string::npos; return startup_ok && phase2_ok && trace.find(" FAIL\n") == std::string::npos; } const bool outcome_ok = expected == ExpectedOutcome::Panic ? trace.find("REV:PANIC") != std::string::npos : expected == ExpectedOutcome::Idle && trace.find("REV:BOOT:IDLE\n") != std::string::npos; return startup_ok && outcome_ok; } if (info.panic_state != 0 && memory.Read32(info.panic_state) != 0) { report_panic(); const std::string unmet = panic_unmet(); std::cerr << "StarOS panic path unfinished after " << cycles << " ARM cycles: " << (unmet.empty() ? "it ended inside the settle slices" : unmet) << '\n'; return false; } std::cerr << "No terminal bootstrap marker after " << cycles << " ARM cycles, PC=0x" << std::hex << core.GetRegister(15) << std::dec << '\n'; // As main.c's monitor at its own timeout: the report anyway, under SBL:TIMEOUT. if (client) report_client(CLIENT_STATUS_TIMEOUT); return false; } } // namespace int main(int argc, char** argv) { Common::RegisterMsgAlertHandler(FailOnAlert); if (argc < 2 || argc > 12) { std::cerr << "Usage: StarletBootstrapProbe [--jit] [--mmu-faults] " "[--sbr1-record|--sbr1-absent] " "[--expect-panic|--expect-fatal-data|--expect-undefined-fault|--expect-phase2] " "[--cycles ] [--stage-spk ] [--ipc-script |--broadway-h1a]\n"; return 2; } bool jit = false; bool mmu_faults = false; SBR1Mode sbr1 = SBR1Mode::None; ExpectedOutcome expected = ExpectedOutcome::Idle; bool outcome_selected = false; std::optional cycles; bool broadway_h1a = false; // --cycles' bound: MAX_CYCLES, or MAX_CLIENT_CYCLES when --broadway-h1a is among the options // (ProbeBudget.h, ClientRequested), known before they are read so that --cycles is read where // it stands. const u64 max_cycles = ClientRequested(argc, argv) ? MAX_CLIENT_CYCLES : MAX_CYCLES; std::optional package_path; std::optional script_path; for (int index = 2; index < argc; ++index) { const std::string_view option = argv[index]; if (option == "--ipc-script") { // One script, given once (ProbeScript.h). if (script_path || index + 1 == argc) { std::cerr << "--ipc-script takes one file, once\n"; return 2; } script_path = argv[++index]; } else if (option == "--stage-spk") { // One package file, given once (ProbeStaging.h). if (package_path || index + 1 == argc) { std::cerr << "--stage-spk takes one package file, once\n"; return 2; } package_path = argv[++index]; } else if (option == "--broadway-h1a") { // StarOS's H-1A bench loader as Broadway (ProbeBroadway.h), once. if (broadway_h1a) { std::cerr << "--broadway-h1a is given once\n"; return 2; } broadway_h1a = true; } else if (option == "--cycles") { // A whole number of slices, at most max_cycles (ProbeBudget.h); given once. if (cycles || index + 1 == argc || !(cycles = ParseCycles(argv[index + 1], max_cycles))) { std::cerr << "--cycles takes one decimal multiple of " << SLICE_CYCLES << ", at most " << max_cycles << ", once\n"; return 2; } ++index; } else if (option == "--jit") jit = true; else if (option == "--mmu-faults") mmu_faults = true; else if (option == "--sbr1-record" || option == "--sbr1-absent") { if (sbr1 != SBR1Mode::None) { std::cerr << "Select only one SBR1 mode\n"; return 2; } sbr1 = option == "--sbr1-record" ? SBR1Mode::Record : SBR1Mode::Absent; } else if (option == "--expect-panic" || option == "--expect-fatal-data" || option == "--expect-undefined-fault" || option == "--expect-phase2") { if (outcome_selected) { std::cerr << "Select only one expected outcome\n"; return 2; } outcome_selected = true; expected = option == "--expect-panic" ? ExpectedOutcome::Panic : option == "--expect-fatal-data" ? ExpectedOutcome::FatalData : option == "--expect-undefined-fault" ? ExpectedOutcome::UndefinedFault : ExpectedOutcome::Phase2; } else { std::cerr << "Unknown option: " << option << '\n'; return 2; } } // The client drives Broadway's side alone: no script, no staged package with it; and it reads // the SBR1 record, as the loader's monitor does, so the record must be there. if (broadway_h1a && (script_path || package_path)) { std::cerr << "--broadway-h1a takes neither --ipc-script nor --stage-spk\n"; return 2; } if (broadway_h1a && sbr1 != SBR1Mode::Record) { std::cerr << "--broadway-h1a takes --sbr1-record: the loader's monitor reads the record\n"; return 2; } // The package file read and checked before anything is set up: a refused file ends the run // before StarOS starts. std::optional> package; if (package_path && !(package = ReadPackage(*package_path))) return STAGING_EXIT_STATUS; std::optional script; if (script_path && !(script = ReadScript(*script_path))) return SCRIPT_EXIT_STATUS; // The Starlet shares MEM1 and MEM2 with Broadway. Back them as on a retail Wii (24 and 64 MiB, // zeroed): without them, any Starlet access there, such as StarOS reading its SBR1 record // header at 0x00200000, is an access to unmapped memory and raises an alert. auto& system = Core::System::GetInstance(); system.SetIsWii(true); system.GetMemory().Init(); auto& memory = system.GetMemory(); if (sbr1 == SBR1Mode::Record) { // As the Broadway loader and the ARM relay leave StarOS's SBR1 boot record before the image // runs (StarOS docs/contracts/wii-dev-boot.md): header, the relay at its last step, EXI // channel 1 given to the Starlet, the magic written last. memory.Write_U32(SBR1_VERSION, SBR1_BASE + 0x04); memory.Write_U32(SBR1_SIZE, SBR1_BASE + 0x08); memory.Write_U32(SBR1_EXI_STARLET, SBR1_BASE + SBR1_EXI_OWNER); memory.Write_U32(SBR1_RELAY_JUMPING, SBR1_BASE + SBR1_RELAY_STATUS); memory.Write_U32(SBR1_MAGIC, SBR1_BASE); } BroadwayClient client; const bool passed = Run(argv[1], jit, mmu_faults, sbr1, expected, cycles.value_or(DEFAULT_CYCLES), package ? &*package : nullptr, script ? &*script : nullptr, broadway_h1a ? &client : nullptr); if (sbr1 != SBR1Mode::None) { // The record's summary after the run; with --sbr1-absent every word must still be zero (an // expected panic fails otherwise). std::cerr << std::hex << "SBR1 magic 0x" << memory.Read_U32(SBR1_BASE) << std::dec << " state " << memory.Read_U32(SBR1_BASE + SBR1_STARLET_STATE) << " reason " << memory.Read_U32(SBR1_BASE + SBR1_FATAL_REASON) << " owner " << memory.Read_U32(SBR1_BASE + SBR1_EXI_OWNER) << " fault " << memory.Read_U32(SBR1_BASE + SBR1_FAULT_CODE) << " ring " << memory.Read_U32(SBR1_BASE + SBR1_RING_LENGTH) << " dropped " << memory.Read_U32(SBR1_BASE + SBR1_RING_DROPPED); u32 written = 0; for (u32 offset = 0; offset < SBR1_SIZE; offset += 4) written += memory.Read_U32(SBR1_BASE + offset) != 0; std::cerr << " nonzero_words " << written << '\n'; if (sbr1 == SBR1Mode::Record) { // The ring's text, as StarOS's Broadway loader prints it over the Gecko on the console; // END starts its own line even when the text does not end with one. const std::string ring = RingText([&memory](u32 offset) { return memory.Read_U8(SBR1_BASE + offset); }, memory.Read_U32(SBR1_BASE + SBR1_RING_LENGTH)); std::cerr << "SBR1 RING BEGIN\n" << ring << (ring.empty() || ring.back() == '\n' ? "" : "\n") << "SBR1 RING END\n"; } } system.GetMemory().Shutdown(); return passed ? 0 : 1; }