Two findings of the review of 2df9dfc909 (StarOS H-1A, D2):
- The report reads the bench window too (hw0c_report(): HW-M4-0D's
area, and H-1A's record if it was never read), but a refused access
there did not fail the run, as one during the run does. It does now:
the failure on stderr, and the run fails.
- --cycles' bound of 2^34 was raised whenever "--broadway-h1a" appeared
among the arguments, even as the value of --ipc-script or --stage-spk
(a file of that name), the run then not in the client's mode.
ClientRequested reads the options with their grammar: the word after
--ipc-script, --stage-spk or --cycles is a value, never an option.
StarletProbeBroadwayTest: ClientRequested.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
326 lines
12 KiB
C++
326 lines
12 KiB
C++
// Copyright 2026 Dolphin Emulator Project
|
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
|
|
|
#include <cstdio>
|
|
#include <cstdlib>
|
|
#include <string>
|
|
#include <string_view>
|
|
#include <vector>
|
|
|
|
#include <gtest/gtest.h>
|
|
|
|
#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<u32> words = std::vector<u32>((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<const char*> argv) {
|
|
return ClientRequested(static_cast<int>(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<std::string_view> 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<int>(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<unsigned long long>(client.Steps()), memory.outside,
|
|
static_cast<int>(client.Failed()), static_cast<int>(client.Done()),
|
|
static_cast<int>(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
|