diff --git a/Source/UnitTests/Core/CMakeLists.txt b/Source/UnitTests/Core/CMakeLists.txt index 50457d2a91..66351f12b4 100644 --- a/Source/UnitTests/Core/CMakeLists.txt +++ b/Source/UnitTests/Core/CMakeLists.txt @@ -16,6 +16,9 @@ add_dolphin_test(ESFormatsTest IOS/ES/FormatsTest.cpp) add_dolphin_test(StarletARMCoreTest IOS/Starlet/ARMCoreTest.cpp) add_dolphin_test(StarletBootstrapELFTest IOS/Starlet/BootstrapELFTest.cpp) +# StarletBootstrapProbe's StarOS panic check (Tools/StarletBootstrapProbe/StarOSPanicCheck.h). +add_dolphin_test(StarletProbePanicCheckTest IOS/Starlet/ProbePanicCheckTest.cpp) +target_include_directories(StarletProbePanicCheckTest PRIVATE ${CMAKE_SOURCE_DIR}/Tools/StarletBootstrapProbe) add_dolphin_test(StarletDMATest IOS/Starlet/DMATest.cpp) add_dolphin_test(StarletSRAMReadTest IOS/Starlet/SRAMReadTest.cpp) add_dolphin_test(StarletSRAMWriteTest IOS/Starlet/SRAMWriteTest.cpp) diff --git a/Source/UnitTests/Core/IOS/Starlet/ProbePanicCheckTest.cpp b/Source/UnitTests/Core/IOS/Starlet/ProbePanicCheckTest.cpp new file mode 100644 index 0000000000..5842cf0ec9 --- /dev/null +++ b/Source/UnitTests/Core/IOS/Starlet/ProbePanicCheckTest.cpp @@ -0,0 +1,99 @@ +// Copyright 2026 Dolphin Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include +#include + +#include + +#include "StarOSPanicCheck.h" + +namespace StarletBootstrapProbe +{ +namespace +{ +// MEM1 words by address; absent words read as zero. +class FakeMem1 +{ +public: + std::map words; + u32 operator()(u32 address) const + { + const auto it = words.find(address); + return it == words.end() ? 0 : it->second; + } +}; + +// The record as the loader leaves it, before the image runs. +FakeMem1 CreatedRecord() +{ + FakeMem1 mem1; + mem1.words[SBR1_BASE] = SBR1_MAGIC; + mem1.words[SBR1_BASE + 0x04] = SBR1_VERSION; + mem1.words[SBR1_BASE + 0x08] = SBR1_SIZE; + mem1.words[SBR1_BASE + SBR1_EXI_OWNER] = SBR1_EXI_STARLET; + mem1.words[SBR1_BASE + SBR1_RELAY_STATUS] = SBR1_RELAY_JUMPING; + return mem1; +} + +// The record once StarOS's panic path has published it. +FakeMem1 PanicRecord() +{ + FakeMem1 mem1 = CreatedRecord(); + mem1.words[SBR1_BASE + SBR1_STARLET_STATE] = SBR1_STATE_PANIC; + mem1.words[SBR1_BASE + SBR1_EXI_OWNER] = SBR1_EXI_PPC; + return mem1; +} + +TEST(StarletProbePanicCheck, WaitsForTheCodeAfterTheGuard) +{ + const FakeMem1 none; + EXPECT_EQ(UnmetPanicCondition(1, 1, SBR1Mode::None, none), ""); + // The guard is stored before the code: a slice can end in between. + EXPECT_EQ(UnmetPanicCondition(1, 0, SBR1Mode::None, none), "__panic_code is 0, not 1"); + EXPECT_EQ(UnmetPanicCondition(1, 2, SBR1Mode::None, none), "__panic_code is 2, not 1"); + EXPECT_EQ(UnmetPanicCondition(0, 1, SBR1Mode::None, none), "__panic_state is 0, not 1"); + EXPECT_EQ(UnmetPanicCondition(2, 1, SBR1Mode::None, none), "__panic_state is 2, not 1"); + // The words come first, whatever the record says. + EXPECT_EQ(UnmetPanicCondition(1, 0, SBR1Mode::Record, PanicRecord()), + "__panic_code is 0, not 1"); +} + +TEST(StarletProbePanicCheck, RecordNeedsPanicStateReasonAndEXIBack) +{ + EXPECT_EQ(UnmetPanicCondition(1, 1, SBR1Mode::Record, PanicRecord()), ""); + // Panic words set, record not yet published. + EXPECT_EQ(UnmetPanicCondition(1, 1, SBR1Mode::Record, CreatedRecord()), + "SBR1 STARLET_STATE is 0, not 5"); + FakeMem1 running = PanicRecord(); + running.words[SBR1_BASE + SBR1_STARLET_STATE] = 2; + EXPECT_EQ(UnmetPanicCondition(1, 1, SBR1Mode::Record, running), "SBR1 STARLET_STATE is 2, not 5"); + FakeMem1 reason = PanicRecord(); + reason.words[SBR1_BASE + SBR1_FATAL_REASON] = 3; + EXPECT_EQ(UnmetPanicCondition(1, 1, SBR1Mode::Record, reason), "SBR1 FATAL_REASON is 3, not 0"); + // State published, EXI not yet handed back to the PPC. + FakeMem1 exi = PanicRecord(); + exi.words[SBR1_BASE + SBR1_EXI_OWNER] = SBR1_EXI_STARLET; + EXPECT_EQ(UnmetPanicCondition(1, 1, SBR1Mode::Record, exi), "SBR1 EXI_OWNER is 2, not 1"); +} + +TEST(StarletProbePanicCheck, AbsentRecordAreaStaysZero) +{ + EXPECT_EQ(UnmetPanicCondition(1, 1, SBR1Mode::Absent, FakeMem1{}), ""); + for (const u32 offset : {0x0u, SBR1_STARLET_STATE, SBR1_EXI_OWNER, SBR1_SIZE - 4}) + { + FakeMem1 written; + written.words[SBR1_BASE + offset] = 5; + EXPECT_EQ(UnmetPanicCondition(1, 1, SBR1Mode::Absent, written), + fmt::format("SBR1 area word +{:#x} is 0x5 without a record", offset)); + } + // Words outside the area are not the record's. + FakeMem1 outside; + outside.words[SBR1_BASE - 4] = 5; + outside.words[SBR1_BASE + SBR1_SIZE] = 5; + EXPECT_EQ(UnmetPanicCondition(1, 1, SBR1Mode::Absent, outside), ""); + // Without an SBR1 option, a written area is not checked. + EXPECT_EQ(UnmetPanicCondition(1, 1, SBR1Mode::None, PanicRecord()), ""); +} +} // namespace +} // namespace StarletBootstrapProbe diff --git a/Tools/StarletBootstrapProbe/StarOSPanicCheck.h b/Tools/StarletBootstrapProbe/StarOSPanicCheck.h new file mode 100644 index 0000000000..89b5979a22 --- /dev/null +++ b/Tools/StarletBootstrapProbe/StarOSPanicCheck.h @@ -0,0 +1,89 @@ +// Copyright 2026 Dolphin Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include +#include + +#include + +#include "Common/CommonTypes.h" + +// What StarletBootstrapProbe requires of a finished StarOS panic path (StarOS ADR-0014 R6). The +// path sets __panic_state first, then __panic_code, then publishes the SBR1 boot record when +// there is one, so the first nonzero guard can show it half done: the probe keeps running until +// every condition holds. Words come through a callback, so the check is tested without a CPU. +namespace StarletBootstrapProbe +{ +// StarOS's SBR1 boot record in MEM1 (StarOS crates/boot-contract, report.rs). +enum class SBR1Mode +{ + None, + // --sbr1-record: the probe creates the record as the loader does. + Record, + // --sbr1-absent: no record; its area must stay zero. + Absent, +}; +constexpr u32 SBR1_BASE = 0x00200000; +constexpr u32 SBR1_SIZE = 0x1000; +constexpr u32 SBR1_MAGIC = 0x53425231; +constexpr u32 SBR1_VERSION = 1; +// Word offsets. +constexpr u32 SBR1_EXI_OWNER = 0x0c; +constexpr u32 SBR1_RELAY_STATUS = 0x10; +constexpr u32 SBR1_STARLET_STATE = 0x18; +constexpr u32 SBR1_FATAL_REASON = 0x1c; +constexpr u32 SBR1_FAULT_CODE = 0x20; +constexpr u32 SBR1_RING_LENGTH = 0x2c; +constexpr u32 SBR1_RING_DROPPED = 0x30; +// Values. +constexpr u32 SBR1_EXI_PPC = 1; +constexpr u32 SBR1_EXI_STARLET = 2; +constexpr u32 SBR1_RELAY_JUMPING = 4; +constexpr u32 SBR1_STATE_PANIC = 5; +// __panic_state once the path is entered; __panic_code of a Rust panic. +constexpr u32 PANIC_ENTERED = 1; +constexpr u32 PANIC_CODE_RUST = 1; + +// The first condition of a finished panic path that does not hold, or an empty string: +// - __panic_state 1 and __panic_code 1; +// - with --sbr1-record: STARLET_STATE 5 (Panic), FATAL_REASON 0 and EXI_OWNER 1 (PPC); +// - with --sbr1-absent: every word of the record area still zero. +// read_mem1 returns the big-endian MEM1 word at an address. +inline std::string UnmetPanicCondition(u32 panic_state, u32 panic_code, SBR1Mode sbr1, + const std::function& read_mem1) +{ + if (panic_state != PANIC_ENTERED) + return fmt::format("__panic_state is {}, not {}", panic_state, PANIC_ENTERED); + if (panic_code != PANIC_CODE_RUST) + return fmt::format("__panic_code is {}, not {}", panic_code, PANIC_CODE_RUST); + if (sbr1 == SBR1Mode::Record) + { + const struct + { + const char* name; + u32 offset; + u32 expected; + } fields[] = {{"STARLET_STATE", SBR1_STARLET_STATE, SBR1_STATE_PANIC}, + {"FATAL_REASON", SBR1_FATAL_REASON, 0}, + {"EXI_OWNER", SBR1_EXI_OWNER, SBR1_EXI_PPC}}; + for (const auto& field : fields) + { + const u32 value = read_mem1(SBR1_BASE + field.offset); + if (value != field.expected) + return fmt::format("SBR1 {} is {}, not {}", field.name, value, field.expected); + } + } + if (sbr1 == SBR1Mode::Absent) + { + for (u32 offset = 0; offset < SBR1_SIZE; offset += 4) + { + const u32 value = read_mem1(SBR1_BASE + offset); + if (value != 0) + return fmt::format("SBR1 area word +{:#x} is {:#x} without a record", offset, value); + } + } + return {}; +} +} // namespace StarletBootstrapProbe diff --git a/Tools/StarletBootstrapProbe/StarletBootstrapProbe.cpp b/Tools/StarletBootstrapProbe/StarletBootstrapProbe.cpp index 8f76e925b7..8b0a6914ca 100644 --- a/Tools/StarletBootstrapProbe/StarletBootstrapProbe.cpp +++ b/Tools/StarletBootstrapProbe/StarletBootstrapProbe.cpp @@ -17,9 +17,12 @@ #include "Core/IOS/Starlet/BootstrapELF.h" #include "Core/IOS/Starlet/StarletMemory.h" #include "Core/System.h" +#include "StarOSPanicCheck.h" namespace { +using namespace StarletBootstrapProbe; + enum class ExpectedOutcome { Idle, @@ -32,21 +35,8 @@ enum class ExpectedOutcome // Exit status of a run stopped by a Dolphin alert (1: failed expectation, 2: usage). constexpr int ALERT_EXIT_STATUS = 3; -// StarOS's SBR1 boot record in MEM1 (StarOS crates/boot-contract, report.rs). -enum class SBR1Mode -{ - None, - // --sbr1-record: create the record as the loader does, report it after the run. - Record, - // --sbr1-absent: no record; report the area after the run. - Absent, -}; -constexpr u32 SBR1_BASE = 0x00200000; -constexpr u32 SBR1_MAGIC = 0x53425231; -constexpr u32 SBR1_VERSION = 1; -constexpr u32 SBR1_SIZE = 0x1000; -constexpr u32 SBR1_EXI_STARLET = 2; -constexpr u32 SBR1_RELAY_JUMPING = 4; +// 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. @@ -57,7 +47,8 @@ bool FailOnAlert(const char* caption, const char* text, bool, Common::MsgType) std::_Exit(ALERT_EXIT_STATUS); } -bool Run(const std::string& path, bool jit, bool mmu_faults, ExpectedOutcome expected) +bool Run(const std::string& path, bool jit, bool mmu_faults, SBR1Mode sbr1, + ExpectedOutcome expected) { std::ifstream file(path, std::ios::binary | std::ios::ate); if (!file || file.tellg() <= 0 || file.tellg() > 16 * 1024 * 1024) @@ -95,6 +86,26 @@ bool Run(const std::string& path, bool jit, bool mmu_faults, ExpectedOutcome exp 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'; + }; + int panic_settled = 0; for (int slice = 0; slice < 512; ++slice) { core.SetIRQLine(system.GetWiiIPC().IsStarletIRQAsserted()); @@ -109,21 +120,25 @@ bool Run(const std::string& path, bool jit, bool mmu_faults, ExpectedOutcome exp return expected == ExpectedOutcome::UndefinedFault && fault_code == 1 && memory.Read32(info.fault_lr) != 0 && memory.Read32(info.fault_spsr) != 0; } - // StarOS's panic path writes no trace line: it sets its guard word, then its code, and halts. - // The trace published so far is printed for the scenario's ordered checks. + // 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) { - const u32 panic_state = memory.Read32(info.panic_state); - const u32 panic_code = memory.Read32(info.panic_code); - 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 " << panic_state << " code " << panic_code << '\n'; - return expected == ExpectedOutcome::Panic && panic_state == 1; + 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; @@ -174,6 +189,14 @@ bool Run(const std::string& path, bool jit, bool mmu_faults, ExpectedOutcome exp 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 2097152 ARM cycles: " + << (unmet.empty() ? "it ended inside the settle slices" : unmet) << '\n'; + return false; + } std::cerr << "No terminal bootstrap marker after 2097152 ARM cycles, PC=0x" << std::hex << core.GetRegister(15) << std::dec << '\n'; return false; @@ -246,21 +269,22 @@ int main(int argc, char** argv) // 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 + 0x0c); - memory.Write_U32(SBR1_RELAY_JUMPING, SBR1_BASE + 0x10); + 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, expected); + const bool passed = Run(argv[1], jit, mmu_faults, sbr1, expected); if (sbr1 != SBR1Mode::None) { - // The record's summary after the run; with --sbr1-absent every word must still be zero. + // 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 + 0x18) << " reason " - << memory.Read_U32(SBR1_BASE + 0x1c) << " owner " - << memory.Read_U32(SBR1_BASE + 0x0c) << " fault " - << memory.Read_U32(SBR1_BASE + 0x20) << " ring " - << memory.Read_U32(SBR1_BASE + 0x2c) << " dropped " - << memory.Read_U32(SBR1_BASE + 0x30); + << " 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;