// Copyright 2026 Dolphin Emulator Project // SPDX-License-Identifier: GPL-2.0-or-later #include #include #include #include #include #include #include "ProbeBroadway.h" #include "ProbeBudget.h" #include "staros_h1a.h" #include "staros_hw0c.h" namespace StarletBootstrapProbe { namespace { // StarOS boot/wii/loader/board.h's timebase ticks a millisecond. constexpr u64 TICKS_PER_MS = 60750; // PPC memory as the client sees it: the bench window's words, and a count of what fell outside. class FakeMemory final : public BroadwayMemory { public: u32 LoadWord(u32 address) override { if (InBenchWindow(address)) return words[(address - BENCH_WINDOW_BASE) / 4]; ++outside; return 0; } void StoreWord(u32 address, u32 word) override { if (InBenchWindow(address)) words[(address - BENCH_WINDOW_BASE) / 4] = word; else ++outside; } u32& At(u32 offset) { return words[offset / 4]; } std::vector words = std::vector((BENCH_WINDOW_END - BENCH_WINDOW_BASE) / 4); int outside = 0; }; TEST(StarletProbeBroadway, TheWindowIsTheContractsAndNothingElseIsReached) { EXPECT_EQ(BENCH_WINDOW_BASE, u32{STAROS_HW0C_BASE}); EXPECT_EQ(BENCH_WINDOW_END, u32{STAROS_HW0C_BASE + STAROS_HW0C_SIZE}); FakeMemory memory; BroadwayHal hal(memory, 100); hal.Write(BENCH_WINDOW_BASE, 7); hal.Write(BENCH_WINDOW_END - 4, 9); EXPECT_EQ(hal.Read(BENCH_WINDOW_BASE), u32{7}); EXPECT_EQ(hal.Read(BENCH_WINDOW_END - 4), u32{9}); hal.CacheLine(BENCH_WINDOW_BASE + 0x20); EXPECT_FALSE(hal.Failed()); EXPECT_EQ(hal.Accesses(), u64{5}); // The first access outside fails the client, and is never made. EXPECT_EQ(hal.Read(BENCH_WINDOW_END), u32{0}); EXPECT_TRUE(hal.Failed()); EXPECT_EQ(hal.Failure(), "a read at 0x13510000, outside the bench window 0x13500000..0x13510000"); hal.Write(BENCH_WINDOW_BASE - 4, 1); hal.Write(BENCH_WINDOW_BASE + 2, 1); EXPECT_EQ(memory.outside, 0); EXPECT_EQ(hal.Failure(), "a read at 0x13510000, outside the bench window 0x13500000..0x13510000"); EXPECT_FALSE(InBenchWindow(BENCH_WINDOW_BASE + 2)); EXPECT_FALSE(InBenchWindow(0x135E0000)); EXPECT_FALSE(InBenchWindow(u64{BENCH_WINDOW_BASE} + 0x100000000)); } TEST(StarletProbeBroadway, TheTimebaseIsAQuarterOfTheStarletsCycles) { FakeMemory memory; BroadwayHal hal(memory, CLIENT_TIMEBASE_BASE); hal.Elapse(0); EXPECT_EQ(hal.Timebase(), CLIENT_TIMEBASE_BASE); hal.Elapse(SLICE_CYCLES); EXPECT_EQ(hal.Timebase(), CLIENT_TIMEBASE_BASE + 1024); // 2 ms of Broadway's timebase (StarOS's TICKS_PER_MS, 60750) is 486,000 Starlet cycles. hal.Elapse(486000); EXPECT_EQ(hal.Timebase() - CLIENT_TIMEBASE_BASE - 1024, u64{121500}); } TEST(StarletProbeBroadway, TheCaptureIsGeckosFormats) { FakeMemory memory; BroadwayHal hal(memory, 0); hal.Put("SBL:X "); hal.Hex(0x1A); hal.Put(" "); hal.Dec(0); hal.Put(" "); hal.Dec(0xFFFFFFFF); hal.Put(" "); hal.Hex(0xDEADBEEF); EXPECT_EQ(hal.Capture(), "SBL:X 0x0000001a 0 4294967295 0xdeadbeef"); } TEST(StarletProbeBroadway, TheClientsBudgetIsBoundedAndOnlyItsOwn) { EXPECT_EQ(MAX_CLIENT_CYCLES, u64{1} << 34); EXPECT_EQ(ParseCycles("17179869184", MAX_CLIENT_CYCLES), MAX_CLIENT_CYCLES); EXPECT_FALSE(ParseCycles("17179873280", MAX_CLIENT_CYCLES)); EXPECT_FALSE(ParseCycles("17179869185", MAX_CLIENT_CYCLES)); // Without the client, 2^30 as before. EXPECT_EQ(ParseCycles("1073741824"), MAX_CYCLES); EXPECT_FALSE(ParseCycles("1073745920")); EXPECT_FALSE(ParseCycles("17179869184")); } TEST(StarletProbeBroadway, OnlyTheOptionItselfRaisesTheBudget) { const auto requested = [](std::vector argv) { return ClientRequested(static_cast(argv.size()), argv.data()); }; EXPECT_TRUE(requested({"probe", "image.elf", "--broadway-h1a"})); EXPECT_TRUE(requested({"probe", "image.elf", "--jit", "--cycles", "4096", "--broadway-h1a"})); EXPECT_TRUE(requested({"probe", "image.elf", "--stage-spk", "p.bin", "--broadway-h1a"})); EXPECT_FALSE(requested({"probe", "image.elf"})); // The image's own name, or another option's value, is no option. EXPECT_FALSE(requested({"probe", "--broadway-h1a"})); EXPECT_FALSE(requested({"probe", "image.elf", "--ipc-script", "--broadway-h1a"})); EXPECT_FALSE(requested({"probe", "image.elf", "--stage-spk", "--broadway-h1a", "--jit"})); EXPECT_FALSE(requested({"probe", "image.elf", "--cycles", "--broadway-h1a"})); } TEST(StarletProbeBroadway, TheStarletIsDoneAsTheLoadersMonitorSaysIt) { // StarOS boot/contract/staros_boot.h: RUNNING 2, IDLE 3, FATAL 4, PANIC 5, FAULT 6; EXI PPC 1. EXPECT_EQ(SBR1_STATE_IDLE, u32{3}); EXPECT_TRUE(StarletDone(false, 3, SBR1_EXI_PPC)); EXPECT_TRUE(StarletDone(false, 6, SBR1_EXI_PPC)); EXPECT_FALSE(StarletDone(false, 2, SBR1_EXI_PPC)); EXPECT_FALSE(StarletDone(false, 3, SBR1_EXI_STARLET)); EXPECT_FALSE(StarletDone(false, 0, 0)); // A terminal trace line counts on its own. EXPECT_TRUE(StarletDone(true, 0, 0)); } TEST(StarletProbeBroadway, TheBuiltInSourcesAreTheNineFilesWithTheirHashes) { const std::string_view sources = BroadwayClient::Sources(); const char* names[] = {"hw0c.c", "hw0c.h", "hw0d.c", "hw0d.h", "h1a.c", "h1a.h", "staros_hw0c.h", "staros_hw0d.h", "staros_h1a.h"}; size_t at = 0; for (const char* name : names) { const size_t end = sources.find('\n', at); ASSERT_NE(end, std::string_view::npos); const std::string_view line = sources.substr(at, end - at); EXPECT_TRUE(line.starts_with(std::string(name) + " ")) << line; EXPECT_EQ(line.size(), std::string_view(name).size() + 1 + 64) << line; at = end + 1; } EXPECT_EQ(at, sources.size()); } // A scripted Starlet over the fake window, as StarOS's tests/hardware-wii/h1a benches script it: // READY 100 ms after the first step (unless `ready` is false), then each round answered at least // 40,960 timebase ticks after it was first seen posted, S2B's words first for a Starlet -> Broadway // round; after the last round or Broadway's END, its record (GEN 2) and its END (unless `ends` is // false). struct Starlet { bool ready = true; bool ends = true; bool answered_ready = false; bool ended = false; u32 answered = 0; bool seen = false; u64 seen_at = 0; static u32 Scenario(u32 round) { return round <= STAROS_H1A_S1_ROUNDS ? STAROS_H1A_S1 : STAROS_H1A_S3; } void Tick(FakeMemory& memory, u64 now, u64 start) { const u32 broadway = STAROS_HW0C_BROADWAY, starlet = STAROS_HW0C_STARLET; if (!answered_ready) { if (ready && now >= start + 100 * TICKS_PER_MS) { answered_ready = true; memory.At(STAROS_HW0C_ENVIRONMENT + STAROS_HW0C_E_CONTROL) = 0x000520FB; memory.At(STAROS_HW0C_ENVIRONMENT + STAROS_HW0C_E_DACR) = 1; memory.At(STAROS_HW0C_ENVIRONMENT + STAROS_HW0C_E_DESCRIPTOR) = 0x13500552; memory.At(STAROS_HW0C_ENVIRONMENT + STAROS_HW0C_E_SENTINEL_BAD) = 0; memory.At(starlet + STAROS_HW0C_C_STATE) = STAROS_HW0C_STATE_READY; } return; } if (ended) return; if (answered == STAROS_H1A_ROUNDS || memory.At(broadway + STAROS_HW0C_C_STATE) == STAROS_HW0C_STATE_END) { if (!ends) return; for (u32 i = 0; i < STAROS_H1A_RESULTS_WORDS; ++i) memory.At(STAROS_H1A_RESULTS + 4 * i) = 0x1000 + i; memory.At(STAROS_H1A_RESULTS + 4 * STAROS_H1A_F_MAGIC) = STAROS_H1A_MAGIC; memory.At(STAROS_H1A_RESULTS + 4 * STAROS_H1A_F_VERSION) = STAROS_H1A_VERSION; memory.At(STAROS_H1A_RESULTS + 4 * STAROS_H1A_F_GEN) = STAROS_H1A_GEN; memory.At(starlet + STAROS_HW0C_C_STATE) = STAROS_HW0C_STATE_END; ended = true; return; } const u32 round = answered + 1; if (memory.At(broadway + STAROS_HW0C_C_STATE) != STAROS_HW0C_STATE_ROUND || memory.At(broadway + STAROS_HW0C_C_ROUND) != round || memory.At(broadway + STAROS_HW0C_C_SCENARIO) != Scenario(round)) { return; } if (!seen) { seen = true; seen_at = now; } if (now - seen_at < 40960) return; const bool s2b = round > STAROS_H1A_S1_ROUNDS && (round - STAROS_H1A_S1_ROUNDS) % 2 == 0; if (s2b) { for (u32 w = 0; w < STAROS_H1A_AREA_WORDS; ++w) { const u32 offset = STAROS_H1A_S2B + 4 * w; memory.At(offset) = STAROS_HW0C_PATTERN(STAROS_HW0C_TAG_S2B, round, offset / 4); } } memory.At(starlet + STAROS_HW0C_C_SCENARIO) = Scenario(round); memory.At(starlet + STAROS_HW0C_C_STATE) = STAROS_HW0C_STATE_ROUND; memory.At(starlet + STAROS_HW0C_C_ROUND) = round; answered = round; seen = false; } }; // One whole run of the real loader code in this process (its state is global: each death test's // child runs it alone). Its report goes to stderr; the child exits 0 only if the run ended (or not) // as `done` says, reached nothing outside the window, held every line of `required` in its report, // and a second client in the same process was refused. (Windows' gtest has only simple regular // expressions, so the child checks the report itself.) [[noreturn]] void RunClient(Starlet starlet, u64 slices, bool done, std::vector required) { FakeMemory memory; BroadwayClient client; if (!client.Start(memory)) std::exit(2); const u64 start = client.Timebase(); for (u64 slice = 0; slice < slices && !client.Done() && !client.Failed(); ++slice) { starlet.Tick(memory, client.Timebase(), start); client.Advance(slice == 0 ? 0 : SLICE_CYCLES); } const std::string report = client.Report(client.Done() ? CLIENT_STATUS_DONE : CLIENT_STATUS_TIMEOUT); std::fputs(report.c_str(), stderr); int missing = 0; for (const std::string_view line : required) { if (report.find(line) == std::string::npos) { std::fprintf(stderr, "MISSING %.*s\n", static_cast(line.size()), line.data()); ++missing; } } BroadwayClient second; FakeMemory other; const bool refused = !second.Start(other) && second.Failed(); std::fprintf(stderr, "steps %llu outside %d failed %d done %d refused %d missing %d\n", static_cast(client.Steps()), memory.outside, static_cast(client.Failed()), static_cast(client.Done()), static_cast(refused), missing); const bool passed = client.Done() == done && !client.Failed() && memory.outside == 0 && refused && missing == 0; std::exit(passed ? 0 : 1); } // Enough slices for 3000 rounds of at least 2 ms each, and the waits: 2^34 cycles' worth. constexpr u64 SLICES = MAX_CLIENT_CYCLES / SLICE_CYCLES; TEST(StarletProbeBroadwayDeathTest, TheRealLoaderRunsAllRoundsAndReports) { EXPECT_EXIT(RunClient(Starlet{}, SLICES, true, {"SBL:STARLET DONE\n", "SBL:HW0C S0 PASS\n", "SBL:H1A B POSTS 3000 LAST 3000 ACKS 3000 TIMEOUT 0 ", "SBL:H1A B CHECK SENTINEL 0 SCRUB 0 POOL 0\n", "SBL:H1A S GEN 0x00000002 0x00000002 ATTEMPTS 1 COHERENT\n", "SBL:H1A END CRC 0x", "\nSBL:END GECKO DROPPED AFTER JUMP 0 BEFORE 0\n"}), ::testing::ExitedWithCode(0), ""); } TEST(StarletProbeBroadwayDeathTest, WithoutReadyTheBenchTimesOutAndNeverStartsH1A) { Starlet starlet; starlet.ready = false; EXPECT_EXIT(RunClient(starlet, SLICES, true, {"SBL:HW0C S0 INCONCLUSIVE\n", "SBL:HW0C TIMEOUT S0 ROUND 0\n", "SBL:H1A B POSTS 0 LAST 0 ACKS 0 ", "ATTEMPTS 3 INCOMPLETE\n"}), ::testing::ExitedWithCode(0), ""); } TEST(StarletProbeBroadwayDeathTest, WithoutTheStarletsEndBroadwayStopsAtReadyPlus65s) { Starlet starlet; starlet.ends = false; EXPECT_EXIT(RunClient(starlet, SLICES, true, {"SBL:HW0C S0 INCONCLUSIVE\n", "SBL:HW0C TIMEOUT S0 ROUND 0\n", "SBL:H1A B POSTS 3000 LAST 3000 ", "ATTEMPTS 3 INCOMPLETE\n"}), ::testing::ExitedWithCode(0), ""); } TEST(StarletProbeBroadwayDeathTest, ABudgetTooShortEndsUnderTimeout) { EXPECT_EXIT(RunClient(Starlet{}, 1000, false, {"SBL:TIMEOUT\n", "SBL:END GECKO DROPPED AFTER JUMP 0 BEFORE 0\n"}), ::testing::ExitedWithCode(0), ""); } } // namespace } // namespace StarletBootstrapProbe