Fix sceKernelGetTscFrequency disagreeing with the counter on non-Windows hosts (#213)

sceKernelReadTsc only returns the CPU's RDTSC when the host RDTSC reader is
available (currently 64-bit Windows); on Linux and macOS it falls back to the
QPC-based Stopwatch. ResolveKernelTscFrequency, however, still consulted the
CPUID-reported hardware TSC frequency in that case, so sceKernelGetTscFrequency
reported a multi-GHz rate while ReadTsc was ticking at the Stopwatch frequency.
A guest computing elapsed = readTscDelta / frequency then gets the wrong time
on those platforms.

Gate the calibrated/CPUID frequencies on RDTSC actually being available (the
calibration path was already self-gated; the CPUID path was not) and otherwise
report the Stopwatch frequency, keeping ReadTsc and GetTscFrequency consistent.

The selection logic is extracted into a pure, host-independent helper so both
branches can be unit tested, including a regression test asserting that a host
without RDTSC reports the Stopwatch frequency rather than the hardware TSC.
This commit is contained in:
can
2026-07-16 00:11:46 +03:00
committed by GitHub
parent 5c8ace82c0
commit 5f97d2edc4
2 changed files with 170 additions and 22 deletions

View File

@@ -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)

View File

@@ -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);
}
}