Files
dolphin/Tools/StarletBootstrapProbe/StarletBootstrapProbe.cpp
T
Yaya48andClaude Opus 5.5 9cce9b35ef StarletBootstrapProbe: --broadway-h1a ends as the loader's monitor does
The first whole run of StarOS's H-1A image (interpreter, 2^34 cycles)
ran every round: Broadway posted 3000, the Starlet answered 3000, its
record COHERENT, END NONE, S0 PASS. But the probe never ended it: the
image's trace, 1 KiB, is full before its REV:BOOT:IDLE line, and the
probe looked for that line only. StarOS's main.c does not: its monitor
takes the Starlet for done once the SBR1 record's state is IDLE or after,
with EXI given back to Broadway (StarletDone), and its bench ended.

In this mode the probe now does the same: it reads the record, which
--broadway-h1a therefore requires (--sbr1-record), prints the trace as it
stands (and says on stderr when it is full: its last lines are in the
SBR1 ring the probe prints), then the client's report under SBL:STARLET
DONE. The run passes when the trace shows the boot's start, no panic or
fatal line, and the record's state is IDLE (or the trace's IDLE line).
Without --broadway-h1a nothing changes.

StarletProbeBroadwayTest: StarletDone against StarOS's states.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 18:18:05 +01:00

590 lines
23 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 "ProbeBroadway.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;
};
// What the Broadway client reaches: PPC memory, by whole words (ProbeBroadway.h bounds it to the
// bench window).
class ProbeMemory final : public BroadwayMemory
{
public:
explicit ProbeMemory(Memory::MemoryManager& memory) : m_memory(memory) {}
u32 LoadWord(u32 address) override { return m_memory.Read_U32(address); }
void StoreWord(u32 address, u32 word) override { m_memory.Write_U32(word, address); }
private:
Memory::MemoryManager& m_memory;
};
bool Run(const std::string& path, bool jit, bool mmu_faults, SBR1Mode sbr1,
ExpectedOutcome expected, u64 cycles, const std::vector<u8>* package, ScriptRunner* script,
BroadwayClient* client)
{
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';
}
// --broadway-h1a: StarOS's loader sets the bench window up before the jump; so does the client,
// before the Starlet's first instruction.
ProbeMemory client_memory(system.GetMemory());
if (client)
{
std::cerr << "--broadway-h1a: StarOS loader sources built in:\n" << BroadwayClient::Sources();
if (!client->Start(client_memory) || client->Failed())
{
std::cerr << "--broadway-h1a: " << client->Failure() << '\n';
return false;
}
}
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';
};
// The client's capture on stdout, after the trace, and its counts on stderr.
const auto report_client = [&](std::string_view status) {
std::cout << client->Report(status);
std::cerr << "--broadway-h1a: " << client->Steps() << " steps, timebase 0x" << std::hex
<< client->Timebase() << std::dec << ", done " << client->Done() << '\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;
}
}
if (client)
{
// As the loader's monitor: one step of the bench a turn, the first at the jump.
client->Advance(slice == 0 ? 0 : SLICE_CYCLES);
if (client->Failed())
{
std::cerr << "--broadway-h1a: " << client->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 (client)
{
// --broadway-h1a: the loader's monitor reports once the SBR1 record says the Starlet is done
// (StarletDone) and its bench has ended; the trace, possibly full, is printed as it stands.
const u32 length = memory.Read32(info.trace) == TRACE_MAGIC ?
std::min(memory.Read32(info.trace + 4), TRACE_CAPACITY) :
0;
std::string trace(length, '\0');
for (u32 index = 0; index < length; ++index)
trace[index] = static_cast<char>(memory.Read8(info.trace + 8 + index));
const bool trace_terminal = trace.find("REV:BOOT:IDLE\n") != std::string::npos ||
trace.find("REV:PANIC") != std::string::npos ||
trace.find("REV:FATAL") != std::string::npos;
const u32 state = mem1.Read_U32(SBR1_BASE + SBR1_STARLET_STATE);
if (!StarletDone(trace_terminal, state, mem1.Read_U32(SBR1_BASE + SBR1_EXI_OWNER)) ||
!client->Done())
{
continue;
}
std::cout << trace;
std::cout << "PC=0x" << std::hex << core.GetRegister(15) << std::dec
<< " instructions=" << core.GetExecutedInstructions() << '\n';
if (length == TRACE_CAPACITY)
std::cerr << "--broadway-h1a: the trace is full: its last lines are in the SBR1 ring\n";
report_client(CLIENT_STATUS_DONE);
const bool startup_ok = trace.find("REV:BOOT:ENTRY\n") != std::string::npos &&
trace.find("REV:BOOT:BSS_OK\n") != std::string::npos &&
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;
const bool idle =
state == SBR1_STATE_IDLE || trace.find("REV:BOOT:IDLE\n") != std::string::npos;
return startup_ok && idle && trace.find("REV:PANIC") == std::string::npos &&
trace.find("REV:FATAL") == std::string::npos;
}
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';
// As main.c's monitor at its own timeout: the report anyway, under SBL:TIMEOUT.
if (client)
report_client(CLIENT_STATUS_TIMEOUT);
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>|--broadway-h1a]\n";
return 2;
}
bool jit = false;
bool mmu_faults = false;
SBR1Mode sbr1 = SBR1Mode::None;
ExpectedOutcome expected = ExpectedOutcome::Idle;
bool outcome_selected = false;
std::optional<std::string_view> cycles_text;
bool broadway_h1a = false;
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 == "--broadway-h1a")
{
// StarOS's H-1A bench loader as Broadway (ProbeBroadway.h), once.
if (broadway_h1a)
{
std::cerr << "--broadway-h1a is given once\n";
return 2;
}
broadway_h1a = true;
}
else if (option == "--cycles")
{
// Given once; read below, once the mode, which bounds it, is known.
if (cycles_text || index + 1 == argc)
{
std::cerr << "--cycles takes one decimal multiple of " << SLICE_CYCLES << ", once\n";
return 2;
}
cycles_text = argv[++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 client drives Broadway's side alone: no script, no staged package with it; and it reads
// the SBR1 record, as the loader's monitor does, so the record must be there.
if (broadway_h1a && (script_path || package_path))
{
std::cerr << "--broadway-h1a takes neither --ipc-script nor --stage-spk\n";
return 2;
}
if (broadway_h1a && sbr1 != SBR1Mode::Record)
{
std::cerr << "--broadway-h1a takes --sbr1-record: the loader's monitor reads the record\n";
return 2;
}
// A whole number of slices, at most MAX_CYCLES, or MAX_CLIENT_CYCLES with the client
// (ProbeBudget.h).
const u64 max_cycles = broadway_h1a ? MAX_CLIENT_CYCLES : MAX_CYCLES;
std::optional<u64> cycles;
if (cycles_text && !(cycles = ParseCycles(*cycles_text, max_cycles)))
{
std::cerr << "--cycles takes one decimal multiple of " << SLICE_CYCLES << ", at most "
<< max_cycles << ", once\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);
}
BroadwayClient client;
const bool passed = Run(argv[1], jit, mmu_faults, sbr1, expected, cycles.value_or(DEFAULT_CYCLES),
package ? &*package : nullptr, script ? &*script : nullptr,
broadway_h1a ? &client : 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;
}