diff --git a/docs/TCP_COMMANDS.md b/docs/TCP_COMMANDS.md index a51f37bb..30216a88 100644 --- a/docs/TCP_COMMANDS.md +++ b/docs/TCP_COMMANDS.md @@ -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":""}` 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":""}` 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` | ✓ | | | diff --git a/runtime/include/mod_resident.h b/runtime/include/mod_resident.h index 770b36fd..1a861d9e 100644 --- a/runtime/include/mod_resident.h +++ b/runtime/include/mod_resident.h @@ -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 @@ -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 "."; 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 diff --git a/runtime/src/debug_server.c b/runtime/src/debug_server.c index b5c2ffcc..51d7b3cb 100644 --- a/runtime/src/debug_server.c +++ b/runtime/src/debug_server.c @@ -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 }, diff --git a/runtime/src/mod_resident.cpp b/runtime/src/mod_resident.cpp index e0292481..08a69f03 100644 --- a/runtime/src/mod_resident.cpp +++ b/runtime/src/mod_resident.cpp @@ -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 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(g_event_total, kEventCapacity); + std::vector 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; +} diff --git a/runtime/tests/test_mod_resident.cpp b/runtime/tests/test_mod_resident.cpp index b7a5d3cf..e4a6fc62 100644 --- a/runtime/tests/test_mod_resident.cpp +++ b/runtime/tests/test_mod_resident.cpp @@ -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 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 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 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 guarded(ram.begin() + 0x1000, ram.begin() + 0x1040);