The probe stands in for Broadway, so that a real request can enter StarOS's image through the Hollywood IPC registers and its reply be read back (StarOS docs/contracts/hollywood-ipc-injection.md, approved for development). A script is data: whole words stored in one range of PPC memory, 0x135E0000..0x13600000, PPCMSG and PPCCTRL written, PPCCTRL and ARMMSG read, every wait bounded in ARM cycles. Steps run between the probe's slices. The probe knows nothing of the code under test and answers nothing for it. A script refused when read ends the run with exit status 5, before StarOS starts. A step that fails, or a terminal trace line with steps left, fails the run with its line on stderr. The option given twice, or without its file, is a usage error. The one change in Source/Core: WiiIPC::SetStarletAttached, an opt-in flag UpdateInterrupts reads beside GetStarlet(), because the probe runs an ARMCore with no Starlet object in the System. The IPC cause is then raised by the model's own line, X1 and IX1 included, with no second model of it. Reset clears the flag, and nothing sets it without --ipc-script. ProbeScript.h holds the parser and the runner, tested by StarletProbeScriptTest over a fake bus; StarletProbeInterruptTest holds the flag's behaviour with and without it. Co-Authored-By: Claude Opus 5.5 <[email protected]>
472 lines
18 KiB
C++
472 lines
18 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 "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<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;
|
|
}
|
|
|
|
// Reads and parses the file of --ipc-script: its runner, or nullopt once the reason is on stderr.
|
|
std::optional<ScriptRunner> 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<u64>(end) > SCRIPT_MAX_BYTES)
|
|
{
|
|
std::cerr << "--ipc-script: refused " << path << ": over " << SCRIPT_MAX_BYTES << " bytes\n";
|
|
return std::nullopt;
|
|
}
|
|
std::string text(static_cast<size_t>(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;
|
|
};
|
|
|
|
bool Run(const std::string& path, bool jit, bool mmu_faults, SBR1Mode sbr1,
|
|
ExpectedOutcome expected, u64 cycles, const std::vector<u8>* package, ScriptRunner* script)
|
|
{
|
|
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';
|
|
};
|
|
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;
|
|
}
|
|
}
|
|
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';
|
|
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';
|
|
return false;
|
|
}
|
|
} // namespace
|
|
|
|
int main(int argc, char** argv)
|
|
{
|
|
Common::RegisterMsgAlertHandler(FailOnAlert);
|
|
if (argc < 2 || argc > 12)
|
|
{
|
|
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>] [--ipc-script <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;
|
|
std::optional<std::string> 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 == "--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;
|
|
std::optional<ScriptRunner> 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);
|
|
}
|
|
const bool passed = Run(argv[1], jit, mmu_faults, sbr1, expected, cycles.value_or(DEFAULT_CYCLES),
|
|
package ? &*package : nullptr, script ? &*script : 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;
|
|
}
|