StarOS's I2-C image admits its User services from an SPK1 package that the loader stages in MEM2 before the Starlet's first instruction (StarOS docs/contracts/spk-staging.md). --stage-spk <file> places the file at the package window's base, 0x13720000, once the image is loaded: the address is the contract's, never an argument. The bytes are copied as the file holds them (big-endian words) and read back before the first instruction; a window that does not read back fails the run. The file is read and checked before anything is set up. A missing or unreadable file, an empty one, one whose length is not whole 32-bit words or that exceeds the 128 KiB window ends the run with its reason on stderr and exit status 4, before StarOS starts. The option given twice or without its file is a usage error (exit 2). The probe checks none of SPK1's fields or digests: that is StarOS's job. The size rule and the window are in ProbeStaging.h, tested by StarletProbeStagingTest. Without the option the probe runs as before. Co-Authored-By: Claude Opus 5.5 <[email protected]>
391 lines
15 KiB
C++
391 lines
15 KiB
C++
// Copyright 2026 Dolphin Emulator Project
|
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
|
|
|
#include <algorithm>
|
|
#include <cstdlib>
|
|
#include <fstream>
|
|
#include <iostream>
|
|
#include <optional>
|
|
#include <string>
|
|
#include <string_view>
|
|
#include <vector>
|
|
|
|
#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 "ProbeBudget.h"
|
|
#include "ProbeRing.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<std::vector<u8>> 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<u64>(end)))
|
|
{
|
|
std::cerr << "--stage-spk: refused " << path << " (" << end << " bytes): " << *refusal << '\n';
|
|
return std::nullopt;
|
|
}
|
|
std::vector<u8> package(static_cast<size_t>(end));
|
|
file.seekg(0);
|
|
if (!file.read(reinterpret_cast<char*>(package.data()), end))
|
|
{
|
|
std::cerr << "--stage-spk: cannot read the package file: " << path << '\n';
|
|
return std::nullopt;
|
|
}
|
|
return package;
|
|
}
|
|
|
|
bool Run(const std::string& path, bool jit, bool mmu_faults, SBR1Mode sbr1,
|
|
ExpectedOutcome expected, u64 cycles, const std::vector<u8>* package)
|
|
{
|
|
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<size_t>(file.tellg());
|
|
std::vector<u8> image(size);
|
|
file.seekg(0);
|
|
if (!file.read(reinterpret_cast<char*>(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<u8> 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';
|
|
}
|
|
|
|
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<char>(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';
|
|
};
|
|
int panic_settled = 0;
|
|
for (u64 slice = 0; slice < cycles / SLICE_CYCLES; ++slice)
|
|
{
|
|
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 (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<char>(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';
|
|
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';
|
|
return false;
|
|
}
|
|
} // namespace
|
|
|
|
int main(int argc, char** argv)
|
|
{
|
|
Common::RegisterMsgAlertHandler(FailOnAlert);
|
|
if (argc < 2 || argc > 10)
|
|
{
|
|
std::cerr << "Usage: StarletBootstrapProbe <ELF> [--jit] [--mmu-faults] "
|
|
"[--sbr1-record|--sbr1-absent] "
|
|
"[--expect-panic|--expect-fatal-data|--expect-undefined-fault|--expect-phase2] "
|
|
"[--cycles <n>] [--stage-spk <file>]\n";
|
|
return 2;
|
|
}
|
|
bool jit = false;
|
|
bool mmu_faults = false;
|
|
SBR1Mode sbr1 = SBR1Mode::None;
|
|
ExpectedOutcome expected = ExpectedOutcome::Idle;
|
|
bool outcome_selected = false;
|
|
std::optional<u64> cycles;
|
|
std::optional<std::string> package_path;
|
|
for (int index = 2; index < argc; ++index)
|
|
{
|
|
const std::string_view option = argv[index];
|
|
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 == "--cycles")
|
|
{
|
|
// A whole number of slices, at most MAX_CYCLES (ProbeBudget.h); given once.
|
|
if (cycles || index + 1 == argc || !(cycles = ParseCycles(argv[index + 1])))
|
|
{
|
|
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 package file read and checked before anything is set up: a refused file ends the run
|
|
// before StarOS starts.
|
|
std::optional<std::vector<u8>> package;
|
|
if (package_path && !(package = ReadPackage(*package_path)))
|
|
return STAGING_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);
|
|
}
|
|
const bool passed = Run(argv[1], jit, mmu_faults, sbr1, expected, cycles.value_or(DEFAULT_CYCLES),
|
|
package ? &*package : 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;
|
|
}
|