From b40c427807ab395aa2fe5984c2411b6c1eef3970 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Rydg=C3=A5rd?= Date: Wed, 28 Oct 2026 22:48:43 +0100 Subject: [PATCH 1/3] Built-in relay: Pump every millisecond Every message through the relay waited for the next pump, every 8 ms, or 100 ms while nobody was connected, which is when a game's first connection comes in. That was most of what a round trip took once the emulators stopped running in frame bursts. Co-Authored-By: Claude Opus 5.5 --- Core/HLE/sceNetAdhoc.cpp | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/Core/HLE/sceNetAdhoc.cpp b/Core/HLE/sceNetAdhoc.cpp index de56320abb..e06df83568 100644 --- a/Core/HLE/sceNetAdhoc.cpp +++ b/Core/HLE/sceNetAdhoc.cpp @@ -2133,14 +2133,12 @@ void __NetAdhocInit() { while (adhocServerRunning) { auto start = std::chrono::steady_clock::now(); auto pump_status = relay_server.pump(); - auto wait_time = std::chrono::milliseconds(8); if (pump_status == aemu_postoffice_server::ServerPumpStatus::LISTEN_SOCK_DEAD) { break; } - if (pump_status == aemu_postoffice_server::ServerPumpStatus::IDLE) { - wait_time = std::chrono::milliseconds(100); - } - std::this_thread::sleep_until(start + wait_time); + // Every hop through the relay waits for the next pump, so keep them close together. Idle + // (nobody connected) too, since that's when a game's first connection comes in. + std::this_thread::sleep_until(start + std::chrono::milliseconds(1)); } INFO_LOG(Log::sceNet, "RelayServer: Shutdown complete"); From d40290b30df902910ac39fdf3b382ad99c30b2d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Rydg=C3=A5rd?= Date: Wed, 28 Oct 2026 22:48:43 +0100 Subject: [PATCH 2/3] sceDisplay: Keep lag sync from drifting, and let it pace alone Lag sync ("Force real clock sync") waited for 1 ms after the end of the previous sync, so every sync's oversleep added up: GTA VCS lost 0.4 s per minute against the wall clock. Wait instead for a fixed base plus the emulated time since, which can't drift, and start over from now only when more than 100 ms behind (a pause, or loading). While it runs, the frame throttle's own waits are skipped. They were out of phase with lag sync, which then ran flat out to catch up, so frames were bursts again. The wait itself is the precise one the frame throttle uses, instead of a yield() spin on Windows. Co-Authored-By: Claude Opus 5.5 --- Core/HLE/sceDisplay.cpp | 74 ++++++++++++++++++++++++----------------- 1 file changed, 43 insertions(+), 31 deletions(-) diff --git a/Core/HLE/sceDisplay.cpp b/Core/HLE/sceDisplay.cpp index d9708edfca..03eec4b974 100644 --- a/Core/HLE/sceDisplay.cpp +++ b/Core/HLE/sceDisplay.cpp @@ -100,8 +100,16 @@ static std::vector vblankWakePending; static int afterFlipEvent = -1; static int lagSyncEvent = -1; -static double lastLagSync = 0.0; +// Lag sync keeps emulated time in step with the wall clock: lagSyncEmuBase (emulated us) was reached at +// lagSyncRealBase, and each sync waits for the wall clock to catch up from there. +static double lagSyncRealBase = 0.0; +static u64 lagSyncEmuBase = 0; +static float lagSyncScale = 1.0f; static bool lagSyncScheduled = false; +static const int LAG_SYNC_INTERVAL_US = 1000; +// Further behind than this (a pause, or loading), start over from now instead of catching up. Like the +// frame throttle's limit, it lets the odd slow frame be made up for. +static const double LAG_SYNC_MAX_BEHIND = 0.1; static int numSkippedFrames; static bool hasSetMode; @@ -178,16 +186,18 @@ static bool UseLagSync() { return g_Config.bForceLagSync && !UseAutoFrameSkip(); } -static void ScheduleLagSync(int over = 0) { +static void ResetLagSyncBase() { + lagSyncRealBase = time_now_d(); + lagSyncEmuBase = CoreTiming::GetGlobalTimeUs(); +} + +// Starts or stops lag sync to match the setting. +static void ScheduleLagSync() { + CoreTiming::UnscheduleEvent(lagSyncEvent, 0); lagSyncScheduled = UseLagSync(); if (lagSyncScheduled) { - // Reset over if it became too high, such as after pausing or initial loading. - // There's no real sense in it being more than 1/60th of a second. - if (over > 1000000 / framerate) { - over = 0; - } - CoreTiming::ScheduleEvent(usToCycles(1000 + over), lagSyncEvent, 0); - lastLagSync = time_now_d(); + ResetLagSyncBase(); + CoreTiming::ScheduleEvent(usToCycles(LAG_SYNC_INTERVAL_US), lagSyncEvent, 0); } } @@ -294,9 +304,10 @@ void __DisplayDoState(PointerWrap &p) { Do(p, lagSyncScheduled); CoreTiming::RestoreRegisterEvent(lagSyncEvent, "LagSync", &hleLagSync); if (p.mode == p.MODE_READ) { - lastLagSync = time_now_d(); if (lagSyncScheduled != UseLagSync()) { ScheduleLagSync(); + } else { + ResetLagSyncBase(); } } } else { @@ -500,7 +511,9 @@ static void DoFrameTiming(bool throttle, bool *skipFrame, float scaledTimestep, } } - if (curFrameTime < nextFrameTime && throttle) { + // Lag sync paces emulation already. A wait here too would be out of phase with it, and it would race + // to catch up afterwards. + if (curFrameTime < nextFrameTime && throttle && !lagSyncScheduled) { // If time gap is huge just jump (somebody fast-forwarded) if (nextFrameTime - curFrameTime > 2*scaledTimestep) { nextFrameTime = curFrameTime; @@ -523,7 +536,7 @@ static void DoFrameTiming(bool throttle, bool *skipFrame, float scaledTimestep, static void DoFrameIdleTiming() { PROFILE_THIS_SCOPE("timing"); - if (!FrameTimingThrottled() || !g_Config.bEnableSound || wasPaused) { + if (!FrameTimingThrottled() || !g_Config.bEnableSound || wasPaused || lagSyncScheduled) { return; } @@ -835,27 +848,26 @@ void hleLagSync(u64 userdata, int cyclesLate) { scale = (float)framerate / fpsLimit; } - const double goal = lastLagSync + (scale / 1000.0f); - double before = time_now_d(); - // Don't lag too long ever, if they leave it paused. - double now = before; - while (now < goal && goal < now + 0.01) { - // Tight loop on win32 - intentionally, as timing is otherwise not precise enough. - // TODO: Use the precise waits if available -#ifndef _WIN32 - const double left = goal - now; - if (left > 0.0f && left < 1.0f) { // Sanity check - usleep((long)(left * 1000000.0)); - } -#else - yield(); -#endif - now = time_now_d(); + if (scale != lagSyncScale) { + lagSyncScale = scale; + ResetLagSyncBase(); } - const int emuOver = (int)cyclesToUs(cyclesLate); - const int over = (int)((now - goal) * 1000000); - ScheduleLagSync(over - emuOver); + // Measured from a fixed base rather than from the previous sync, so oversleeping doesn't add up. + const double emulated = (double)(CoreTiming::GetGlobalTimeUs() - lagSyncEmuBase) / 1000000.0; + const double goal = lagSyncRealBase + emulated * scale; + const double before = time_now_d(); + if (before > goal + LAG_SYNC_MAX_BEHIND || goal > before + LAG_SYNC_MAX_BEHIND) { + ResetLagSyncBase(); + } else if (before < goal) { + WaitUntil(before, goal, "lag-sync"); + } + const double now = time_now_d(); + + lagSyncScheduled = UseLagSync(); + if (lagSyncScheduled) { + CoreTiming::ScheduleEvent(usToCycles(LAG_SYNC_INTERVAL_US) - cyclesLate, lagSyncEvent, 0); + } if ((DebugOverlay)g_Config.iDebugOverlay == DebugOverlay::FRAME_GRAPH || g_coreCollectDebugStats) { DisplayNotifySleep(now - before); From b5666343188445fb6584f16a50a11add2fab6ca4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Rydg=C3=A5rd?= Date: Wed, 28 Oct 2026 22:48:43 +0100 Subject: [PATCH 3/3] Add --lag-sync, and let it pace headless --realtime The JIT cores only return from the run loop at the end of a frame, so --realtime paces once per frame, like the app. With --lag-sync, lag sync paces every emulated millisecond instead and --realtime's own sleep stays out of its way. Two instances measuring round trips over the relay (pspautotests net/adhoc/pair/timing) go from 15-31 ms to 3 ms. Co-Authored-By: Claude Opus 5.5 --- Core/CmdLine.cpp | 5 +++++ Core/CmdLine.h | 1 + headless/Headless.cpp | 7 +++++-- 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/Core/CmdLine.cpp b/Core/CmdLine.cpp index 9329dd16ad..9fd41529c0 100644 --- a/Core/CmdLine.cpp +++ b/Core/CmdLine.cpp @@ -257,6 +257,7 @@ static const CommandLineParam g_autoParams[] = { {POFF(instance), CmdParamType::Int, "instance", '\0', "Run as instance N of several talking to each other over ad hoc (own 127.0.0.N address and MAC)", CmdLineMode::Headless}, {POFF(realtime), CmdParamType::Bool, "realtime", '\0', "Run at PSP speed instead of as fast as possible", CmdLineMode::Headless}, {POFF(adhocServer), CmdParamType::Bool, "adhoc-server", '\0', "Run the built-in ad hoc server"}, + {POFF(lagSync), CmdParamType::Bool, "lag-sync", '\0', "Keep emulated time in step with the wall clock every millisecond, not every frame (the Force real clock sync setting)"}, {POFF(frameStats), CmdParamType::Bool, "frame-stats", '\0', "Print flips per emulated second at the end of the run, counted from --state's load", CmdLineMode::Headless}, {POFF(maxScreenshotError), CmdParamType::Double, "max-mse", '\0', "Maximum allowed MSE error for screenshot comparison", CmdLineMode::Headless}, {POFF(mountIso), CmdParamType::String, "mount", 'm', "Mount ISO/CSO on umd1: when booting an ELF/PBP or --vsh"}, @@ -573,6 +574,10 @@ void CommandLineOptions::ApplyToConfig() const { g_Config.bEnableAdhocServer = adhocServer.value(); g_Config.DoNotSaveSetting(&g_Config.bEnableAdhocServer); } + if (lagSync.has_value()) { + g_Config.bForceLagSync = lagSync.value(); + g_Config.DoNotSaveSetting(&g_Config.bForceLagSync); + } if (fuseId.has_value()) { g_Config.sFuseID = fuseId.value(); g_Config.DoNotSaveSetting(&g_Config.sFuseID); diff --git a/Core/CmdLine.h b/Core/CmdLine.h index d7e913624d..d2a6dce1d3 100644 --- a/Core/CmdLine.h +++ b/Core/CmdLine.h @@ -200,6 +200,7 @@ struct CommandLineOptions { std::optional instance; std::optional realtime; std::optional adhocServer; + std::optional lagSync; std::optional screenshotFilename; std::optional screenshotFilenameSave; diff --git a/headless/Headless.cpp b/headless/Headless.cpp index 8c7bfbb7d6..5f4fd0ed4e 100644 --- a/headless/Headless.cpp +++ b/headless/Headless.cpp @@ -662,10 +662,13 @@ static bool RunAutoTest(GraphicsContext *graphicsContext, CoreParameter &corePar int blockTicks = (int)usToCycles(1000000 / (opt.presses.empty() ? 10 : 60)); if (opt.realtime) { // Display flips are the only thing that paces headless otherwise, and a test that doesn't - // draw never flips, so the emulated clock would race ahead of instances it talks to. + // draw never flips, so the emulated clock would race ahead of instances it talks to. The JIT + // cores only return at the end of a frame, so like the app, this paces once per frame. blockTicks = (int)usToCycles(1000); const double ahead = emulatedElapsed - (time_now_d() - startTime); - if (ahead > 0.001) { + // With --lag-sync, lag sync paces every emulated millisecond instead, and two pacers out of + // phase would undo each other. + if (ahead > 0.001 && !g_Config.bForceLagSync) { sleep_ms((int)(std::min(ahead, 0.05) * 1000.0), "realtime"); } }