Files
Joe MattielloandClaude Opus 5.5 0085c4485d feat(app): emulation thread phase 2 — achievements, netplay and busy calls (#967) (#1031)
* docs(app): emulation thread phase 2 plan (#967)

Achievements and netplay back on with the emulation thread (core-event stream, rc_client_do_frame on the
emulation thread, netplay as a UI-thread object fed by the stream), the cheat batch as a regular command, the
250 ms busy spinner, and the Phase 1 open items (MinGW waits, limits after a failed start, the vsync-off nap).

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* feat(app): EmuSnapshot in its own file, with the core-event stream (#967)

CoreEventQueue carries log lines, vj_events and option writes from the emulation thread to the UI thread in order;
log lines are bounded, events and option writes never dropped.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* feat(app): core log lines, events and owner option writes reach the UI thread through the stream (#967)

CoreHost::onUiEvent gets every log line and vj_event on the UI thread (pinSnapshot), with an epoch that rides the
command queue; option writes made on the emulation thread inside a call() are applied to the UI's mirror and the
config there too, never on the emulation thread.  A content switch drops queued lines and events.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* feat(app): netplay works with the emulation thread on (#967)

Netplay stays a UI-thread object: the core's link lines and events reach it through CoreHost::onUiEvent, filtered
by an epoch bumped at every start/stop/bounce, and the endpoint bounce completes after the core has read "disabled"
for 2 emulated frames (3 threaded), counted with frameCount().  Threaded twins of netplay_pair (both sides, voice
modem, a mixed threaded/inline pair) and of netplay_no_partner, which must exit 1, never 3.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* refactor(app): the cheat list reaches the core as one regular command (#967)

CheatManager::apply() builds one CheatBatch (reset, then every set) and hands it to commit, which the app posts to
the emulation thread; the three sinks and the UI-thread batch collector are gone.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* refactor(app): RetroAchievements safe on two threads (#967)

rc_client events are copied into AchEvents and applied in pump(); core RAM, the memory map and the cheat resets
are reached on the emulation thread (call/post); read_memory off it answers address checks from the map without
touching RAM; the rich-presence line is computed with the frame and published; the mock server is locked.  Inline
runs behave as before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(app): high-resolution short waits for the emulation thread on Windows (#967)

The MinGW runner paced threaded fast-forward at one frame per ~15.6 ms (63.4 vs 96.6 frames/s); develop's newest
completed MinGW runs (x64 and ARM64) pass emu_thread_unit, but ctest hides a passing test's output so the timer note
is not visible there, and no MinGW run on develop has yet carried the 1 ms precise-wait check.  preciseSleepNs uses a
high-resolution waitable timer, and every app job now prints emu_thread_unit's timer report.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* test(app): the precise-sleep check uses the shortest wake (#967)

A starved host still has some on-time wakes, while timers that are coarse by platform (every wake ~15.6 ms on MinGW)
have none, so the check asserts on the shortest of 20 preciseSleepNs(1 ms) waits; the note prints mean and min.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* feat(app): RetroAchievements works with the emulation thread on (#967)

rc_client_do_frame (and with it rc_client_idle) runs after each frame on the emulation thread; the UI pump posts the
idle while paused.  A threaded headless RA run steps one frame at a time in the inline order, so every ra_* test has
a _threaded twin that lands its steps on the same frames.  Only the debugger tools and the GDB stub stay off.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(app): precise_sleep.h includes a lean windows.h (#967)

emu_commands.h reaches ~24 MinGW translation units through app.h; WIN32_LEAN_AND_MEAN keeps legacy winsock and macro
collisions out of them (the waitable-timer and Sleep calls need nothing outside it).  A WaitForSingleObject that
does not return WAIT_OBJECT_0 now falls back to Sleep(1), so a failed wait cannot make waitUntil spin.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(app): quiesce the emulation thread before RetroAchievements shuts down (#967)

App::shutdown destroyed the rc_client while a free-running emulation thread could still be inside rc_client_do_frame
or a posted emuIdle: it now unloads and stops the thread first, then ach.shutdown() (still before video.shutdown(),
images.stop() needs the GL context).  AchHttp::ensureWorker runs inside request()'s locked block, and shutdown()
moves the worker out under the lock and joins outside it, so request() is safe from the UI and the emulation thread
(new two-thread case in ach_http_test).  onContentLoaded and onReset resync framesSeen_ so frames that ran before the
load are not counted as the new game's.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* feat(app): a call() that takes over 250 ms shows a busy spinner (#967)

EmuThread::call() runs a wait hook on the waiting thread from 250 ms on; the app's hook pumps OS events and swaps a
spinner drawn with scissored clears -- no ImGui frame, no call(), no read of a field only a call() writes (CoreHost
counts those while the UI waits).  Headless, it logs once per call.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(app): a failed emulation-thread start lifts the feature limits; vsync off naps to the next refresh (#967)

Phase 1 final-review minors 2 and 3.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* docs(app): emulation thread phase 2 -- achievements, netplay, the busy spinner (#967)

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(app): drop the previous game's achievement trackers and indicators after a content switch (#967)

Events carry the generation they were queued in; onContentLoaded and onContentClosed bump it, and drainEvents drops
the stale tracker, challenge, progress, attempt started/failed and reset events (no HIDE would ever come for them).
A real unlock, completions, scoreboards and the connection state are kept.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(app): drive rc_client's mutating calls from the emulation thread only (#967)

App::reset runs ach.onReset() inside the emulation-thread call (rc_client_reset raises events); applyHardcore,
setEnabled(false), logout and the pause check (rc_client_can_pause advances the pause-decay state) go through coreCall,
so none of them runs while rc_client_do_frame does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(app): the vsync-off present loop runs on a display-period deadline grid and handles input while it naps (#967)

The nap was anchored at the swap end, so the period was refresh + build + swap (~54-57 Hz on a 60 Hz display) and no
input was published during it. LateLatch::advanceGridNs / gridNapNs keep a deadline grid; App::presentNap waits for
events as latchWait does and hands them to the emulation thread at once. (SDL 3.4 has no SDL_WaitEventTimeoutNS.)

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(app): the emulation-thread setting hint names only what is still switched off (#967)

RetroAchievements and netplay work with the thread on; the Tools (debugger) menu and the GDB stub do not.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* test(app): pin that a reload drops the previous game's queued core log lines (#967)

The block had no onUiEvent, so it only proved option survival. It now queues a log line inside the same call as the
reload, and asserts it is not delivered while the option write still is.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(app): final-review minors -- ci report step, inline pin, display mode, RAII guards, stale docs (#967)

ci: the timer-report step is informational (continue-on-error). CoreHost::pinSnapshot applies queued option events
when the UI thread is the owner (thread stopped / inline). The display period is re-read on
SDL_EVENT_DISPLAY_CURRENT_MODE_CHANGED. busyFrame's UI-waiting flag and call()'s wait-hook flag are RAII. netplay.h and
cheats.h comments, docs/agent/testing.md (TSan recipe) and the ach_event_unit label are brought up to date. The
off-thread read_memory answer notes the rcheevos v12.5 assumption.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(app): serialize achievement replies and synchronize scope capture (#967)

Run HTTP and mock rc_client replies on the core owner; guard scope visibility and its sample ring. Add fail-first ownership/concurrency regressions to the sanitizer suite and prevent Windows GL-loader macros from breaking the busy spinner.

* fix(app): synchronize achievement UI client access (#967)

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-19 20:15:25 -04:00

207 lines
7.3 KiB
C++

// Cheat scanner end to end: the REAL core (through CoreHost) running a public ROM headlessly.
//
// A "game" counter is found the way a person would: take a snapshot, let a few frames run, keep what changed,
// keep what went up by one, again. The survivor is then kept as a cheat through the same CheatManager the
// Cheats window uses, wired to the core's retro_cheat_* entry points, and the core must hold the value:
// * with no cheat the candidate keeps counting (so the check can fail),
// * with the cheat it stays at the frozen value over many frames,
// * switching the cheat off lets it count again,
// * with hardcore blocking cheats the same "Add as cheat" is refused and the counter keeps counting.
// Needs no phase-B core support: the RAM comes from retro_get_memory_data, the cheat from retro_cheat_set.
//
// usage: vj_debug_scan_e2e <rom.j64>
#include "cheat_scan.h"
#include "cheats.h"
#include "core_host.h"
#include "debug_mock.h"
#include "debug_session.h"
#include "scanner_actions.h"
#include "libretro.h"
#include <cstdio>
#include <cstdlib>
#include <ctime>
#include <filesystem>
#include <set>
#include <string>
#include <vector>
namespace fs = std::filesystem;
using namespace vj;
static int g_fail = 0, g_checks = 0;
#define CHECK(cond) \
do { \
g_checks++; \
if (!(cond)) { \
g_fail++; \
std::fprintf(stderr, "FAIL %s:%d %s\n", __FILE__, __LINE__, #cond); \
} \
} while (0)
static bool ramOf(CoreHost& host, const uint8_t*& p, size_t& n) {
uint8_t* d = nullptr;
if (!host.memoryRegion(RETRO_MEMORY_SYSTEM_RAM, d, n) || !d) return false;
p = d;
return true;
}
int main(int argc, char** argv) {
if (argc < 2) {
std::fprintf(stderr, "usage: vj_debug_scan_e2e <rom.j64>\n");
return 2;
}
const std::string rom = argv[1];
std::error_code ec;
fs::path tmp = fs::temp_directory_path(ec);
if (ec) tmp = ".";
tmp /= "vj-scan-e2e-" + std::to_string((long long)std::time(nullptr)) + "-" + std::to_string((long long)clock());
fs::create_directories(tmp / "system", ec);
fs::create_directories(tmp / "saves", ec);
fs::create_directories(tmp / "cheats", ec);
const std::string t = tmp.string() + "/";
CoreHost& host = CoreHost::instance();
host.setDirectories(t + "system/", t + "saves/");
std::string err;
CHECK(host.loadContent(rom, err));
for (int i = 0; i < 30; i++) host.runFrame(); // past the boot
const uint8_t* ram = nullptr;
size_t size = 0;
CHECK(ramOf(host, ram, size));
CHECK(size >= 0x200000);
if (!ram || !size) return 1;
// ---- find a counter: changed, then increased, then increased by exactly one ------------------------
CheatScan sc;
ScanOptions o; // 8-bit, aligned
ScanSpec unk;
CHECK(sc.begin(ram, size, o, unk, &err));
CHECK(sc.count() == size);
std::vector<size_t> trace;
trace.push_back(sc.count());
ScanSpec ch;
ch.mode = ScanMode::Changed;
for (int i = 0; i < 3; i++) {
host.runFrame();
CHECK(sc.next(ram, size, ch, &err));
trace.push_back(sc.count());
}
CHECK(sc.count() > 0 && sc.count() < size);
ScanSpec inc;
inc.mode = ScanMode::Increased;
for (int i = 0; i < 2; i++) {
host.runFrame();
CHECK(sc.next(ram, size, inc, &err));
trace.push_back(sc.count());
}
ScanSpec by1;
by1.mode = ScanMode::IncreasedBy;
by1.value = 1;
for (int i = 0; i < 3 && sc.count() > 1; i++) {
host.runFrame();
CHECK(sc.next(ram, size, by1, &err));
trace.push_back(sc.count());
}
std::printf("scan narrowing:");
for (size_t c : trace) std::printf(" %zu", c);
std::printf("\n");
CHECK(sc.count() >= 1);
std::vector<uint32_t> found;
sc.results(0, 1000, found);
CHECK(!found.empty());
if (found.empty()) return 1;
const uint32_t addr = found[0];
std::printf("counter candidate: 0x%06X\n", (unsigned)addr);
// ---- control: with no cheat the counter counts --------------------------------------------------------
auto at = [&] { return (unsigned)ram[addr]; };
std::set<unsigned> seen;
unsigned lastV = at();
unsigned moves = 0;
for (int i = 0; i < 12; i++) {
host.runFrame();
seen.insert(at());
if (at() != lastV) moves++;
lastV = at();
}
CHECK(moves >= 8); // it moves nearly every frame
CHECK(seen.size() >= 6);
// ---- keep it as a cheat, exactly as the window's "Add as cheat" does ----------------------------------------
// The Cheats window's CheatManager, wired to the core's own entry points. The debug tools' session is gated
// by the mock interface (the real one arrives with the core's debug interface); nothing else of it is used.
MockDebugIface mock;
DebugSession session;
session.setIface(mock.iface());
session.setContentLoaded(true);
CheatManager cm;
bool hardcore = false;
cm.commit = [](CheatBatch b) {
for (const CheatOp& o : b) {
if (o.reset) retro_cheat_reset();
else retro_cheat_set(o.index, o.enabled, o.code.c_str());
}
};
cm.blocked = [&] { return hardcore; };
cm.setStateDir(t + "cheats/");
GameIdentity id;
id.key = "crc-5ca00001";
id.title = "scan e2e";
cm.gameLoaded(id);
// Hardcore first: the same click is refused, nothing reaches the core, the counter keeps counting.
hardcore = true;
CHECK(!scanAddCheat(cm, session, addr, 42, 1, "Counter", err));
CHECK(cm.userCheats().empty());
{
unsigned v0 = at();
unsigned diff = 0;
for (int i = 0; i < 6; i++) {
host.runFrame();
if (at() != v0) diff++;
}
CHECK(diff >= 5);
}
hardcore = false;
CHECK(scanAddCheat(cm, session, addr, 42, 1, "Counter", err));
CHECK(cm.userCheats().size() == 1);
CHECK(cm.userCheats()[0].code == scanCheatCode(addr, 42, 1));
for (int i = 0; i < 10; i++) {
host.runFrame();
CHECK(at() == 42); // the core writes it after every frame, whatever the game did
}
// Hardcore starting while the cheat is on: the app re-applies the list (empty), the counter is free again.
hardcore = true;
cm.apply();
{
unsigned diff = 0;
for (int i = 0; i < 6; i++) {
host.runFrame();
if (at() != 42) diff++;
}
CHECK(diff >= 4);
}
hardcore = false;
cm.apply(); // hardcore over: the saved cheat goes back in
host.runFrame();
CHECK(at() == 42);
// Switched off in the Cheats window: it counts again.
cm.setUserEnabled(0, false);
host.runFrame();
host.runFrame();
host.runFrame();
CHECK(at() != 42);
cm.gameClosed();
host.unloadContent();
fs::remove_all(tmp, ec); // our own temp dir only
std::printf("scan e2e: %d checks, %d failed\n", g_checks, g_fail);
return g_fail ? 1 : 0;
}