diff --git a/src/SharpEmu.Libs/Kernel/KernelRuntimeCompatExports.cs b/src/SharpEmu.Libs/Kernel/KernelRuntimeCompatExports.cs index 69910c7..a59a639 100644 --- a/src/SharpEmu.Libs/Kernel/KernelRuntimeCompatExports.cs +++ b/src/SharpEmu.Libs/Kernel/KernelRuntimeCompatExports.cs @@ -1717,41 +1717,59 @@ public static class KernelRuntimeCompatExports } private static ulong ResolveKernelTscFrequency() + { + var (frequencyHz, source) = SelectKernelTscFrequency( + _rdtscReader is not null, + Environment.GetEnvironmentVariable("SHARPEMU_TSC_FREQ_HZ"), + TryCalibrateHostTscFrequency, + TryResolveCpuidTscFrequency, + Stopwatch.Frequency); + TraceKernelTscFrequency(source, frequencyHz); + return frequencyHz; + } + + internal delegate bool TryGetFrequency(out ulong frequencyHz); + + // sceKernelReadTsc only returns the CPU's RDTSC when the host RDTSC reader is available + // (currently 64-bit Windows); otherwise it falls back to the QPC-based Stopwatch. The + // calibrated and CPUID frequencies both describe RDTSC, so reporting either while ReadTsc is + // actually returning Stopwatch ticks makes sceKernelGetTscFrequency disagree with the counter, + // and a guest computing elapsed = readTscDelta / frequency gets the wrong time on Linux and + // macOS. Gate those on rdtscAvailable; otherwise report Stopwatch's own frequency so the pair + // stays consistent. + internal static (ulong FrequencyHz, string Source) SelectKernelTscFrequency( + bool rdtscAvailable, + string? overrideHzText, + TryGetFrequency tryCalibrate, + TryGetFrequency tryResolveCpuid, + long stopwatchFrequency) { const ulong minSane = 1_000_000UL; - var overrideHzText = Environment.GetEnvironmentVariable("SHARPEMU_TSC_FREQ_HZ"); if (!string.IsNullOrWhiteSpace(overrideHzText) && ulong.TryParse(overrideHzText, out var overrideHz) && overrideHz >= minSane) { - TraceKernelTscFrequency("env", overrideHz); - return overrideHz; + return (overrideHz, "env"); } - if (TryCalibrateHostTscFrequency(out ulong calibratedHz) && calibratedHz >= minSane) + if (rdtscAvailable) { - TraceKernelTscFrequency("calibrated-rdtsc", calibratedHz); - return calibratedHz; + if (tryCalibrate(out ulong calibratedHz) && calibratedHz >= minSane) + { + return (calibratedHz, "calibrated-rdtsc"); + } + + if (tryResolveCpuid(out ulong cpuidHz) && cpuidHz >= minSane) + { + return (cpuidHz, "cpuid"); + } } - if (TryResolveCpuidTscFrequency(out ulong cpuidHz) && cpuidHz >= minSane) - { - TraceKernelTscFrequency("cpuid", cpuidHz); - return cpuidHz; - } - - var hostQpc = Stopwatch.Frequency > 0 - ? unchecked((ulong)Stopwatch.Frequency) + var hostQpc = stopwatchFrequency > 0 + ? unchecked((ulong)stopwatchFrequency) : DefaultKernelTscFrequency; - if (hostQpc >= minSane) - { - TraceKernelTscFrequency("qpc", hostQpc); - return hostQpc; - } - - TraceKernelTscFrequency("default", DefaultKernelTscFrequency); - return DefaultKernelTscFrequency; + return hostQpc >= minSane ? (hostQpc, "qpc") : (DefaultKernelTscFrequency, "default"); } private static void TraceKernelTscFrequency(string source, ulong frequencyHz) diff --git a/tests/SharpEmu.Libs.Tests/Kernel/KernelRuntimeCompatExportsTests.cs b/tests/SharpEmu.Libs.Tests/Kernel/KernelRuntimeCompatExportsTests.cs new file mode 100644 index 0000000..7fb803b --- /dev/null +++ b/tests/SharpEmu.Libs.Tests/Kernel/KernelRuntimeCompatExportsTests.cs @@ -0,0 +1,130 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using SharpEmu.Libs.Kernel; +using Xunit; + +namespace SharpEmu.Libs.Tests.Kernel; + +// sceKernelGetTscFrequency must describe the same clock that sceKernelReadTsc returns. ReadTsc +// only returns the CPU's RDTSC when the host RDTSC reader is available (64-bit Windows) and +// otherwise falls back to the QPC-based Stopwatch, so the frequency selection has to follow suit. +public sealed class KernelRuntimeCompatExportsTests +{ + private static KernelRuntimeCompatExports.TryGetFrequency Yields(ulong hz) => + (out ulong frequencyHz) => + { + frequencyHz = hz; + return true; + }; + + private static readonly KernelRuntimeCompatExports.TryGetFrequency Fails = + (out ulong frequencyHz) => + { + frequencyHz = 0; + return false; + }; + + [Fact] + public void WithoutHostRdtsc_ReportsStopwatchFrequency_NotHardwareTsc() + { + // Regression: on Linux/macOS ReadTsc returns the Stopwatch counter, so the reported + // frequency must be the Stopwatch's, never the CPU's much larger hardware TSC frequency. + var (frequencyHz, source) = KernelRuntimeCompatExports.SelectKernelTscFrequency( + rdtscAvailable: false, + overrideHzText: null, + tryCalibrate: Yields(2_400_000_000UL), + tryResolveCpuid: Yields(3_000_000_000UL), + stopwatchFrequency: 10_000_000); + + Assert.Equal(10_000_000UL, frequencyHz); + Assert.Equal("qpc", source); + } + + [Fact] + public void WithHostRdtsc_PrefersCalibratedFrequency() + { + var (frequencyHz, source) = KernelRuntimeCompatExports.SelectKernelTscFrequency( + rdtscAvailable: true, + overrideHzText: null, + tryCalibrate: Yields(2_400_000_000UL), + tryResolveCpuid: Yields(3_000_000_000UL), + stopwatchFrequency: 10_000_000); + + Assert.Equal(2_400_000_000UL, frequencyHz); + Assert.Equal("calibrated-rdtsc", source); + } + + [Fact] + public void WithHostRdtsc_FallsBackToCpuid_WhenCalibrationFails() + { + var (frequencyHz, source) = KernelRuntimeCompatExports.SelectKernelTscFrequency( + rdtscAvailable: true, + overrideHzText: null, + tryCalibrate: Fails, + tryResolveCpuid: Yields(3_000_000_000UL), + stopwatchFrequency: 10_000_000); + + Assert.Equal(3_000_000_000UL, frequencyHz); + Assert.Equal("cpuid", source); + } + + [Fact] + public void WithHostRdtsc_UsesStopwatch_WhenRdtscFrequencyUnknown() + { + var (frequencyHz, source) = KernelRuntimeCompatExports.SelectKernelTscFrequency( + rdtscAvailable: true, + overrideHzText: null, + tryCalibrate: Fails, + tryResolveCpuid: Fails, + stopwatchFrequency: 10_000_000); + + Assert.Equal(10_000_000UL, frequencyHz); + Assert.Equal("qpc", source); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void EnvOverride_Wins_WhenSane(bool rdtscAvailable) + { + var (frequencyHz, source) = KernelRuntimeCompatExports.SelectKernelTscFrequency( + rdtscAvailable, + overrideHzText: "1500000000", + tryCalibrate: Yields(2_400_000_000UL), + tryResolveCpuid: Yields(3_000_000_000UL), + stopwatchFrequency: 10_000_000); + + Assert.Equal(1_500_000_000UL, frequencyHz); + Assert.Equal("env", source); + } + + [Fact] + public void EnvOverride_BelowMinimum_IsIgnored() + { + // 500 kHz is below the sanity floor, so it is dropped; with rdtsc unavailable the + // hardware-TSC path is gated off and the Stopwatch frequency is used. + var (frequencyHz, _) = KernelRuntimeCompatExports.SelectKernelTscFrequency( + rdtscAvailable: false, + overrideHzText: "500000", + tryCalibrate: Fails, + tryResolveCpuid: Yields(3_000_000_000UL), + stopwatchFrequency: 10_000_000); + + Assert.Equal(10_000_000UL, frequencyHz); + } + + [Fact] + public void NonPositiveStopwatchFrequency_FallsBackToDefault() + { + var (frequencyHz, source) = KernelRuntimeCompatExports.SelectKernelTscFrequency( + rdtscAvailable: false, + overrideHzText: null, + tryCalibrate: Fails, + tryResolveCpuid: Fails, + stopwatchFrequency: 0); + + Assert.Equal(10_000_000UL, frequencyHz); // DefaultKernelTscFrequency + Assert.Equal("qpc", source); + } +}