Merge pull request #529 from RetroPortingToolKit/feat/resident-service-ring

Resident kit: memory-only pack fallback and an always-on service ring
This commit is contained in:
Matt Stanley
2026-10-05 23:32:38 -07:00
committed by GitHub
5 changed files with 205 additions and 10 deletions
+3 -2
View File
@@ -434,9 +434,9 @@ The TCP server is the canonical instrumentation surface. Rule 3 in `CLAUDE.md` i
## Complete command index (generated)
**348 commands registered** — 335 on the native server (`runtime/src/debug_server.c`), 61 on the Beetle server (`runtime/src/beetle_debug_server.c`).
**349 commands registered** — 336 on the native server (`runtime/src/debug_server.c`), 61 on the Beetle server (`runtime/src/beetle_debug_server.c`).
68 of 348 have prose above; **280 are index-only**. An index-only command still works — it just has no description here yet. Send it `{"cmd":"<name>"}` and read the reply, or find its `handle_*` function in the server source.
68 of 349 have prose above; **281 are index-only**. An index-only command still works — it just has no description here yet. Send it `{"cmd":"<name>"}` and read the reply, or find its `handle_*` function in the server source.
Regenerate with `python tools/gen_tcp_commands.py`; `--check` fails if this block has drifted from the code.
@@ -675,6 +675,7 @@ Regenerate with `python tools/gen_tcp_commands.py`; `--check` fails if this bloc
| `render_pass_dump` | ✓ | | ✓ |
| `render_pass_refuse` | ✓ | | ✓ |
| `render_pass_stats` | ✓ | | ✓ |
| `resident_events` | ✓ | | |
| `resident_status` | ✓ | | |
| `restore_trace` | ✓ | | |
| `restore_trace_clear` | ✓ | | |
+22 -1
View File
@@ -18,7 +18,12 @@
*
* Call psx_resident_prepare() from an activation callback (it reads the disc
* through psx_mod_read_disc_file). Accessors are emulation-thread only.
* TCP: {"cmd":"resident_status"} lists every prepared pack.
* A cache that cannot be written (no cache directory, read-only or full disk)
* does not fail preparation: the pack is kept in memory for this launch, so
* the outcome depends only on the effective disc and the plan (two netplay
* peers with the same plan always take the same loader path).
* TCP: {"cmd":"resident_status"} lists every prepared pack;
* {"cmd":"resident_events"} returns the always-on service ring.
*/
#include <stdint.h>
@@ -109,6 +114,22 @@ int psx_resident_guest_ranges_match(const PSXResidentRange* ranges,
/* Debug server: JSON array describing every prepared pack. */
int psx_resident_status_json(char* out, uint32_t capacity);
/* Always-on service ring (observability, not logging). Adapters record every
* loader request they complete from memory (served = 1) or hand to the
* original loader (served = 0), so a load window is measured afterwards from
* the ring instead of by arming a trace. `op` must be a string literal of the
* form "<adapter>.<request>"; file is the pack index or UINT32_MAX. Each
* entry carries the guest frame (as the CD-ROM rings) and the guest cycle.
* Emulation-thread only; the oldest entries are evicted. */
void psx_resident_record(const char* op, uint32_t file, uint32_t lba,
uint32_t bytes, int served);
/* Debug server: entries with frame_lo <= frame <= frame_hi (inclusive; pass
* 0 and UINT64_MAX for all), at most `max` of the newest, oldest first:
* {"total":n,"capacity":c,"entries":[{seq,frame,cycle,op,file,lba,bytes,
* served}]}. Returns 0 when `capacity` is too small. */
int psx_resident_events_json(char* out, uint32_t capacity, uint64_t frame_lo,
uint64_t frame_hi, uint32_t max);
#ifdef __cplusplus
}
#endif
+37
View File
@@ -8068,6 +8068,42 @@ static void handle_resident_status(int id, const char *json)
send_err(id, "resident status too large");
}
/* Always-on resident-loading service ring (psx_resident_record):
* {"cmd":"resident_events","frame_lo":a,"frame_hi":b,"count":n}
* -> total, capacity, entries [{seq, frame, cycle, op, file, lba, bytes,
* served}] oldest first: the newest `count` (default 4096) whose guest
* frame lies in [frame_lo, frame_hi] (defaults: everything held). */
static void handle_resident_events(int id, const char *json)
{
extern int psx_resident_events_json(char *out, uint32_t capacity,
uint64_t frame_lo, uint64_t frame_hi,
uint32_t max);
const int lo = json_get_int(json, "frame_lo", 0);
const int hi = json_get_int(json, "frame_hi", -1);
const int count = json_get_int(json, "count", 4096);
if (count <= 0) { send_err(id, "count must be positive"); return; }
for (uint32_t cap = 65536u; cap <= (1u << 24); cap *= 4u) {
char *events = (char *)malloc(cap);
if (!events) break;
if (psx_resident_events_json(events, cap, lo < 0 ? 0u : (uint64_t)lo,
hi < 0 ? UINT64_MAX : (uint64_t)hi,
(uint32_t)count)) {
char *buf = (char *)malloc(cap + 64u);
if (buf) {
/* events is an object; splice id/ok into it. */
snprintf(buf, cap + 64u, "{\"id\":%d,\"ok\":true,%s", id, events + 1);
debug_server_send_line(buf);
free(buf);
}
free(events);
if (!buf) send_err(id, "alloc failed");
return;
}
free(events);
}
send_err(id, "resident events too large");
}
static void handle_ws_hud_mode(int id, const char *json)
{
int v = json_get_int(json, "tag_rects", -1);
@@ -15010,6 +15046,7 @@ static const CmdEntry s_commands[] = {
{ "mod_counters", handle_mod_counters },
{ "resident_status", handle_resident_status },
{ "netplay_status", handle_netplay_status },
{ "resident_events", handle_resident_events },
{ "capture_mark", handle_capture_mark },
{ "present_image_ring_stats", handle_present_image_ring_stats },
{ "present_image_ring_get", handle_present_image_ring_get },
+90 -7
View File
@@ -405,29 +405,53 @@ extern "C" const PSXResidentPack* psx_resident_prepare(const PSXResidentSpec* sp
const Digest key = cache_key(spec, fingerprint);
/* Without a committed plan there is no disc identity to key on: build
* in memory and never reuse or publish. */
const bool cacheable = !fingerprint.empty();
bool cacheable = !fingerprint.empty();
std::string memory_only; /* why a planned cache is not used */
std::filesystem::path path;
std::unique_ptr<PSXResidentPack> pack;
if (cacheable) {
path = cache_dir(spec->title) / (std::string(spec->format) + "-" + hex(key.data(), 16) + ".pack");
rec.path = path.string();
try {
path = cache_dir(spec->title) / (std::string(spec->format) + "-" + hex(key.data(), 16) + ".pack");
rec.path = path.string();
} catch (const std::exception& e) {
cacheable = false;
memory_only = e.what();
}
}
if (cacheable) {
std::error_code ec;
pack = load(path, key, spec->file_count);
if (pack && !extents_match(*pack, spec)) pack.reset();
if (!pack && std::filesystem::exists(path)) psx_mod_counter_add("resident.cache_rejected", 1);
if (!pack && std::filesystem::exists(path, ec)) psx_mod_counter_add("resident.cache_rejected", 1);
}
if (pack) {
rec.state = "verified";
std::error_code ec;
std::filesystem::last_write_time(path, std::filesystem::file_time_type::clock::now(), ec);
prune(path, spec->format, spec->keep_packs ? spec->keep_packs : 3);
psx_mod_counter_add("resident.verified", 1);
} else {
pack = build(spec, rec.error);
if (!pack) throw std::runtime_error(rec.error);
if (cacheable) publish(path, *pack, key);
rec.state = cacheable ? "prepared" : "prepared (uncached: no mod plan)";
/* The cache only shortens the next launch. Whether a pack exists
* depends on the effective disc and the plan alone, never on the
* host file system, so netplay peers with one plan take one path. */
if (cacheable) {
try {
publish(path, *pack, key);
prune(path, spec->format, spec->keep_packs ? spec->keep_packs : 3);
} catch (const std::exception& e) {
memory_only = e.what();
}
}
if (!memory_only.empty()) {
rec.state = "prepared (memory only: " + memory_only + ")";
psx_mod_counter_add("resident.cache_write_failed", 1);
} else {
rec.state = cacheable ? "prepared" : "prepared (uncached: no mod plan)";
}
psx_mod_counter_add("resident.prepared", 1);
}
if (cacheable) prune(path, spec->format, spec->keep_packs ? spec->keep_packs : 3);
rec.pack = std::move(pack);
} catch (const std::exception& e) {
rec.pack.reset();
@@ -538,3 +562,62 @@ extern "C" int psx_resident_status_json(char* out, uint32_t capacity) {
std::memcpy(out, s.c_str(), s.size() + 1);
return 1;
}
/* Always-on service ring (mod_resident.h). Frames come from the debug
* server's guest frame counter, the clock of the CD-ROM rings, so a load
* window can be compared with cdrom_bursts / cdrom_sector_history. */
extern "C" uint64_t s_frame_count;
extern "C" uint64_t psx_get_cycle_count(void);
namespace {
struct Event {
uint64_t seq, frame, cycle;
const char* op;
uint32_t file, lba, bytes, served;
};
constexpr uint32_t kEventCapacity = 8192;
Event g_events[kEventCapacity];
uint64_t g_event_total = 0;
} // namespace
extern "C" void psx_resident_record(const char* op, uint32_t file, uint32_t lba,
uint32_t bytes, int served) {
Event& e = g_events[g_event_total % kEventCapacity];
e.seq = g_event_total++;
e.frame = s_frame_count;
e.cycle = psx_get_cycle_count();
e.op = op && *op ? op : "?";
e.file = file;
e.lba = lba;
e.bytes = bytes;
e.served = served ? 1u : 0u;
}
extern "C" int psx_resident_events_json(char* out, uint32_t capacity, uint64_t frame_lo,
uint64_t frame_hi, uint32_t max) {
const uint64_t held = std::min<uint64_t>(g_event_total, kEventCapacity);
std::vector<const Event*> picked;
for (uint64_t i = 0; i < held && picked.size() < max; i++) {
const Event& e = g_events[(g_event_total - 1 - i) % kEventCapacity];
if (e.frame >= frame_lo && e.frame <= frame_hi) picked.push_back(&e);
}
std::string s;
char b[96];
std::snprintf(b, sizeof b, "{\"total\":%llu,\"capacity\":%u,\"entries\":[",
(unsigned long long)g_event_total, kEventCapacity);
s += b;
for (size_t i = picked.size(); i-- > 0;) {
const Event& e = *picked[i];
s += "{\"seq\":" + std::to_string(e.seq) + ",\"frame\":" + std::to_string(e.frame) +
",\"cycle\":" + std::to_string(e.cycle) + ",\"op\":";
json_string(s, e.op);
std::snprintf(b, sizeof b, ",\"file\":%d,\"lba\":%u,\"bytes\":%u,\"served\":%u}%s",
e.file == UINT32_MAX ? -1 : int(e.file), e.lba, e.bytes, e.served,
i ? "," : "");
s += b;
}
s += "]}";
if (!out || capacity <= s.size()) return 0;
std::memcpy(out, s.c_str(), s.size() + 1);
return 1;
}
+53
View File
@@ -56,6 +56,9 @@ extern "C" void gpu_ws_set_adaptive_backdrop_preload(int) {}
extern "C" int gpu_ws_configured_x_reveal(void) { return 0; }
extern "C" void gpu_ws_tag_hud_prim(uint32_t, int) {}
extern "C" void gpu_ws_tag_screen_mask_quad(uint32_t) {}
extern "C" { uint64_t s_frame_count = 0; }
static uint64_t cycles;
extern "C" uint64_t psx_get_cycle_count(void) { return cycles; }
extern "C" void gpu_ws_tag_radial_screen_mask_quad(uint32_t, float) {}
/* mod_runtime.cpp's lobby netplay commit reads the negotiated match caps.
* No lobby match is negotiated in this test. */
@@ -101,6 +104,11 @@ static int derive(PSXResidentSink* sink, uint32_t file, const uint8_t* data, uin
return 1;
}
static std::string events(uint64_t lo, uint64_t hi, uint32_t max) {
std::vector<char> b(1 << 20);
return psx_resident_events_json(b.data(), (uint32_t)b.size(), lo, hi, max) ? std::string(b.data()) : "";
}
static std::string status() {
std::vector<char> b(8192);
return psx_resident_status_json(b.data(), (uint32_t)b.size()) ? std::string(b.data()) : "";
@@ -261,6 +269,51 @@ int main() {
"grown file read through its patched directory record");
check(status().find("\"modified\":2") != std::string::npos, "status counts modified files");
/* An unwritable cache keeps the pack in memory: the loader path depends
* on the disc and plan only. A regular file where the title folder
* belongs makes every cache write fail. */
const fs::path blocked = root / "blocked";
write_bytes(blocked / "ResidentTest", {1});
#ifdef _WIN32
_putenv_s("PSX_RESIDENT_CACHE", blocked.string().c_str());
#else
setenv("PSX_RESIDENT_CACHE", blocked.string().c_str(), 1);
#endif
pack = psx_resident_prepare(&spec);
check(pack && psx_resident_file(pack, 1, nullptr, nullptr), "unwritable cache still prepares");
check(status().find("prepared (memory only:") != std::string::npos, "memory-only state reported");
check(fs::is_regular_file(blocked / "ResidentTest"), "no cache written over the blocker");
#ifdef _WIN32
_putenv_s("PSX_RESIDENT_CACHE", (root / "cache").string().c_str());
#else
setenv("PSX_RESIDENT_CACHE", (root / "cache").string().c_str(), 1);
#endif
/* Service ring: frame window, newest-first selection, oldest-first
* output, eviction past capacity. */
check(events(0, UINT64_MAX, 10).find("\"total\":0") != std::string::npos, "empty ring");
for (uint32_t i = 0; i < 20; i++) {
s_frame_count = 100 + i;
cycles = 1000 * i;
psx_resident_record(i & 1 ? "rt.read" : "rt.begin", i & 1 ? UINT32_MAX : i, 22 + i, 2048 * i, i % 3 != 0);
}
const std::string window = events(105, 107, 100);
check(window.find("\"seq\":5,\"frame\":105,\"cycle\":5000,\"op\":\"rt.read\",\"file\":-1,\"lba\":27,\"bytes\":10240,\"served\":1}") != std::string::npos &&
window.find("\"seq\":7") != std::string::npos && window.find("\"seq\":4,") == std::string::npos &&
window.find("\"seq\":8,") == std::string::npos, "frame window inclusive");
check(window.find("\"seq\":5") < window.find("\"seq\":6"), "oldest first");
const std::string newest = events(0, UINT64_MAX, 2);
check(newest.find("\"seq\":18") != std::string::npos && newest.find("\"seq\":19") != std::string::npos &&
newest.find("\"seq\":17") == std::string::npos, "count keeps the newest");
check(events(106, 106, 10).find("\"served\":0") != std::string::npos, "declined requests kept");
std::vector<char> tiny(16);
check(!psx_resident_events_json(tiny.data(), 16, 0, UINT64_MAX, 10), "small buffer refused");
for (uint32_t i = 0; i < 9000; i++) psx_resident_record("rt.flood", 0, i, 0, 1);
const std::string all = events(0, UINT64_MAX, 100000);
check(all.find("\"total\":9020,\"capacity\":8192") != std::string::npos &&
all.find("\"seq\":828,") != std::string::npos && all.find("\"seq\":827,") == std::string::npos,
"oldest entries evicted");
/* Guest-range guard. */
for (uint32_t i = 0; i < 64; i++) ram[0x1000 + i] = (uint8_t)(i ^ 0x5a);
std::vector<uint8_t> guarded(ram.begin() + 0x1000, ram.begin() + 0x1040);