Compare commits

..

6 Commits

Author SHA1 Message Date
ParantezTech 6be44dd750 chore: bump version to 0.0.3 2026-07-28 03:38:27 +03:00
Berk 2b6bd5a532 Sdl backend (#670)
* [audio] added sdl audio backend and in-tree atrac9 decoder

* [input] replaced per-platform pad readers with sdl gamepad input

* [video] added sdl window and host display plumbing

* [gui] added host display options and per-game render settings

* [bink] synced host movie playback to the guest audio clock

* [cpu] hooked windows write faults into guest image tracking

* [perf] added guest and render profiling, reserved host cpu lanes

* [kernel] fixed stale pthread mutex handle alias

* [host] wired the sdl session, save-data paths and project references

* [audio] hoisted ajm trace stackalloc out of its loop

* [video] Add guest image sync setting

* [build] Strip native symbols

* reuse
2026-07-28 03:33:26 +03:00
Daniel Freak b4cc5f88ca [GUI] Upgrade Avalonia to 12.1.0 and enable compiled bindings (#666)
* [GUI] bump to avalonia 12

* [GUI] add compiled bindings for cards/console logs/game metas

* [GUI] enable compiled bindings across launcher XAML
2026-07-27 23:00:51 +03:00
MarcelMediaDev db4339f698 fix(gta): restore wiped GTA foundation and gameplay path (PPSA04264) (#650)
* fix(kernel): implement APR ResolveFilepathsWithPrefixToIdsAndFileSizes

Resource streamers resolve relative paths against a shared prefix; without
this HLE every call returned NOT_FOUND and assets never got real ids/sizes.

* fix(remoteplay): stub Initialize and GetConnectionStatus as disconnected

Titles probe Remote Play during pad/network bring-up; unresolved imports
returned NOT_FOUND. Report initialized + disconnected so callers take the
normal offline path.

* fix(agc): accept Gen5 hull shaders that omit PGM_LO/HI in CreateShader

Type-5 headers can start with RSRC1/RSRC2; rejecting them left null handles
and Main Thread AVs. Scan the SH table and skip PGM patch when absent.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(kernel): reject getdents on file fds and emit . / .. for empty dirs

Returning rax=0 for non-directory or empty listings looked like EOF and
let GTA treat the fd as a pointer (fiWriteAsyncDataWorker AV at 0xB1).

* fix(hle): enable GuestImageWriteTracker CPU sync on Windows

Windows previously hard-disabled the tracker, so CPU-written guest
planes never marked dirty and host textures stayed empty. Arm pages
with VirtualProtect, handle write AVs in VEH, and warm/test on
VirtualAlloc memory so protect cannot poison the CRT heap.

* fix(agc): skip CB metadata draws for EliminateFastClear/Fmask/DCC

CB_COLOR_CONTROL modes 2/5/6 are colour-buffer metadata ops; applying
the bound shader as a normal colour draw corrupts subsequent composites.
Decode MODE from bits [6:4] and return before translate.

* fix(agc): merge Prospero attrib-table formats onto IR vertex inputs

IR-discovered BufferLoadFormat often keeps a stale float sharp format;
patch DataFormat/offset from the AGC attrib table (semantic index),
allow offen fetches, and map quirks 113/121 through NarrowVk for host
vertex input.

* fix(audio): harden AudioOut2 stack out-buffer writes against canary smash

Titles that stack-allocate AudioOut2 outs next to the frame canary were
corrupted by oversized or mistyped HLE writes; keep ContextPush pacing.

* Revert "fix(memory): reserve only large regions (#608)"

This reverts commit 8f9456229a.

* fix(gpu): decode Gen5 R16 and RG32 render-target formats

* fix(audio): AudioOut2 host beds, deeper waveOut queue, AJM MP3

GTA V Enhanced routes intro/menu audio through AudioOut2 and FMOD's AJM
MP3 path. Wire PortCreate/PortSetAttributes/ContextPush to dual host
stereo streams, deepen WinMM queue to 128KiB, and decode AJM codec 0
with a stateful NLayer helper so menu music is not silent.

* fix(agc): map PS interpolants via SPI_PS_INPUT_CNTL semantics

Identity ATTR→param wiring ignored hardware remapping, so UI draws
got wrong (or empty) interpolants. Pack CNTL from matched PS/GS
semantics, thread it into Vulkan/Metal as Location/Flat, and fingerprint
it in the graphics shader cache key.

* fix(agc): rect-list/NGG strips, Index8 expand, and GE_INDX_OFFSET

NGG single-rect UI needs triangle-strip expansion; Prospero Index8 must
expand to host u16; glyphs need base vertex from GE_INDX_OFFSET. Skip
param-less rect-lists instead of inventing colour draws.

* fix(np): report GTA Story Mode addcont entitlements as owned

NpEntitlementAccess was returning an empty add-on list, so GTA V Enhanced offered Buy Story Mode. Publish the installed license labels and stub premium-event registration so offline sessions take the owned path.

* fix(cpu): prefer native workers for all guest entry stubs

Route thread entry, continuation, and main entry through RunGuestEntryStub so guest stubs are not invoked above CLR-managed frames (UnmanagedCallersOnly FailFast). Keep requireNativeWorker for tbb_thead; other paths prefer workers with calli fallback.

* fix(agc): implement Rewind/Jump writers and IT_REWIND waits

GTA Subrender AVs came from AcbJumpGetSize / DcbRewind returning NOT_FOUND as packet sizes. Add IT_REWIND and INDIRECT_BUFFER writers, patch SetRewindState into the GPU wait registry, and nest-parse 4-dword jumps.

* fix(gpu): use AddrLib ExactXor for Gen5 Standard256B (mode 1)

Mode 5 already had Standard4K ExactXor; mode 1 still used the generic StandardSwizzle block table, which mis-detiles Gen5 UI atlases.

* Revert "fix(cpu): prefer native workers for all guest entry stubs"

This reverts commit 31c4db0d38.

* fix(memory): commit-first large maps; reserve only on failure

Replace the #608 always-reserve-only exact-map path with allocate-first and lazy reserve fallback when a huge non-exec commit cannot be satisfied. Prime and widen GetPointer commit so the fallback path is safer for native walkers. Drops the need for a hard #608 revert.

* [Agc] Implement fused shader half exports

* fix(agc): accept optional hull state in CreatePrimState

Port the CreatePrimState hull-optional path from #583 so fused HS pipelines (GTA) are not rejected with INVALID_ARGUMENT. Geometry-derived CX/UC writes are unchanged; hull is traced only.

* fix(videoout): restore thread-safe VulkanHostBufferPool (#564)

The 6db095e wipe dropped CasualcoderDev's lock-ordering-safe pool. Concurrent Return/TryTake without the gate races after the first present and can hang the submit path.

* Revert "fix(agc): implement Rewind/Jump writers and IT_REWIND waits"

This reverts commit bec77bf083.

* test(memory): align lazy-commit expectations with commit-first policy

Fake hosts must reject Allocate so reserve-only paths still run, and GetPointer asserts the 32 MiB prime range including AlignUp spill.

* diag(gpu): log guest-queue backlog breakdown under backpressure

Rate-limit top work types and ordered debugName prefixes when the Vulkan guest work queue stalls, so North Yankton logs show acquire/label vs draw traffic instead of only VulkanOrderedGuestAction.

* perf(agc): coalesce acquire flushes and batch non-DMA label wakes

Flush pending ACQUIRE_MEM invalidation at draw/dispatch/dma/flip boundaries instead of before every packet, and complete release/write-data producers in the same ordered action so load paths enqueue far fewer VulkanOrderedGuestAction items.

* perf(gpu): wait for ordered-action fences and keep draining sync

On Windows/Linux, block briefly for queue-visibility fences instead of deferring the whole logical queue for the tick. Prefer ordered sync/flip heads under backlog pressure, and keep macOS non-blocking defer behavior.

* perf(gpu): raise sync-item ceiling above payload guest-work cap

Apply SHARPEMU_PENDING_GUEST_WORK_ITEMS mainly to compute/draw/image payload work, and allow a higher SHARPEMU_PENDING_GUEST_SYNC_ITEMS ceiling for zero-payload ordered actions and flip markers. Keep the byte budget as the RAM safety valve.

* fix(gta): stub Voice ports and implement sceKernelCheckReachability

Resolve North Yankton-path Voice Create/Delete/Connect/Disconnect/End NIDs and EnumerationThread reachability checks so leftover unresolved imports are not on the critical path.

* diag(gta): arm flip/present/wait probes after North Audio

Rate-limited load_progress TRACE for flip submit, ordered flip enqueue, present taken/not-taken, and GPU wait backlog so North Yankton freezes can be classified without full AGC tracing.

* fix(ampr): restore sequential offset=-1 reads for streamer packs

Re-wire PakDirectoryTracker into sceAmprAprCommandBufferReadFile (dropped in #216) so RAGE sequential pack reads no longer fail while the North Yankton UI keeps flipping. Also rate-limit CheckReachability miss paths for EnumerationThread diagnosis.

* fix(hle/videoout): Windows GuestImage opt-in and keep GTA intro without sync

Default the tracker off on Windows to avoid VirtualProtect thrash, gate AGC
texel-copy skips on Enabled so guest Bink planes keep shipping pixels, and
drain CPU-written images on the present thread when sync is opted in.

* fix(videoout): probe guest content when tracker off so UI can skip copies

Restores upload-known/texture-cache skips for Dead Cells menus, and uses a
sparse guest-memory fingerprint when GuestImageWriteTracker is disabled so
CPU-updated Bink planes still force texel copies for GTA intro.

* fix(audio): keep 128KiB host queue AudioOut2-only

Restore the default 32 KiB (~171 ms) PCM bed for classic AudioOut so
titles like Dreaming Sarah stay in sync; only AudioOut2 opens the deeper
queue needed for bursty FMOD Push on GTA.

---------

Co-authored-by: samto6 <123419830+samto6@users.noreply.github.com>
2026-07-27 01:58:55 +03:00
Berk 0535783f46 Update README with project details and usage instructions 2026-07-26 15:13:17 +03:00
Berk 99004a3ccd [GPU] Host cached guest buffer (#649) 2026-07-26 04:28:32 +03:00
158 changed files with 17110 additions and 5082 deletions
+1 -1
View File
@@ -9,7 +9,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<SharpEmuVersion>0.0.2-beta.5</SharpEmuVersion>
<SharpEmuVersion>0.0.3</SharpEmuVersion>
<Version>$(SharpEmuVersion)</Version>
<RepoRoot>$([MSBuild]::NormalizeDirectory('$(MSBuildThisFileDirectory)'))</RepoRoot>
+8 -8
View File
@@ -7,23 +7,23 @@ SPDX-License-Identifier: GPL-2.0-or-later
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="Avalonia" Version="11.3.18" />
<PackageVersion Include="Avalonia.Desktop" Version="11.3.18" />
<PackageVersion Include="Avalonia.Fonts.Inter" Version="11.3.18" />
<PackageVersion Include="Avalonia.Themes.Fluent" Version="11.3.18" />
<PackageVersion Include="Avalonia" Version="12.1.0" />
<PackageVersion Include="Avalonia.Desktop" Version="12.1.0" />
<PackageVersion Include="Avalonia.Fonts.Inter" Version="12.1.0" />
<PackageVersion Include="Avalonia.Themes.Fluent" Version="12.1.0" />
<PackageVersion Include="FFmpeg.AutoGen" Version="7.1.1" />
<PackageVersion Include="Iced" Version="1.21.0" />
<PackageVersion Include="Microsoft.Build.Framework" Version="17.14.8" />
<PackageVersion Include="Microsoft.CodeAnalysis.Analyzers" Version="3.11.0" />
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="4.12.0" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageVersion Include="Silk.NET.Input" Version="2.23.0" />
<PackageVersion Include="NLayer" Version="1.14.0" />
<PackageVersion Include="ppy.SDL3-CS" Version="2026.629.0" />
<PackageVersion Include="Silk.NET.Vulkan" Version="2.23.0" />
<PackageVersion Include="Silk.NET.Vulkan.Extensions.EXT" Version="2.23.0" />
<PackageVersion Include="Silk.NET.Vulkan.Extensions.KHR" Version="2.23.0" />
<PackageVersion Include="Silk.NET.Windowing" Version="2.23.0" />
<!-- Transitive of Avalonia.Desktop; pinned to fix GHSA-xrw6-gwf8-vvr9 -->
<PackageVersion Include="Tmds.DBus.Protocol" Version="0.21.3" />
<!-- Transitive of Avalonia.Desktop; pinned. Avalonia 12 requires 0.94.1+. -->
<PackageVersion Include="Tmds.DBus.Protocol" Version="0.94.1" />
<PackageVersion Include="xunit" Version="2.9.3" />
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.1" />
</ItemGroup>
-10
View File
@@ -13,16 +13,6 @@ SPDX-License-Identifier: GPL-2.0-or-later
An experimental PlayStation 5 emulator for Windows, Linux and macOS.
</p>
<p align="center">
<a href="https://discord.gg/6GejPEDqpc">
<img src="https://img.shields.io/badge/Discord-Join%20our%20Community-5865F2?style=for-the-badge&logo=discord&logoColor=white" alt="Join our Discord">
</a>
</p>
<p align="center">
<strong>Join our Discord for development updates, compatibility discussions, support, and community chat.</strong>
</p>
---
<p align="center">
+1
View File
@@ -7,6 +7,7 @@ path = [
"global.json",
"**/packages.lock.json",
"scripts/ps5_names.txt",
"src/SharpEmu.LibAtrac9/**",
"src/SharpEmu.GUI/Languages/**",
"src/SharpEmu.ShaderCompiler.Metal/Templates/**",
"tests/SharpEmu.ShaderCompiler.Metal.Tests/Goldens/**",
+1
View File
@@ -5,6 +5,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
<Solution>
<Folder Name="/src/">
<Project Path="src/SharpEmu.LibAtrac9/SharpEmu.LibAtrac9.csproj" />
<Project Path="src/SharpEmu.CLI/SharpEmu.CLI.csproj" />
<Project Path="src/SharpEmu.Core/SharpEmu.Core.csproj" />
<Project Path="src/SharpEmu.DebugClient/SharpEmu.DebugClient.csproj" />
+270 -98
View File
@@ -8,6 +8,7 @@ using SharpEmu.HLE;
using SharpEmu.Libs.VideoOut;
using SharpEmu.Logging;
using System.Runtime.InteropServices;
using System.Runtime.Loader;
using System.Text;
using System.Text.Json;
@@ -45,6 +46,8 @@ internal static partial class Program
[STAThread]
private static int Main(string[] args)
{
ConfigureManagedPluginResolution();
try
{
return Run(args);
@@ -56,6 +59,25 @@ internal static partial class Program
}
}
private static void ConfigureManagedPluginResolution()
{
AssemblyLoadContext.Default.Resolving += static (loadContext, assemblyName) =>
{
if (string.IsNullOrWhiteSpace(assemblyName.Name))
{
return null;
}
var assemblyPath = Path.Combine(
AppContext.BaseDirectory,
"plugins",
assemblyName.Name + ".dll");
return File.Exists(assemblyPath)
? loadContext.LoadFromAssemblyPath(assemblyPath)
: null;
};
}
private static int Run(string[] args)
{
if (Updater.TryApply(args, out var updateExitCode))
@@ -64,7 +86,6 @@ internal static partial class Program
}
args = NormalizeInternalArguments(args, out var isMitigatedChild);
PreloadGlfw();
if (args.Length == 0)
{
@@ -93,14 +114,9 @@ internal static partial class Program
PreloadMacVulkanLoader();
}
// GLFW requires window creation and event processing on the
// process main thread: AppKit demands it on macOS, and X11 has a
// single event queue that must be serviced from the main thread
// (a window created and polled off it may never map, which showed
// as a running game with no visible window on Linux). Emulation
// moves to a worker thread and the main thread services the window
// work the video presenter posts. Windows keeps a per-thread event
// queue, so its window stays on the presenter's own thread.
// SDL/AppKit window work belongs on the process main thread on
// macOS. Linux uses the same model for consistent X11/Wayland
// event ownership. Emulation remains on a worker thread.
var exitCode = 0;
HostMainThread.Enable();
var emulation = new Thread(() =>
@@ -131,10 +147,9 @@ internal static partial class Program
/// starts: the CPU backend executes guest x86-64 code natively, so the
/// host process must be x86-64 — win-x64/linux-x64 on x64 hardware, or
/// osx-x64 under Rosetta 2 on Apple Silicon (Rosetta translates the
/// whole process, so it still reports as X64 here). An arm64 process
/// (e.g. the osx-arm64 build) can browse the GUI but cannot run games;
/// failing up front distinguishes that from MoltenVK, signal-handler,
/// or guest-memory startup problems.
/// whole process, so it still reports as X64 here). Failing up front on
/// any other process architecture distinguishes that from MoltenVK,
/// signal-handler, or guest-memory startup problems.
/// </summary>
private static bool CheckHostArchitecture()
{
@@ -178,11 +193,11 @@ internal static partial class Program
}
/// <summary>
/// Makes a Vulkan loader visible to GLFW's dlopen("libvulkan.1.dylib").
/// Makes a Vulkan loader visible before SDL creates its Vulkan surface.
/// Homebrew's Vulkan libraries are arm64-only and cannot load into this
/// x86-64 (Rosetta 2) process, so a universal libMoltenVK.dylib placed
/// next to the executable (named libvulkan.1.dylib) is preloaded here;
/// dyld then resolves GLFW's bare-name dlopen to the loaded image.
/// dyld can then resolve the loader for SDL and Silk.NET.
/// </summary>
private static void PreloadMacVulkanLoader()
{
@@ -214,27 +229,6 @@ internal static partial class Program
"as libvulkan.1.dylib.");
}
/// <summary>
/// SharpEmu.CLI.csproj publishes glfw into a "plugins" subfolder rather
/// than flat next to the executable, which falls outside the default OS
/// DLL/dlopen search path. Preloading it here by full path first means
/// any later bare-name lookup (however Silk.NET/GLFW itself resolves the
/// library) finds it already loaded in the process and reuses it -- the
/// same technique <see cref="PreloadMacVulkanLoader"/> already relies on
/// for the Vulkan loader.
/// </summary>
private static void PreloadGlfw()
{
var fileName = OperatingSystem.IsWindows() ? "glfw3.dll"
: OperatingSystem.IsMacOS() ? "libglfw.3.dylib"
: "libglfw.so.3";
var candidate = Path.Combine(AppContext.BaseDirectory, "plugins", fileName);
if (File.Exists(candidate))
{
NativeLibrary.TryLoad(candidate, out _);
}
}
private static int RunEmulator(string[] args, bool isMitigatedChild)
{
Console.Error.WriteLine($"[DEBUG] SharpEmu starting with {args.Length} args");
@@ -244,17 +238,13 @@ internal static partial class Program
return childExitCode;
}
if (!TryExtractHostSurfaceArgument(args, out var emulatorArgs, out var hostSurface, out var hostSurfaceError))
{
Console.Error.WriteLine($"[LOADER][ERROR] {hostSurfaceError}");
return 1;
}
HostSessionControl.SetEmbeddedHostSurface(
hostSurface?.WindowHandle ?? 0,
hostSurface?.DisplayHandle ?? 0);
if (!TryParseArguments(emulatorArgs, out var ebootPath, out var runtimeOptions, out var logLevel, out var logFilePath))
if (!TryParseArguments(
args,
out var ebootPath,
out var runtimeOptions,
out var videoOptions,
out var logLevel,
out var logFilePath))
{
PrintUsage();
return 1;
@@ -266,6 +256,11 @@ internal static partial class Program
}
SharpEmuLog.MinimumLevel = logLevel;
if (!HostVideoHost.TryConfigureVideo(videoOptions))
{
Console.Error.WriteLine("[LOADER][ERROR] Video options cannot change while a presenter is active.");
return 3;
}
Log.Info(BuildInfo.Banner);
Log.Info(HostSystemInfo.Summary);
@@ -309,12 +304,6 @@ internal static partial class Program
try
{
if (hostSurface is not null && !VulkanVideoHost.TryAttachSurface(hostSurface))
{
Console.Error.WriteLine("[LOADER][ERROR] The requested GUI host surface is already active.");
return 3;
}
using var runtime = SharpEmuRuntime.CreateDefault(runtimeOptions);
OrbisGen2Result result;
@@ -384,53 +373,9 @@ internal static partial class Program
debugHost.DisposeAsync().AsTask().GetAwaiter().GetResult();
}
HostSessionControl.SetEmbeddedHostSurface(0);
if (hostSurface is not null)
{
VulkanVideoHost.RequestClose();
VulkanVideoHost.DetachSurface(hostSurface);
hostSurface.Dispose();
}
}
}
private static bool TryExtractHostSurfaceArgument(
IReadOnlyList<string> args,
out string[] emulatorArgs,
out VulkanHostSurface? hostSurface,
out string? error)
{
const string hostSurfacePrefix = "--host-surface=";
var remaining = new List<string>(args.Count);
hostSurface = null;
error = null;
foreach (var argument in args)
{
if (!argument.StartsWith(hostSurfacePrefix, StringComparison.OrdinalIgnoreCase))
{
remaining.Add(argument);
continue;
}
if (hostSurface is not null)
{
emulatorArgs = [];
error = "more than one GUI host surface was specified";
return false;
}
var descriptor = argument[hostSurfacePrefix.Length..];
if (!VulkanHostSurface.TryCreateChildProcessSurface(descriptor, out hostSurface, out error))
{
emulatorArgs = [];
return false;
}
}
emulatorArgs = remaining.ToArray();
return true;
}
private static void EnsureCliConsole()
{
if (!OperatingSystem.IsWindows())
@@ -1042,7 +987,7 @@ internal static partial class Program
private static void PrintUsage()
{
Log.Info("Usage: SharpEmu.CLI [--strict] [--trace-imports[=N]] [--cpu-engine=<native>] [--log-level=<level>] [--log-file[=<path>]] [--debug-server[=host:port]] <path-to-eboot.bin>");
Log.Info("Usage: SharpEmu.CLI [--strict] [--trace-imports[=N]] [--cpu-engine=<native>] [--log-level=<level>] [--log-file[=<path>]] [--window-mode=<windowed|borderless|exclusive>] [--resolution=<WIDTHxHEIGHT>] [--display=<N>] [--refresh-rate=<HZ>] [--scaling=<fit|cover|stretch|integer>] [--vsync=<on|off>] [--hdr=<auto|on|off>] [--debug-server[=host:port]] <path-to-eboot.bin>");
Log.Info(@"Example: SharpEmu.CLI --cpu-engine=native --trace-imports=64 --log-level=debug --log-file ""E:\Games\...\eboot.bin""");
Log.Info("Debug server: --debug-server starts a live debug listener (default 127.0.0.1:5714); connect with SharpEmu.DebugClient.");
}
@@ -1087,6 +1032,7 @@ internal static partial class Program
string[] args,
out string ebootPath,
out SharpEmuRuntimeOptions runtimeOptions,
out HostVideoOptions videoOptions,
out LogLevel logLevel,
out string? logFilePath)
{
@@ -1094,6 +1040,7 @@ internal static partial class Program
{
ebootPath = string.Empty;
runtimeOptions = default;
videoOptions = HostVideoOptions.Default;
logLevel = SharpEmuLog.MinimumLevel;
logFilePath = null;
return false;
@@ -1102,12 +1049,99 @@ internal static partial class Program
var strictDynlibResolution = false;
var importTraceLimit = 0;
var cpuEngine = CpuExecutionEngine.NativeOnly;
HostWindowMode? windowModeOverride = null;
HostScalingMode? scalingModeOverride = null;
int? windowWidthOverride = null;
int? windowHeightOverride = null;
int? displayIndexOverride = null;
int? refreshRateOverride = null;
bool? vsyncOverride = null;
HostHdrMode? hdrModeOverride = null;
videoOptions = HostVideoOptions.Default;
logFilePath = null;
logLevel = SharpEmuLog.MinimumLevel;
var pathTokens = new List<string>(args.Length);
for (var i = 0; i < args.Length; i++)
{
var argument = args[i];
if (TrySplitOption(argument, "--window-mode", out var windowModeText))
{
if (!TryParseWindowMode(windowModeText, out var windowMode))
{
ebootPath = string.Empty;
runtimeOptions = default;
return false;
}
windowModeOverride = windowMode;
continue;
}
if (TrySplitOption(argument, "--resolution", out var resolutionText))
{
if (!TryParseResolution(resolutionText, out var windowWidth, out var windowHeight))
{
ebootPath = string.Empty;
runtimeOptions = default;
return false;
}
windowWidthOverride = windowWidth;
windowHeightOverride = windowHeight;
continue;
}
if (TrySplitOption(argument, "--display", out var displayText))
{
if (!int.TryParse(displayText, out var displayIndex) || displayIndex < 0)
{
ebootPath = string.Empty;
runtimeOptions = default;
return false;
}
displayIndexOverride = displayIndex;
continue;
}
if (TrySplitOption(argument, "--refresh-rate", out var refreshText))
{
if (!int.TryParse(refreshText, out var refreshRate) || refreshRate < 0)
{
ebootPath = string.Empty;
runtimeOptions = default;
return false;
}
refreshRateOverride = refreshRate;
continue;
}
if (TrySplitOption(argument, "--scaling", out var scalingText))
{
if (!TryParseScalingMode(scalingText, out var scalingMode))
{
ebootPath = string.Empty;
runtimeOptions = default;
return false;
}
scalingModeOverride = scalingMode;
continue;
}
if (TrySplitOption(argument, "--vsync", out var vsyncText))
{
if (!TryParseSwitch(vsyncText, out var vsync))
{
ebootPath = string.Empty;
runtimeOptions = default;
return false;
}
vsyncOverride = vsync;
continue;
}
if (TrySplitOption(argument, "--hdr", out var hdrText))
{
if (!TryParseHdrMode(hdrText, out var hdrMode))
{
ebootPath = string.Empty;
runtimeOptions = default;
return false;
}
hdrModeOverride = hdrMode;
continue;
}
if (string.Equals(argument, "--strict", StringComparison.OrdinalIgnoreCase))
{
strictDynlibResolution = true;
@@ -1269,9 +1303,147 @@ internal static partial class Program
StrictDynlibResolution = strictDynlibResolution,
ImportTraceLimit = importTraceLimit,
};
var configuredVideoOptions = LoadConfiguredVideoOptions(ebootPath);
videoOptions = (configuredVideoOptions with
{
WindowMode = windowModeOverride ?? configuredVideoOptions.WindowMode,
ScalingMode = scalingModeOverride ?? configuredVideoOptions.ScalingMode,
Width = windowWidthOverride ?? configuredVideoOptions.Width,
Height = windowHeightOverride ?? configuredVideoOptions.Height,
DisplayIndex = displayIndexOverride ?? configuredVideoOptions.DisplayIndex,
RefreshRate = refreshRateOverride ?? configuredVideoOptions.RefreshRate,
VSync = vsyncOverride ?? configuredVideoOptions.VSync,
HdrMode = hdrModeOverride ?? configuredVideoOptions.HdrMode,
}).Normalize();
return true;
}
private static HostVideoOptions LoadConfiguredVideoOptions(string ebootPath)
{
var defaults = HostVideoOptions.Default;
try
{
var effective = EffectiveLaunchSettings.Resolve(
GuiSettings.Load(),
PerGameSettings.Load(TryReadTitleId(ebootPath)));
var windowMode = TryParseWindowMode(effective.WindowMode, out var parsedWindowMode)
? parsedWindowMode
: defaults.WindowMode;
var scalingMode = TryParseScalingMode(effective.ScalingMode, out var parsedScalingMode)
? parsedScalingMode
: defaults.ScalingMode;
var hasResolution = TryParseResolution(
effective.Resolution,
out var configuredWidth,
out var configuredHeight);
var hdrMode = TryParseHdrMode(effective.HdrMode, out var parsedHdrMode)
? parsedHdrMode
: defaults.HdrMode;
return new HostVideoOptions
{
WindowMode = windowMode,
ScalingMode = scalingMode,
Width = hasResolution ? configuredWidth : defaults.Width,
Height = hasResolution ? configuredHeight : defaults.Height,
DisplayIndex = effective.DisplayIndex,
RefreshRate = effective.RefreshRate,
VSync = effective.VSync,
HdrMode = hdrMode,
}.Normalize();
}
catch (Exception exception)
{
Console.Error.WriteLine(
$"[LOADER][WARN] GUI video settings could not be loaded; using defaults: {exception.Message}");
return defaults;
}
}
private static bool TrySplitOption(string argument, string name, out string value)
{
var prefix = name + "=";
if (argument.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
{
value = argument[prefix.Length..];
return true;
}
value = string.Empty;
return false;
}
private static bool TryParseWindowMode(string value, out HostWindowMode mode)
{
mode = value.ToLowerInvariant() switch
{
"windowed" => HostWindowMode.Windowed,
"borderless" => HostWindowMode.Borderless,
"exclusive" or "fullscreen" => HostWindowMode.ExclusiveFullscreen,
_ => (HostWindowMode)(-1),
};
return Enum.IsDefined(mode);
}
private static bool TryParseScalingMode(string value, out HostScalingMode mode)
{
mode = value.ToLowerInvariant() switch
{
"fit" => HostScalingMode.Fit,
"cover" => HostScalingMode.Cover,
"stretch" => HostScalingMode.Stretch,
"integer" => HostScalingMode.Integer,
_ => (HostScalingMode)(-1),
};
return Enum.IsDefined(mode);
}
private static bool TryParseHdrMode(string value, out HostHdrMode mode)
{
mode = value.ToLowerInvariant() switch
{
"auto" => HostHdrMode.Auto,
"on" or "true" or "1" => HostHdrMode.On,
"off" or "false" or "0" => HostHdrMode.Off,
_ => (HostHdrMode)(-1),
};
return Enum.IsDefined(mode);
}
private static bool TryParseResolution(string value, out int width, out int height)
{
var parts = value.Split('x', 'X');
if (parts.Length == 2 && int.TryParse(parts[0], out width) && int.TryParse(parts[1], out height) &&
width >= 640 && height >= 360)
{
return true;
}
width = 0;
height = 0;
return false;
}
private static bool TryParseSwitch(string value, out bool enabled)
{
if (value is "1" || value.Equals("on", StringComparison.OrdinalIgnoreCase) ||
value.Equals("true", StringComparison.OrdinalIgnoreCase))
{
enabled = true;
return true;
}
if (value is "0" || value.Equals("off", StringComparison.OrdinalIgnoreCase) ||
value.Equals("false", StringComparison.OrdinalIgnoreCase))
{
enabled = false;
return true;
}
enabled = false;
return false;
}
private static bool TryParseCpuEngine(string valueText, out CpuExecutionEngine engine)
{
if (string.Equals(valueText, "native", StringComparison.OrdinalIgnoreCase) ||
+29 -17
View File
@@ -77,35 +77,47 @@ SPDX-License-Identifier: GPL-2.0-or-later
<TargetPath>Languages\%(Filename)%(Extension)</TargetPath>
<Visible>False</Visible>
</Content>
<Content Include="..\SharpEmu.LibAtrac9\LICENSE.txt">
<CopyToPublishDirectory>Always</CopyToPublishDirectory>
<TargetPath>licenses\LibAtrac9.txt</TargetPath>
<ExcludeFromSingleFile>true</ExcludeFromSingleFile>
<Visible>False</Visible>
</Content>
</ItemGroup>
<!-- Native libraries (glfw, FFmpeg) publish into a subfolder next to the
<Target Name="KeepLibAtrac9External" BeforeTargets="_ComputeFilesToBundle">
<ItemGroup>
<ResolvedFileToPublish Update="@(ResolvedFileToPublish)"
Condition="'%(Filename)%(Extension)' == 'SharpEmu.LibAtrac9.dll'">
<ExcludeFromSingleFile>true</ExcludeFromSingleFile>
<RelativePath>plugins\SharpEmu.LibAtrac9.dll</RelativePath>
</ResolvedFileToPublish>
</ItemGroup>
</Target>
<!-- These are native debug symbols emitted by Skia/HarfBuzz, not managed
symbols that single-file publish can bundle. They are not needed at
runtime and would otherwise add more than 100 MB to every release. -->
<Target Name="RemoveNativeDebugSymbols" AfterTargets="Publish">
<ItemGroup>
<_NativeDebugSymbols Include="$(PublishDir)**\*.pdb" />
</ItemGroup>
<Delete Files="@(_NativeDebugSymbols)" />
</Target>
<!-- Native FFmpeg libraries publish into a subfolder next to the
executable instead of sitting loose beside it, so the publish
directory stays uncluttered as more native deps get added. The folder
name is a fixed constant, not derived from the RID/architecture: each
publish output only ever holds one architecture's binaries anyway, so
varying the name added a class of bugs (RID resolution timing, host-OS
vs. target-RID mixups) for no benefit. Runtime code (Program.cs's
PreloadGlfw, FfmpegNativeBinkFrameSource's RootPath) uses the same
literal "plugins" folder name. -->
FfmpegNativeBinkFrameSource uses the same literal "plugins" folder
name. -->
<PropertyGroup>
<NativeLibraryFolderName>plugins</NativeLibraryFolderName>
</PropertyGroup>
<!-- Keep glfw as a loose file in the native subfolder; every other native
library is embedded into the single-file bundle. -->
<Target Name="KeepGlfwOutsideSingleFile" AfterTargets="ComputeResolvedFilesToPublishList">
<ItemGroup>
<_GlfwPublishFiles Include="@(ResolvedFileToPublish)"
Condition="$([System.String]::Copy('%(ResolvedFileToPublish.Filename)').StartsWith('glfw')) Or $([System.String]::Copy('%(ResolvedFileToPublish.Filename)').StartsWith('libglfw'))" />
<ResolvedFileToPublish Remove="@(_GlfwPublishFiles)" />
<ResolvedFileToPublish Include="@(_GlfwPublishFiles)">
<ExcludeFromSingleFile>true</ExcludeFromSingleFile>
<RelativePath>$(NativeLibraryFolderName)/%(Filename)%(Extension)</RelativePath>
</ResolvedFileToPublish>
</ItemGroup>
</Target>
<PropertyGroup>
<FfmpegRuntimeTag>2c92585</FfmpegRuntimeTag>
<FfmpegRuntimeDir>
@@ -23,14 +23,71 @@ public sealed partial class DirectExecutionBackend
private static long _perfHleTotal;
private static long _perfHleDispatchTicks;
private sealed class PerfHleExportCost
{
public long Calls;
public long Ticks;
}
private static readonly System.Collections.Concurrent.ConcurrentDictionary<string, PerfHleExportCost> _perfHleCosts = new();
/// <summary>
/// Name of the export currently being dispatched on this thread, so the
/// gateway can attribute its elapsed time once the call returns. Answering
/// "which export is worth optimising" needs cost per export, not just call
/// counts — a rare expensive call and a hot cheap one look identical in a
/// frequency histogram.
/// </summary>
[System.ThreadStatic]
private static string? _perfHleCurrentExport;
private static long _perfHleFirstTimestamp;
private static void RecordPerfHleDispatchTime(long ticks)
{
var total = System.Threading.Interlocked.Add(ref _perfHleDispatchTicks, ticks);
var calls = System.Threading.Interlocked.Read(ref _perfHleTotal);
var name = _perfHleCurrentExport;
if (name is not null)
{
var cost = _perfHleCosts.GetOrAdd(name, static _ => new PerfHleExportCost());
System.Threading.Interlocked.Increment(ref cost.Calls);
System.Threading.Interlocked.Add(ref cost.Ticks, ticks);
}
if (calls > 0 && calls % 500000 == 0)
{
var avgUs = (double)total / System.Diagnostics.Stopwatch.Frequency * 1_000_000.0 / calls;
System.Console.Error.WriteLine($"[PERF][HLE] managed_dispatch_avg={avgUs:F3}us total_managed_s={(double)total / System.Diagnostics.Stopwatch.Frequency:F2}");
var frequency = (double)System.Diagnostics.Stopwatch.Frequency;
var avgUs = (double)total / frequency * 1_000_000.0 / calls;
var first = System.Threading.Interlocked.CompareExchange(ref _perfHleFirstTimestamp, 0, 0);
var wallSeconds = first == 0
? 0
: (double)(System.Diagnostics.Stopwatch.GetTimestamp() - first) / frequency;
System.Console.Error.WriteLine(
$"[PERF][HLE] managed_dispatch_avg={avgUs:F3}us " +
$"total_managed_s={(double)total / frequency:F2} " +
$"wall_s={wallSeconds:F2} " +
$"cores={(wallSeconds > 0 ? total / frequency / wallSeconds : 0):F2}");
var snapshot = new System.Collections.Generic.List<System.Collections.Generic.KeyValuePair<string, PerfHleExportCost>>(_perfHleCosts.Count + 16);
foreach (var kvp in _perfHleCosts)
{
snapshot.Add(kvp);
}
var top = snapshot
.OrderByDescending(kvp => System.Threading.Interlocked.Read(ref kvp.Value.Ticks))
.Take(12)
.Select(kvp =>
{
var seconds = System.Threading.Interlocked.Read(ref kvp.Value.Ticks) / frequency;
var callCount = System.Threading.Interlocked.Read(ref kvp.Value.Calls);
var cores = wallSeconds > 0 ? seconds / wallSeconds : 0;
var perCallUs = callCount > 0 ? seconds * 1_000_000.0 / callCount : 0;
return $"{kvp.Key}: {cores:F2}cores {seconds:F1}s n={callCount} {perCallUs:F2}us/call";
});
System.Console.Error.WriteLine($"[PERF][HLE] cost: {string.Join(" | ", top)}");
}
}
@@ -39,7 +96,16 @@ public sealed partial class DirectExecutionBackend
private static void RecordPerfHleCall(string name)
{
_perfHleCurrentExport = name;
var total = System.Threading.Interlocked.Increment(ref _perfHleTotal);
if (total == 1)
{
System.Threading.Interlocked.CompareExchange(
ref _perfHleFirstTimestamp,
System.Diagnostics.Stopwatch.GetTimestamp(),
0);
}
if (!_perfHleNoDict)
{
_perfHleCounts.AddOrUpdate(name, 1, static (_, v) => v + 1);
@@ -40,6 +40,15 @@ public sealed partial class DirectExecutionBackend
}
_rawExceptionHandler = (nint)AddVectoredExceptionHandler(1u, _rawExceptionHandlerStub);
Console.Error.WriteLine($"[LOADER][INFO] Raw exception handler installed: 0x{_rawExceptionHandler:X16}");
// The raw handler carries the guest-image write-fault bridge, so the
// path must be compiled before the first protected-page store can
// reach it. Guest code has not started yet, so warming here cannot
// race a real fault.
SharpEmu.HLE.GuestImageWriteTracker.WarmUp();
Console.Error.WriteLine(
"[LOADER][INFO] Guest image CPU write tracking: " +
$"{(SharpEmu.HLE.GuestImageWriteTracker.Enabled ? "enabled" : "disabled")}");
}
else
{
@@ -55,6 +64,7 @@ public sealed partial class DirectExecutionBackend
}
_exceptionHandler = (nint)AddVectoredExceptionHandler(1u, _exceptionHandlerStub);
Console.Error.WriteLine($"[LOADER][INFO] Exception handler installed: 0x{_exceptionHandler:X16}");
SharpEmu.HLE.GuestImageWriteTracker.WarmUp();
_unhandledFilterDelegate = UnhandledExceptionFilter;
_unhandledFilterHandle = GCHandle.Alloc(_unhandledFilterDelegate);
@@ -117,6 +127,13 @@ public sealed partial class DirectExecutionBackend
{
return -1;
}
if (exceptionCode == 3221225477u &&
exceptionRecord->NumberParameters >= 2 &&
SharpEmu.HLE.GuestImageWriteTracker.TryHandleWriteFault(
exceptionRecord->ExceptionInformation[1]))
{
return -1;
}
if (TryRecoverAuxiliaryThreadExecuteFault(exceptionRecord, contextRecord, rip))
{
return -1;
@@ -0,0 +1,322 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading;
namespace SharpEmu.Core.Cpu.Native;
/// <summary>
/// Sampling profiler for guest code. Managed profilers only see the emulator's
/// own frames — once a guest thread is running translated code it is opaque to
/// them, so a title that burns its cores inside its own spin loops looks like
/// unattributed native time. This walks the guest thread registry and samples
/// each thread's host RIP, which lands directly on the guest instruction being
/// executed.
/// </summary>
public sealed partial class DirectExecutionBackend
{
private static readonly bool _profileGuestRip =
string.Equals(
Environment.GetEnvironmentVariable("SHARPEMU_PROFILE_GUEST_RIP"),
"1",
StringComparison.Ordinal);
private static readonly int _profileGuestRipIntervalMs =
int.TryParse(
Environment.GetEnvironmentVariable("SHARPEMU_PROFILE_GUEST_RIP_INTERVAL_MS"),
out var interval) && interval > 0
? interval
: 2;
private static readonly int _profileGuestRipReportSeconds =
int.TryParse(
Environment.GetEnvironmentVariable("SHARPEMU_PROFILE_GUEST_RIP_REPORT_S"),
out var report) && report > 0
? report
: 15;
private const ulong GuestImageBase = 0x0000_0008_0000_0000UL;
private const ulong GuestImageLimit = 0x0000_0009_0000_0000UL;
private int _guestRipSamplerStarted;
private readonly ConcurrentDictionary<ulong, long> _guestRipSamples = new();
private readonly ConcurrentDictionary<string, long> _guestRipThreadSamples = new();
private readonly ConcurrentDictionary<string, long> _guestWaitSamples = new();
private readonly ConcurrentDictionary<string, long> _guestThreadWaitSamples = new();
private long _guestRipTotalSamples;
private long _guestWaitTotalSamples;
private long _guestRipCaptureFailures;
private long _guestRipSamplerErrors;
private int _guestRipSampleCursor;
/// <summary>
/// Names the HLE call a thread is parked in, using the guest RIP the import
/// dispatcher left on its context.
/// </summary>
private string ResolveWaitLabel(GuestThreadState thread)
{
var context = thread.Context;
if (context is null)
{
return "<no-context>";
}
var importIndex = context.ActiveImportIndex;
if ((uint)importIndex >= (uint)_importEntries.Length)
{
// Host code with no import in flight: the thread is parked by the
// emulator's own scheduler. The cooperative block records why, which
// is the part that actually identifies what the frame is waiting on.
var blockReason = thread.BlockReason;
return string.IsNullOrEmpty(blockReason)
? "<idle-or-scheduler>"
: $"blocked:{blockReason}";
}
var entry = _importEntries[importIndex];
return entry.Export?.Name ?? entry.Nid;
}
internal void ClearActiveImportIndex()
{
if (!_profileGuestRip)
{
return;
}
var context = ActiveCpuContext;
if (context is not null)
{
context.ActiveImportIndex = -1;
}
}
private void EnsureGuestRipSampler()
{
if (!_profileGuestRip ||
!OperatingSystem.IsWindows() ||
Interlocked.Exchange(ref _guestRipSamplerStarted, 1) != 0)
{
return;
}
var sampler = new Thread(GuestRipSampleLoop)
{
IsBackground = true,
Name = "SharpEmu guest RIP sampler",
// Sampling suspends guest threads briefly. Keep this diagnostic below
// the title workers so it observes them without becoming the bottleneck.
Priority = ThreadPriority.BelowNormal,
};
sampler.Start();
Console.Error.WriteLine(
$"[PERF][GUEST] RIP sampler started: interval={_profileGuestRipIntervalMs}ms " +
$"report={_profileGuestRipReportSeconds}s");
}
private void GuestRipSampleLoop()
{
var clock = Stopwatch.StartNew();
var lastReportMs = 0L;
var lastReportSamples = 0L;
while (true)
{
try
{
var guestThreads = SnapshotGuestThreads();
var sampleIndex = guestThreads.Length == 0
? 0
: (int)((uint)Interlocked.Increment(ref _guestRipSampleCursor) % (uint)guestThreads.Length);
foreach (var thread in guestThreads.Skip(sampleIndex).Take(1))
{
var hostThreadId = Volatile.Read(ref thread.HostThreadId);
if (hostThreadId == 0)
{
continue;
}
if (!TryCaptureHostThreadContext(hostThreadId, out var snapshot) ||
!snapshot.IsValid)
{
Interlocked.Increment(ref _guestRipCaptureFailures);
continue;
}
_guestRipSamples.AddOrUpdate(snapshot.Rip, 1, static (_, value) => value + 1);
_guestRipThreadSamples.AddOrUpdate(
string.IsNullOrEmpty(thread.Name) ? "<unnamed>" : thread.Name,
1,
static (_, value) => value + 1);
Interlocked.Increment(ref _guestRipTotalSamples);
// A host RIP means the thread is inside the emulator rather
// than running translated code. DispatchImport parks the
// guest RIP on the import stub for the call being serviced,
// so the stub address names what the thread is waiting on —
// no hot-path bookkeeping needed to find out.
if (snapshot.Rip >= GuestImageBase && snapshot.Rip < GuestImageLimit)
{
continue;
}
_guestWaitSamples.AddOrUpdate(
ResolveWaitLabel(thread),
1,
static (_, value) => value + 1);
_guestThreadWaitSamples.AddOrUpdate(
string.IsNullOrEmpty(thread.Name) ? "<unnamed>" : thread.Name,
1,
static (_, value) => value + 1);
Interlocked.Increment(ref _guestWaitTotalSamples);
}
Thread.Sleep(_profileGuestRipIntervalMs);
var elapsedMs = clock.ElapsedMilliseconds;
if (elapsedMs - lastReportMs < _profileGuestRipReportSeconds * 1000L)
{
continue;
}
var samples = Interlocked.Read(ref _guestRipTotalSamples);
ReportGuestRipSamples(samples - lastReportSamples, (elapsedMs - lastReportMs) / 1000.0);
lastReportMs = elapsedMs;
lastReportSamples = samples;
}
catch (Exception exception)
{
// A title can tear down a thread or its context during a capture.
// The profiler must never silently die or affect guest execution.
if (Interlocked.Increment(ref _guestRipSamplerErrors) == 1)
{
Console.Error.WriteLine($"[PERF][GUEST] sampler recovery: {exception.GetType().Name}: {exception.Message}");
}
}
}
}
private void ReportGuestRipSamples(long windowSamples, double windowSeconds)
{
var total = Interlocked.Read(ref _guestRipTotalSamples);
if (total == 0)
{
return;
}
var byRip = new List<KeyValuePair<ulong, long>>(_guestRipSamples.Count + 16);
foreach (var pair in _guestRipSamples)
{
byRip.Add(pair);
}
// A tight spin lands on a handful of instructions; grouping by 4 KB page
// as well shows which routine those instructions belong to.
var byPage = new Dictionary<ulong, long>();
foreach (var pair in byRip)
{
var page = pair.Key & ~0xFFFUL;
byPage[page] = byPage.TryGetValue(page, out var existing)
? existing + pair.Value
: pair.Value;
}
var byThread = new List<KeyValuePair<string, long>>(_guestRipThreadSamples.Count + 16);
foreach (var pair in _guestRipThreadSamples)
{
byThread.Add(pair);
}
Console.Error.WriteLine(
$"[PERF][GUEST] samples={total} window={windowSamples} in {windowSeconds:F1}s " +
$"capture_failures={Interlocked.Read(ref _guestRipCaptureFailures)}");
Console.Error.WriteLine(
"[PERF][GUEST] top_rip: " +
string.Join(
" | ",
byRip.OrderByDescending(pair => pair.Value)
.Take(12)
.Select(pair =>
$"0x{pair.Key:X}{DescribeGuestAddress(pair.Key)}={pair.Value * 100.0 / total:F1}%")));
Console.Error.WriteLine(
"[PERF][GUEST] top_page: " +
string.Join(
" | ",
byPage.OrderByDescending(pair => pair.Value)
.Take(8)
.Select(pair =>
$"0x{pair.Key:X}{DescribeGuestAddress(pair.Key)}={pair.Value * 100.0 / total:F1}%")));
var byWait = new List<KeyValuePair<string, long>>(_guestWaitSamples.Count + 16);
foreach (var pair in _guestWaitSamples)
{
byWait.Add(pair);
}
var waitTotal = Interlocked.Read(ref _guestWaitTotalSamples);
Console.Error.WriteLine(
$"[PERF][GUEST] waiting={waitTotal * 100.0 / total:F1}% of guest thread-time; top_wait: " +
string.Join(
" | ",
byWait.OrderByDescending(pair => pair.Value)
.Take(12)
.Select(pair => $"{pair.Key}={pair.Value * 100.0 / total:F1}%")));
// Per-thread spin/park split. The global wait share mixes the job pool in
// with a dozen dormant threads, which hides the number that matters:
// how much of a core each worker actually burns.
Console.Error.WriteLine(
"[PERF][GUEST] thread_split (running/parked): " +
string.Join(
" | ",
byThread.OrderByDescending(pair => pair.Value)
.Take(10)
.Select(pair =>
{
var parked = _guestThreadWaitSamples.TryGetValue(pair.Key, out var wait) ? wait : 0;
var running = pair.Value - parked;
return $"{pair.Key}={running * 100.0 / pair.Value:F0}%/{parked * 100.0 / pair.Value:F0}%";
})));
Console.Error.WriteLine(
"[PERF][GUEST] top_thread: " +
string.Join(
" | ",
byThread.OrderByDescending(pair => pair.Value)
.Take(10)
.Select(pair => $"{pair.Key}={pair.Value * 100.0 / total:F1}%")));
}
/// <summary>
/// Tags a sampled address with the region it belongs to. Guest module code
/// lives above the image base; anything else is emulator or system code that
/// the managed profiler already covers.
/// </summary>
private string DescribeGuestAddress(ulong address)
{
if (address >= GuestImageBase && address < GuestImageLimit)
{
return $"(app+0x{address - GuestImageBase:X})";
}
for (var index = 0; index < _importEntries.Length; index++)
{
if (_importEntries[index].Address == (address & ~0xFUL))
{
return $"(stub:{_importEntries[index].Nid})";
}
}
return "(host)";
}
}
@@ -54,10 +54,13 @@ public sealed partial class DirectExecutionBackend
var startTicks = System.Diagnostics.Stopwatch.GetTimestamp();
var r = directExecutionBackend.DispatchImport(importIndex, argPackPtr);
RecordPerfHleDispatchTime(System.Diagnostics.Stopwatch.GetTimestamp() - startTicks);
directExecutionBackend.ClearActiveImportIndex();
return r;
}
return directExecutionBackend.DispatchImport(importIndex, argPackPtr);
var result = directExecutionBackend.DispatchImport(importIndex, argPackPtr);
directExecutionBackend.ClearActiveImportIndex();
return result;
}
catch (Exception ex)
{
@@ -69,9 +72,45 @@ public sealed partial class DirectExecutionBackend
private unsafe static int RawVectoredHandlerManaged(void* exceptionInfo)
{
if (TryHandleGuestImageWriteFault(exceptionInfo))
{
return -1;
}
return TryRecoverUnresolvedSentinel(exceptionInfo);
}
/// <summary>
/// Windows counterpart of the POSIX SIGSEGV bridge into
/// <see cref="SharpEmu.HLE.GuestImageWriteTracker"/>. Guest code runs natively,
/// so a store into a surface the GPU backend has cached is an ordinary CPU
/// write with nothing to intercept — the page is write-protected instead and
/// the resulting fault is what tells the backend to re-upload. Without this
/// the cache serves the first upload forever, and anything the guest CPU
/// draws (a software-decoded movie frame, a memset fog layer) never reaches
/// the screen.
/// </summary>
private unsafe static bool TryHandleGuestImageWriteFault(void* exceptionInfo)
{
if (!SharpEmu.HLE.GuestImageWriteTracker.Enabled)
{
return false;
}
var exceptionRecord = ((EXCEPTION_POINTERS*)exceptionInfo)->ExceptionRecord;
// STATUS_ACCESS_VIOLATION, and only the write flavour: ExceptionInformation
// is [accessKind, address] with 0=read, 1=write, 8=DEP execute.
if (exceptionRecord->ExceptionCode != 3221225477u ||
exceptionRecord->NumberParameters < 2 ||
exceptionRecord->ExceptionInformation[0] != 1uL)
{
return false;
}
return SharpEmu.HLE.GuestImageWriteTracker.TryHandleWriteFault(
exceptionRecord->ExceptionInformation[1]);
}
private unsafe static int RawUnhandledFilterManaged(void* exceptionInfo)
{
return TryRecoverUnresolvedSentinel(exceptionInfo);
@@ -165,6 +204,10 @@ public sealed partial class DirectExecutionBackend
{
RecordPerfHleCall(importStubEntry.Export?.Name ?? importStubEntry.Nid);
}
if (_profileGuestRip)
{
EnsureGuestRipSampler();
}
int num2 = Volatile.Read(in _rawSentinelRecoveries);
if (num2 != _lastReportedRawSentinelRecoveries)
{
@@ -178,6 +221,10 @@ public sealed partial class DirectExecutionBackend
}
cpuContext.Rip = importStubEntry.Address;
if (_profileGuestRip)
{
cpuContext.ActiveImportIndex = importIndex;
}
LoadImportVolatileArguments(cpuContext, argPackPtr);
cpuContext[CpuRegister.Rdi] = *(ulong*)argPackPtr;
cpuContext[CpuRegister.Rsi] = *(ulong*)(argPackPtr + 8);
@@ -1444,7 +1491,7 @@ public sealed partial class DirectExecutionBackend
string.Equals(nid, "fzyMKs9kim0", StringComparison.Ordinal) &&
result == OrbisGen2Result.ORBIS_GEN2_ERROR_TIMED_OUT;
var expectedMutexTrylockBusy =
string.Equals(nid, "K-jXhbt2gn4", StringComparison.Ordinal) &&
(nid is "K-jXhbt2gn4" or "upoVrzMHFeE") &&
result == OrbisGen2Result.ORBIS_GEN2_ERROR_BUSY;
var expectedSemaphoreTrywaitAgain =
string.Equals(nid, "H2a+IN9TP0E", StringComparison.Ordinal) &&
@@ -1461,6 +1508,9 @@ public sealed partial class DirectExecutionBackend
var expectedPrivacyInvalidParameter =
string.Equals(nid, "D-CzAxQL0XI", StringComparison.Ordinal) &&
resultValue == unchecked((int)0x80960009);
var expectedPlayGoChunkEnumerationEnd =
string.Equals(nid, "uWIYLFkkwqk", StringComparison.Ordinal) &&
resultValue == unchecked((int)0x80B2000C);
if (!expectedFileProbeMiss &&
!expectedTimedWaitTimeout &&
!expectedEqueueTimeout &&
@@ -1469,7 +1519,8 @@ public sealed partial class DirectExecutionBackend
!expectedPollSemaBusy &&
!expectedNetAcceptWouldBlock &&
!expectedUserServiceNoEvent &&
!expectedPrivacyInvalidParameter)
!expectedPrivacyInvalidParameter &&
!expectedPlayGoChunkEnumerationEnd)
{
return true;
}
@@ -13,6 +13,7 @@ using SharpEmu.Core.Cpu.Debugging;
using SharpEmu.Core.Loader;
using SharpEmu.Core.Memory;
using SharpEmu.HLE;
using SharpEmu.Libs.Diagnostics;
namespace SharpEmu.Core.Cpu.Native;
@@ -3651,6 +3652,7 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
$"[LOADER][INFO] Scheduled guest thread '{thread.Name}' handle=0x{thread.ThreadHandle:X16} " +
$"entry=0x{thread.EntryPoint:X16} arg=0x{thread.Argument:X16} priority={thread.Priority} " +
$"host_priority={MapGuestThreadPriority(thread.Priority)} affinity=0x{thread.AffinityMask:X}");
LoadProgressDiagnostics.ArmIfNorthAudioThread(thread.Name);
Pump(creatorContext, "pthread_create");
// Pump is suppressed while another cooperative dispatch is active. The
// background dispatcher would eventually observe this thread, but an
@@ -5312,7 +5314,7 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
var hostCpu = processorCount < 8
? guestCpu % processorCount
: processorCount >= 16
? guestCpu * 2
? MapGuestCpuAcrossSmtLanes(guestCpu, processorCount)
: guestCpu;
if (hostCpu < processorCount)
{
@@ -5323,6 +5325,45 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
return hostAffinityMask;
}
/// <summary>
/// Places guest CPUs on distinct physical cores first, then wraps onto the
/// SMT siblings. Doubling the index alone only works while the title stays
/// inside the first half of the guest CPU set: beyond that every mapped lane
/// lands past the host's processor count and gets dropped, which silently
/// leaves those threads unpinned. Demon's Souls asks for CPUs 0-12 and keeps
/// its renderer on 9 and 11, so dropping the overflow un-pinned both the
/// renderer and a third of its job pool onto every core at once.
/// </summary>
private static int MapGuestCpuAcrossSmtLanes(int guestCpu, int processorCount)
{
// Reserve the top lanes for the emulator itself. A title sized for a
// console's dedicated cores will happily keep a worker per guest CPU
// spinning on an empty queue — Demon's Souls' job pool runs ~90% busy
// doing nothing — and spreading those across every host lane leaves the
// GPU translation and present threads fighting them for a slice. Packing
// near-idle spinners tighter costs them almost nothing and buys back
// whole cores for the work that actually produces frames.
var usableLanes = Math.Max(processorCount - EmulatorReservedLanes, 2);
var physicalCores = usableLanes / 2;
var lane = guestCpu % usableLanes;
return lane < physicalCores
? lane * 2
: ((lane - physicalCores) * 2) + 1;
}
/// <summary>
/// Host lanes kept away from guest threads. Measured on a 16-lane host with
/// Demon's Souls: reserving 0/4/6/8 lanes gave 6.08/6.78/7.20/5.62 fps, so
/// the useful range is a bit over a third of the machine — too few and the
/// emulator is crowded out, too many and the guest cannot make progress.
/// </summary>
private static readonly int EmulatorReservedLanes =
int.TryParse(
Environment.GetEnvironmentVariable("SHARPEMU_RESERVED_HOST_LANES"),
out var reserved) && reserved >= 0
? reserved
: Math.Max(2, Environment.ProcessorCount * 3 / 8);
public bool TrySetGuestThreadPriority(ulong guestThreadHandle, int guestPriority)
{
lock (_guestThreadGate)
@@ -238,15 +238,22 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
var alignedSize = (size + 0xFFF) & ~0xFFFUL;
var protection = executable ? PAGE_EXECUTE_READWRITE : PAGE_READWRITE;
var hostProtection = executable ? HostPageProtection.ReadWriteExecute : HostPageProtection.ReadWrite;
// Reserve address space only for very large non-executable regions; commit is done lazily later.
var reservedOnly = !executable &&
var allowLazyReserve = !executable &&
alignedSize >= LargeDataReserveThreshold &&
alignedSize > FullCommitRegionLimit;
var result = reservedOnly
? _hostMemory.Reserve(desiredAddress, alignedSize, HostPageProtection.ReadWrite)
: _hostMemory.Allocate(desiredAddress, alignedSize, hostProtection);
// Commit first so titles that walk guest memory via raw host pointers
// (GTA post-RenderThread workers) keep fully backed pages. Fall back to
// reserve-only + lazy commit only when a huge non-exec commit fails —
// that is the Poppy / large-reservation path #608 was aiming for.
var reservedOnly = false;
var result = _hostMemory.Allocate(desiredAddress, alignedSize, hostProtection);
if (result == 0 && allowLazyReserve)
{
result = _hostMemory.Reserve(desiredAddress, alignedSize, HostPageProtection.ReadWrite);
reservedOnly = result != 0;
}
if (result == 0)
{
return false;
@@ -260,7 +267,7 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
return false;
}
var state = reservedOnly ? ReserveRegion(actualAddress, alignedSize) : "n/a";
var lazyPrimeState = reservedOnly ? PrimeLazyReserveRegion(actualAddress, alignedSize) : "n/a";
_gate.EnterWriteLock();
try
@@ -279,9 +286,12 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
_gate.ExitWriteLock();
}
var allocationKind = executable ? "executable memory" : "data memory";
TraceVmem($"Allocated exact {allocationKind}: 0x{actualAddress:X16} - 0x{actualAddress + alignedSize:X16} ({alignedSize} bytes)");
var allocationKind = reservedOnly
? "reserved data memory (lazy commit)"
: (executable ? "executable memory" : "data memory");
TraceVmem(
$"Allocated exact {allocationKind}: 0x{actualAddress:X16} - 0x{actualAddress + alignedSize:X16} " +
$"({alignedSize} bytes) lazy_prime={lazyPrimeState}");
return true;
}
@@ -312,55 +322,44 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
var protection = executable ? PAGE_EXECUTE_READWRITE : PAGE_READWRITE;
var hostProtection = executable ? HostPageProtection.ReadWriteExecute : HostPageProtection.ReadWrite;
var reservedOnly = false;
var preferReserveOnly = !executable &&
var allowLazyReserve = !executable &&
alignedSize >= LargeDataReserveThreshold &&
alignedSize > FullCommitRegionLimit;
var reservedOnly = false;
ulong result = 0;
if (preferReserveOnly)
{
result = _hostMemory.Reserve(desiredAddress, alignedSize, HostPageProtection.ReadWrite);
if (result == 0 && allowAlternative)
{
result = _hostMemory.Reserve(0, alignedSize, HostPageProtection.ReadWrite);
}
if (result != 0)
{
reservedOnly = true;
}
}
if (result == 0)
{
result = _hostMemory.Allocate(desiredAddress, alignedSize, hostProtection);
}
// Prefer a full commit. Only fall back to reserve-only when a large
// non-executable commit cannot be satisfied (see TryAllocateAtExact).
ulong result = _hostMemory.Allocate(desiredAddress, alignedSize, hostProtection);
if (result == 0)
{
if (!allowAlternative)
{
throw new InvalidOperationException($"Failed to allocate exact mapping at 0x{desiredAddress:X16} ({alignedSize} bytes)");
}
TraceVmem($"Could not allocate at 0x{desiredAddress:X16}, trying any address...");
result = _hostMemory.Allocate(0, alignedSize, hostProtection);
if (result == 0)
{
if (!executable)
if (allowLazyReserve)
{
result = _hostMemory.Reserve(desiredAddress, alignedSize, HostPageProtection.ReadWrite);
if (result == 0 && allowAlternative)
reservedOnly = result != 0;
}
if (result == 0)
{
throw new InvalidOperationException($"Failed to allocate exact mapping at 0x{desiredAddress:X16} ({alignedSize} bytes)");
}
}
else
{
TraceVmem($"Could not allocate at 0x{desiredAddress:X16}, trying any address...");
result = _hostMemory.Allocate(0, alignedSize, hostProtection);
if (result == 0 && allowLazyReserve)
{
result = _hostMemory.Reserve(desiredAddress, alignedSize, HostPageProtection.ReadWrite);
if (result == 0)
{
result = _hostMemory.Reserve(0, alignedSize, HostPageProtection.ReadWrite);
}
if (result != 0)
{
reservedOnly = true;
}
reservedOnly = result != 0;
}
if (result == 0)
@@ -371,8 +370,7 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
}
var actualAddress = result;
var lazyPrimeState = reservedOnly ? ReserveRegion(actualAddress, alignedSize) : "n/a";
var lazyPrimeState = reservedOnly ? PrimeLazyReserveRegion(actualAddress, alignedSize) : "n/a";
_gate.EnterWriteLock();
try
@@ -399,7 +397,11 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
return actualAddress;
}
private string ReserveRegion(ulong actualAddress, ulong alignedSize)
/// <summary>
/// Commits the leading slice of a reserve-only region so early guest touches
/// succeed before on-demand <see cref="EnsureRangeCommitted"/> runs.
/// </summary>
private string PrimeLazyReserveRegion(ulong actualAddress, ulong alignedSize)
{
var primeBytes = Math.Min(alignedSize, LazyReservePrimeBytes);
if (primeBytes == 0)
@@ -426,11 +428,11 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
var state = committedBytes == primeBytes
? $"ok:{committedBytes:X}"
: $"partial:{committedBytes:X}/{primeBytes:X}";
TraceVmem($"region: 0x{actualAddress:X16} - 0x{actualAddress + committedBytes:X16} ({committedBytes} bytes)");
TraceVmem($"Primed lazy region: 0x{actualAddress:X16} - 0x{actualAddress + committedBytes:X16} ({committedBytes} bytes)");
return state;
}
TraceVmem($"Failed to reserve region at 0x{actualAddress:X16} ({primeBytes} bytes)!");
TraceVmem($"Failed to prime lazy region at 0x{actualAddress:X16} ({primeBytes} bytes), continuing with on-demand commit");
return $"fail:{primeBytes:X}";
}
@@ -1316,12 +1318,26 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
try
{
var region = FindRegion(virtualAddress, 1);
if (region is null ||
(region.IsReservedOnly && !EnsureRangeCommitted(virtualAddress, 1, region)))
if (region is null)
{
return null;
}
// Raw host pointers are walked by native/JIT code without further
// EnsureRangeCommitted calls. For reserve-only regions, commit a
// leading working-set chunk from this address so the common case
// does not immediately AV on the next page.
if (region.IsReservedOnly)
{
var regionEnd = region.VirtualAddress + region.Size;
var remaining = regionEnd > virtualAddress ? regionEnd - virtualAddress : 0;
var commitBytes = Math.Min(remaining, LazyReservePrimeChunkBytes);
if (commitBytes == 0 || !EnsureRangeCommitted(virtualAddress, commitBytes, region))
{
return null;
}
}
return (void*)virtualAddress;
}
finally
@@ -143,6 +143,7 @@ public sealed class SharpEmuRuntime : ISharpEmuRuntime
KernelModuleRegistry.Reset();
var image = LoadImage(normalizedEbootPath);
VideoOutExports.ConfigureApplicationInfo(image.Title, image.TitleId, image.Version);
KernelMemoryCompatExports.ConfigureApplicationInfo(image.TitleId);
SaveDataExports.ConfigureApplicationInfo(image.TitleId);
SystemServiceExports.ConfigureApplicationInfo(image.TitleId);
_ = RegisterLoadedModule(normalizedEbootPath, image, isMain: true, isSystemModule: false);
+2 -1
View File
@@ -6,6 +6,7 @@ using Avalonia.Collections;
using Avalonia.Controls;
using Avalonia.Controls.Templates;
using Avalonia.Data;
using Avalonia.Input.Platform;
using Avalonia.Layout;
using Avalonia.Media;
using Avalonia.Platform;
@@ -40,7 +41,7 @@ public sealed class ConsoleWindow : Window
_searchBox = new TextBox
{
Watermark = loc.Get("Console.SearchWatermark"),
PlaceholderText = loc.Get("Console.SearchWatermark"),
Width = 320,
Margin = new Thickness(0, 0, 12, 0),
};
-620
View File
@@ -1,620 +0,0 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using Avalonia;
using Avalonia.Controls;
using Avalonia.Platform;
using Avalonia.Threading;
using SharpEmu.Libs.VideoOut;
using System.Runtime.InteropServices;
namespace SharpEmu.GUI;
/// <summary>
/// Native child surface owned by Avalonia. The isolated emulator process uses
/// its platform handle to create the Vulkan presentation surface, keeping the
/// guest address space out of the GUI process.
/// </summary>
public sealed class GameSurfaceHost : NativeControlHost
{
private const uint SwpNoSize = 0x0001;
private const uint SwpNoMove = 0x0002;
private const uint SwpNoZOrder = 0x0004;
private const uint SwpNoActivate = 0x0010;
private const uint SwpShowWindow = 0x0040;
private const uint SwpHideWindow = 0x0080;
private const uint WsChild = 0x40000000;
private const uint WsVisible = 0x10000000;
private const uint WsClipSiblings = 0x04000000;
private const uint WsClipChildren = 0x02000000;
private const uint CsOwnDc = 0x0020;
private const uint WmSetCursor = 0x0020;
private const uint WmMouseMove = 0x0200;
private const int IdcArrow = 32512;
private const int CursorHideDelayMs = 2500;
private VulkanHostSurface? _surface;
private nint _windowHandle;
private nint _x11Display;
private string? _win32ClassName;
private WindowProcedure? _windowProcedure;
private nint _metalLayer;
private bool _presentationVisible = true;
private DispatcherTimer? _cursorIdleTimer;
private bool _cursorAutoHide;
private bool _cursorHidden;
private long _lastPointerActivity;
public GameSurfaceHost()
{
PropertyChanged += (_, change) =>
{
if (change.Property == BoundsProperty)
{
UpdateSurfaceSize();
}
};
LayoutUpdated += (_, _) =>
{
// Fullscreen can change a monitor's DPI scale without changing
// the logical Bounds. Refresh the native child from physical size.
UpdateSurfaceSize();
// NativeControlHost may make its HWND visible again as part of a
// later arrange pass. Keep a loading surface hidden until its
// child process reports a real first frame.
if (!_presentationVisible)
{
ApplyPresentationVisibility();
}
};
}
public event EventHandler<VulkanHostSurface>? SurfaceAvailable;
public event EventHandler<VulkanHostSurface>? SurfaceDestroyed;
public VulkanHostSurface? Surface => _surface;
public void RefreshSurfaceSize() => UpdateSurfaceSize();
/// <summary>
/// Hides the platform child without detaching the Vulkan surface. This
/// allows the launcher to return to its library while guest teardown is
/// still finishing on the render thread.
/// </summary>
public void SetPresentationVisible(bool visible)
{
_presentationVisible = visible;
ApplyPresentationVisibility();
}
/// <summary>
/// Auto-hides the mouse cursor over the game surface after a short idle
/// period; any pointer movement brings it back. Enabling (again) restarts
/// the idle countdown, so both "first frame presented" and "entered
/// fullscreen" can arm it. Windows-only; a no-op elsewhere.
/// </summary>
public void SetCursorAutoHide(bool enabled)
{
if (!OperatingSystem.IsWindows())
{
return;
}
_cursorAutoHide = enabled;
_lastPointerActivity = System.Diagnostics.Stopwatch.GetTimestamp();
if (enabled)
{
_cursorIdleTimer ??= CreateCursorIdleTimer();
_cursorIdleTimer.Start();
return;
}
_cursorIdleTimer?.Stop();
ShowCursorNow();
}
private DispatcherTimer CreateCursorIdleTimer()
{
var timer = new DispatcherTimer
{
Interval = TimeSpan.FromMilliseconds(250),
};
timer.Tick += (_, _) => HideCursorWhenIdle();
return timer;
}
private void HideCursorWhenIdle()
{
if (!_cursorAutoHide || _cursorHidden || _windowHandle == 0)
{
return;
}
var idleMs = (System.Diagnostics.Stopwatch.GetTimestamp() - _lastPointerActivity) *
1000 / System.Diagnostics.Stopwatch.Frequency;
if (idleMs < CursorHideDelayMs)
{
return;
}
// Only swallow the cursor while it is actually over the game surface;
// hovering launcher chrome (console, toolbar) must keep the arrow.
if (!GetCursorPos(out var point) || WindowFromPoint(point) != _windowHandle)
{
return;
}
_cursorHidden = true;
_ = SetCursor(0);
}
private void ShowCursorNow()
{
if (!_cursorHidden)
{
return;
}
_cursorHidden = false;
_ = SetCursor(LoadCursorW(0, IdcArrow));
}
private void ApplyPresentationVisibility()
{
if (_windowHandle == 0)
{
return;
}
var visible = _presentationVisible;
if (OperatingSystem.IsWindows())
{
// SW_HIDE can be ignored for a window's initial show state. Force
// the state through SetWindowPos so an old child swapchain cannot
// remain composed while the next game is loading.
var flags = SwpNoSize | SwpNoMove | SwpNoZOrder | SwpNoActivate |
(visible ? SwpShowWindow : SwpHideWindow);
_ = SetWindowPos(_windowHandle, 0, 0, 0, 0, 0, flags);
}
else if (OperatingSystem.IsLinux() && _x11Display != 0)
{
_ = visible
? XMapWindow(_x11Display, _windowHandle)
: XUnmapWindow(_x11Display, _windowHandle);
_ = XFlush(_x11Display);
}
else if (OperatingSystem.IsMacOS())
{
SendBool(_windowHandle, "setHidden:", !visible);
}
}
protected override IPlatformHandle CreateNativeControlCore(IPlatformHandle control)
{
PlatformHandle handle;
if (OperatingSystem.IsWindows())
{
handle = CreateWin32(control);
}
else if (OperatingSystem.IsLinux())
{
handle = CreateX11(control);
}
else if (OperatingSystem.IsMacOS())
{
handle = CreateMacOS();
}
else
{
throw new PlatformNotSupportedException("SharpEmu's embedded Vulkan surface is unsupported on this platform.");
}
UpdateSurfaceSize();
if (_surface is { } surface)
{
SurfaceAvailable?.Invoke(this, surface);
}
return handle;
}
protected override void DestroyNativeControlCore(IPlatformHandle control)
{
if (OperatingSystem.IsWindows())
{
SetCursorAutoHide(false);
}
var surface = _surface;
_surface = null;
if (OperatingSystem.IsWindows())
{
DestroyWin32();
}
else if (OperatingSystem.IsLinux())
{
DestroyX11();
}
else if (OperatingSystem.IsMacOS())
{
DestroyMacOS();
}
if (surface is not null)
{
SurfaceDestroyed?.Invoke(this, surface);
}
}
private PlatformHandle CreateWin32(IPlatformHandle control)
{
_win32ClassName = $"SharpEmuGameSurface-{Guid.NewGuid():N}";
_windowProcedure = WindowProcedureImpl;
var classInfo = new WndClassEx
{
Size = (uint)Marshal.SizeOf<WndClassEx>(),
Style = CsOwnDc,
WindowProcedure = Marshal.GetFunctionPointerForDelegate(_windowProcedure),
Instance = GetModuleHandleW(null),
ClassName = _win32ClassName,
};
if (RegisterClassExW(ref classInfo) == 0)
{
throw new InvalidOperationException($"Could not register the embedded game window class (Win32 error {Marshal.GetLastWin32Error()}).");
}
_windowHandle = CreateWindowExW(
0,
_win32ClassName,
"SharpEmu Game Surface",
WsChild | (_presentationVisible ? WsVisible : 0) | WsClipSiblings | WsClipChildren,
0,
0,
1,
1,
control.Handle,
0,
classInfo.Instance,
0);
if (_windowHandle == 0)
{
var error = Marshal.GetLastWin32Error();
_ = UnregisterClassW(_win32ClassName, classInfo.Instance);
throw new InvalidOperationException($"Could not create the embedded game window (Win32 error {error}).");
}
_surface = new VulkanHostSurface(
VulkanHostSurfaceKind.Win32,
_windowHandle,
classInfo.Instance);
return new PlatformHandle(_windowHandle, "HWND");
}
private PlatformHandle CreateX11(IPlatformHandle control)
{
_x11Display = XOpenDisplay(0);
if (_x11Display == 0)
{
throw new InvalidOperationException("Could not connect to the X11 server for the embedded game surface.");
}
_windowHandle = XCreateSimpleWindow(
_x11Display,
control.Handle,
0,
0,
1,
1,
0,
0,
0);
if (_windowHandle == 0)
{
XCloseDisplay(_x11Display);
_x11Display = 0;
throw new InvalidOperationException("Could not create the X11 embedded game surface.");
}
if (_presentationVisible)
{
_ = XMapWindow(_x11Display, _windowHandle);
}
_ = XFlush(_x11Display);
_surface = new VulkanHostSurface(VulkanHostSurfaceKind.Xlib, _windowHandle, _x11Display);
return new PlatformHandle(_windowHandle, "X11");
}
private PlatformHandle CreateMacOS()
{
_metalLayer = CreateObjectiveCObject("CAMetalLayer");
_windowHandle = CreateObjectiveCObject("NSView");
SendBool(_windowHandle, "setWantsLayer:", true);
SendPointer(_windowHandle, "setLayer:", _metalLayer);
SendBool(_windowHandle, "setHidden:", !_presentationVisible);
_surface = new VulkanHostSurface(VulkanHostSurfaceKind.Metal, _windowHandle, metalLayerHandle: _metalLayer);
return new PlatformHandle(_windowHandle, "NSView");
}
private void UpdateSurfaceSize()
{
if (_surface is null)
{
return;
}
var renderScale = (VisualRoot as TopLevel)?.RenderScaling ?? 1.0;
var width = Math.Max(1, (int)Math.Round(Bounds.Width * renderScale));
var height = Math.Max(1, (int)Math.Round(Bounds.Height * renderScale));
var sizeChanged = _surface.PixelWidth != width || _surface.PixelHeight != height;
if (Environment.GetEnvironmentVariable("SHARPEMU_TRACE_SURFACE_SIZE") == "1")
{
Console.Error.WriteLine(
$"[GUI][TRACE] GameSurfaceHost.UpdateSurfaceSize bounds={Bounds.Width}x{Bounds.Height} " +
$"scale={renderScale} computed={width}x{height} changed={sizeChanged} " +
$"prevSurface={_surface.PixelWidth}x{_surface.PixelHeight}");
}
_surface.UpdatePixelSize(width, height);
if (!sizeChanged)
{
return;
}
if (OperatingSystem.IsWindows() && _windowHandle != 0)
{
_ = SetWindowPos(
_windowHandle,
0,
0,
0,
width,
height,
SwpNoMove | SwpNoZOrder | SwpNoActivate);
}
else if (OperatingSystem.IsLinux() && _x11Display != 0 && _windowHandle != 0)
{
_ = XResizeWindow(_x11Display, _windowHandle, (uint)width, (uint)height);
_ = XFlush(_x11Display);
}
else if (OperatingSystem.IsMacOS() && _metalLayer != 0)
{
SendDouble(_metalLayer, "setContentsScale:", renderScale);
}
}
private void DestroyWin32()
{
if (_windowHandle != 0)
{
_ = DestroyWindow(_windowHandle);
_windowHandle = 0;
}
if (!string.IsNullOrWhiteSpace(_win32ClassName))
{
_ = UnregisterClassW(_win32ClassName, GetModuleHandleW(null));
_win32ClassName = null;
}
_windowProcedure = null;
}
private void DestroyX11()
{
if (_x11Display != 0 && _windowHandle != 0)
{
_ = XDestroyWindow(_x11Display, _windowHandle);
}
if (_x11Display != 0)
{
_ = XCloseDisplay(_x11Display);
}
_windowHandle = 0;
_x11Display = 0;
}
private void DestroyMacOS()
{
if (_windowHandle != 0)
{
SendVoid(_windowHandle, "release");
}
if (_metalLayer != 0)
{
SendVoid(_metalLayer, "release");
}
_windowHandle = 0;
_metalLayer = 0;
}
private nint WindowProcedureImpl(nint window, uint message, nint wParam, nint lParam)
{
if (message == WmMouseMove)
{
_lastPointerActivity = System.Diagnostics.Stopwatch.GetTimestamp();
ShowCursorNow();
}
else if (message == WmSetCursor && _cursorHidden)
{
// Win32 re-resolves the cursor on every mouse message; returning
// TRUE here keeps the parent chain from restoring the arrow.
_ = SetCursor(0);
return 1;
}
return DefWindowProcW(window, message, wParam, lParam);
}
private static nint CreateObjectiveCObject(string className)
{
var classHandle = objc_getClass(className);
if (classHandle == 0)
{
throw new InvalidOperationException($"Objective-C class '{className}' is unavailable.");
}
var instance = objc_msgSend_id(classHandle, sel_registerName("alloc"));
instance = objc_msgSend_id(instance, sel_registerName("init"));
if (instance == 0)
{
throw new InvalidOperationException($"Could not create Objective-C '{className}'.");
}
return instance;
}
private static void SendVoid(nint receiver, string selector) =>
objc_msgSend_void(receiver, sel_registerName(selector));
private static void SendBool(nint receiver, string selector, bool value) =>
objc_msgSend_bool(receiver, sel_registerName(selector), value ? (byte)1 : (byte)0);
private static void SendPointer(nint receiver, string selector, nint value) =>
objc_msgSend_pointer(receiver, sel_registerName(selector), value);
private static void SendDouble(nint receiver, string selector, double value) =>
objc_msgSend_double(receiver, sel_registerName(selector), value);
private delegate nint WindowProcedure(nint window, uint message, nint wParam, nint lParam);
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
private struct WndClassEx
{
public uint Size;
public uint Style;
public nint WindowProcedure;
public int ClassExtra;
public int WindowExtra;
public nint Instance;
public nint Icon;
public nint Cursor;
public nint Background;
public string? MenuName;
public string? ClassName;
public nint IconSmall;
}
[DllImport("kernel32.dll", EntryPoint = "GetModuleHandleW", CharSet = CharSet.Unicode)]
private static extern nint GetModuleHandleW(string? moduleName);
[DllImport("user32.dll", EntryPoint = "RegisterClassExW", SetLastError = true, CharSet = CharSet.Unicode)]
private static extern ushort RegisterClassExW(ref WndClassEx classInfo);
[DllImport("user32.dll", EntryPoint = "UnregisterClassW", SetLastError = true, CharSet = CharSet.Unicode)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool UnregisterClassW(string className, nint instance);
[DllImport("user32.dll", EntryPoint = "CreateWindowExW", SetLastError = true, CharSet = CharSet.Unicode)]
private static extern nint CreateWindowExW(
uint extendedStyle,
string className,
string windowName,
uint style,
int x,
int y,
int width,
int height,
nint parent,
nint menu,
nint instance,
nint parameter);
[DllImport("user32.dll", EntryPoint = "DestroyWindow", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool DestroyWindow(nint window);
[DllImport("user32.dll", EntryPoint = "SetWindowPos", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool SetWindowPos(
nint window,
nint insertAfter,
int x,
int y,
int width,
int height,
uint flags);
[DllImport("user32.dll", EntryPoint = "DefWindowProcW", CharSet = CharSet.Unicode)]
private static extern nint DefWindowProcW(nint window, uint message, nint wParam, nint lParam);
[DllImport("user32.dll", EntryPoint = "SetCursor")]
private static extern nint SetCursor(nint cursor);
[DllImport("user32.dll", EntryPoint = "LoadCursorW", CharSet = CharSet.Unicode)]
private static extern nint LoadCursorW(nint instance, nint cursorName);
[DllImport("user32.dll", EntryPoint = "GetCursorPos")]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool GetCursorPos(out NativePoint point);
[DllImport("user32.dll", EntryPoint = "WindowFromPoint")]
private static extern nint WindowFromPoint(NativePoint point);
[StructLayout(LayoutKind.Sequential)]
private struct NativePoint
{
public int X;
public int Y;
}
[DllImport("libX11.so.6", EntryPoint = "XOpenDisplay")]
private static extern nint XOpenDisplay(nint displayName);
[DllImport("libX11.so.6", EntryPoint = "XCreateSimpleWindow")]
private static extern nint XCreateSimpleWindow(
nint display,
nint parent,
int x,
int y,
uint width,
uint height,
uint borderWidth,
ulong border,
ulong background);
[DllImport("libX11.so.6", EntryPoint = "XMapWindow")]
private static extern int XMapWindow(nint display, nint window);
[DllImport("libX11.so.6", EntryPoint = "XUnmapWindow")]
private static extern int XUnmapWindow(nint display, nint window);
[DllImport("libX11.so.6", EntryPoint = "XResizeWindow")]
private static extern int XResizeWindow(nint display, nint window, uint width, uint height);
[DllImport("libX11.so.6", EntryPoint = "XDestroyWindow")]
private static extern int XDestroyWindow(nint display, nint window);
[DllImport("libX11.so.6", EntryPoint = "XCloseDisplay")]
private static extern int XCloseDisplay(nint display);
[DllImport("libX11.so.6", EntryPoint = "XFlush")]
private static extern int XFlush(nint display);
[DllImport("/usr/lib/libobjc.A.dylib")]
private static extern nint objc_getClass(string name);
[DllImport("/usr/lib/libobjc.A.dylib")]
private static extern nint sel_registerName(string name);
[DllImport("/usr/lib/libobjc.A.dylib", EntryPoint = "objc_msgSend")]
private static extern nint objc_msgSend_id(nint receiver, nint selector);
[DllImport("/usr/lib/libobjc.A.dylib", EntryPoint = "objc_msgSend")]
private static extern void objc_msgSend_void(nint receiver, nint selector);
[DllImport("/usr/lib/libobjc.A.dylib", EntryPoint = "objc_msgSend")]
private static extern void objc_msgSend_bool(nint receiver, nint selector, byte value);
[DllImport("/usr/lib/libobjc.A.dylib", EntryPoint = "objc_msgSend")]
private static extern void objc_msgSend_pointer(nint receiver, nint selector, nint value);
[DllImport("/usr/lib/libobjc.A.dylib", EntryPoint = "objc_msgSend")]
private static extern void objc_msgSend_double(nint receiver, nint selector, double value);
}
+34
View File
@@ -50,6 +50,20 @@ public sealed class GuiSettings
public bool CheckForUpdatesOnStartup { get; set; } = true;
public string WindowMode { get; set; } = "Windowed";
public string Resolution { get; set; } = "1920x1080";
public int DisplayIndex { get; set; }
public int RefreshRate { get; set; }
public string ScalingMode { get; set; } = "Fit";
public bool VSync { get; set; } = true;
public string HdrMode { get; set; } = "Auto";
/// <summary>Names of SHARPEMU_* switches set to "1" in the emulator's environment at launch.</summary>
public List<string> EnvironmentToggles { get; set; } = new();
@@ -103,6 +117,12 @@ public sealed class GuiSettings
{
settings.RenderResolutionScale = 1.0;
}
settings.WindowMode = NormalizeChoice(settings.WindowMode, "Windowed", "Borderless", "Exclusive");
settings.Resolution = NormalizeResolution(settings.Resolution);
settings.ScalingMode = NormalizeChoice(settings.ScalingMode, "Fit", "Cover", "Stretch", "Integer");
settings.HdrMode = NormalizeChoice(settings.HdrMode, "Auto", "On", "Off");
settings.DisplayIndex = Math.Max(0, settings.DisplayIndex);
settings.RefreshRate = Math.Clamp(settings.RefreshRate, 0, 1000);
return settings;
}
@@ -118,6 +138,20 @@ public sealed class GuiSettings
return source.Where(entry => !string.IsNullOrEmpty(entry)).ToList();
}
private static string NormalizeChoice(string? value, string fallback, params string[] choices) =>
choices.Prepend(fallback).FirstOrDefault(
choice => string.Equals(choice, value, StringComparison.OrdinalIgnoreCase)) ?? fallback;
private static string NormalizeResolution(string? value)
{
if (!HostDisplayOptions.TryParseResolution(value, out var width, out var height))
{
return "1920x1080";
}
return $"{width}x{height}";
}
public void Save()
{
try
+138
View File
@@ -0,0 +1,138 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.Libs.VideoOut;
namespace SharpEmu.GUI;
internal sealed record HostDisplayOption(HostDisplayInfo Display)
{
public int Index => Display.Index;
public IReadOnlyList<HostDisplayMode> Modes => Display.Modes;
public override string ToString() => $"{Index + 1}: {Display.Name}";
}
internal sealed record HostRefreshRateOption(int Value, string Label)
{
public override string ToString() => Label;
}
internal static class HostDisplayOptions
{
public static IReadOnlyList<HostDisplayOption> BuildDisplays(
IReadOnlyList<HostDisplayInfo> detected,
int selectedIndex)
{
selectedIndex = Math.Max(0, selectedIndex);
var options = detected
.Select(display => new HostDisplayOption(display))
.ToList();
if (options.Count == 0)
{
options.Add(new HostDisplayOption(new HostDisplayInfo(
0,
"Display 1",
CreateFallbackModes())));
}
if (options.All(display => display.Index != selectedIndex))
{
options.Add(new HostDisplayOption(new HostDisplayInfo(
selectedIndex,
$"Display {selectedIndex + 1}",
options[0].Modes)));
}
return options.OrderBy(display => display.Index).ToArray();
}
public static HostDisplayOption SelectDisplay(
IReadOnlyList<HostDisplayOption> displays,
int selectedIndex) =>
displays.FirstOrDefault(display => display.Index == selectedIndex) ?? displays[0];
public static IReadOnlyList<string> BuildResolutions(
HostDisplayOption display,
string? selectedResolution)
{
var resolutions = display.Modes
.Where(mode => mode.Width > 0 && mode.Height > 0)
.Select(mode => $"{mode.Width}x{mode.Height}")
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
if (TryParseResolution(selectedResolution, out var selectedWidth, out var selectedHeight))
{
var selected = $"{selectedWidth}x{selectedHeight}";
if (!resolutions.Contains(selected, StringComparer.OrdinalIgnoreCase))
{
resolutions.Add(selected);
}
}
if (resolutions.Count == 0)
{
resolutions.Add("1920x1080");
}
return resolutions
.OrderByDescending(resolution => ResolutionArea(resolution))
.ThenByDescending(resolution => resolution, StringComparer.OrdinalIgnoreCase)
.ToArray();
}
public static IReadOnlyList<HostRefreshRateOption> BuildRefreshRates(
HostDisplayOption display,
string? resolution,
int selectedRefreshRate,
string automaticLabel)
{
TryParseResolution(resolution, out var width, out var height);
var rates = display.Modes
.Where(mode => mode.Width == width && mode.Height == height && mode.RefreshRate > 0)
.Select(mode => mode.RefreshRate)
.Distinct()
.OrderByDescending(rate => rate)
.ToList();
if (selectedRefreshRate > 0 && !rates.Contains(selectedRefreshRate))
{
rates.Add(selectedRefreshRate);
rates.Sort((left, right) => right.CompareTo(left));
}
return new[] { new HostRefreshRateOption(0, automaticLabel) }
.Concat(rates.Select(rate => new HostRefreshRateOption(rate, $"{rate} Hz")))
.ToArray();
}
public static bool TryParseResolution(string? value, out int width, out int height)
{
width = 0;
height = 0;
if (string.IsNullOrWhiteSpace(value))
{
return false;
}
var separator = value.IndexOf('x', StringComparison.OrdinalIgnoreCase);
return separator > 0 &&
int.TryParse(value.AsSpan(0, separator), out width) &&
int.TryParse(value.AsSpan(separator + 1), out height) &&
width > 0 &&
height > 0;
}
private static long ResolutionArea(string resolution) =>
TryParseResolution(resolution, out var width, out var height)
? (long)width * height
: 0;
private static IReadOnlyList<HostDisplayMode> CreateFallbackModes() =>
[
new HostDisplayMode(3840, 2160, 60),
new HostDisplayMode(2560, 1440, 60),
new HostDisplayMode(1920, 1080, 60),
new HostDisplayMode(1280, 720, 60),
];
}
+20
View File
@@ -41,6 +41,24 @@
"Options.Section.Emulation": "EMULATION",
"Options.Section.Logging": "LOGGING",
"Options.Section.Launcher": "LAUNCHER",
"Options.Section.Display": "DISPLAY",
"Options.Graphics": "Graphics",
"Options.WindowMode.Label": "Window mode",
"Options.WindowMode.Desc": "Regular window, desktop borderless, or exclusive fullscreen.",
"Options.Resolution.Label": "Resolution",
"Options.Resolution.Desc": "Initial window size or exclusive fullscreen resolution.",
"Options.Display.Label": "Display",
"Options.Display.Desc": "Monitor used for centering and fullscreen.",
"Options.RefreshRate.Label": "Refresh rate",
"Options.RefreshRate.Desc": "Exclusive fullscreen refresh rate. Automatic selects the closest mode.",
"Options.RefreshRate.Automatic": "Automatic",
"Options.Scaling.Label": "Scaling",
"Options.Scaling.Desc": "Scale the native guest image without changing its internal resolution.",
"Options.VSync.Label": "VSync",
"Options.VSync.Desc": "Use FIFO presentation for tear-free output.",
"Options.Hdr.Label": "HDR output",
"Options.Hdr.Desc": "Use HDR when the selected display and graphics backend support it. Auto falls back to SDR.",
"Options.CpuEngine.Label": "CPU engine",
"Options.CpuEngine.Desc": "Execution engine used to run game code.",
@@ -87,6 +105,8 @@
"PerGame.Title": "Per-game settings — {0} ({1})",
"PerGame.InheritNote": "Unchecked rows inherit the global defaults.",
"PerGame.Tab.General": "General",
"PerGame.Tab.Graphics": "Graphics",
"PerGame.EnvToggles.Label": "Environment toggles",
"PerGame.EnvToggles.Desc": "Override the global set of SHARPEMU_* switches for this game.",
+20
View File
@@ -29,6 +29,24 @@
"Options.Section.Emulation": "EMÜLASYON",
"Options.Section.Logging": "GÜNLÜKLEME",
"Options.Section.Launcher": "BAŞLATICI",
"Options.Section.Display": "GÖRÜNTÜ",
"Options.Graphics": "Grafik",
"Options.WindowMode.Label": "Pencere modu",
"Options.WindowMode.Desc": "Normal pencere, kenarlıksız masaüstü veya özel tam ekran.",
"Options.Resolution.Label": "Çözünürlük",
"Options.Resolution.Desc": "Başlangıç pencere boyutu veya özel tam ekran çözünürlüğü.",
"Options.Display.Label": "Ekran",
"Options.Display.Desc": "Ortalama ve tam ekran için kullanılan monitör.",
"Options.RefreshRate.Label": "Yenileme hızı",
"Options.RefreshRate.Desc": "Özel tam ekran yenileme hızı. Otomatik, en yakın modu seçer.",
"Options.RefreshRate.Automatic": "Otomatik",
"Options.Scaling.Label": "Ölçekleme",
"Options.Scaling.Desc": "Dahili çözünürlüğü değiştirmeden oyun görüntüsünü ölçekle.",
"Options.VSync.Label": "VSync",
"Options.VSync.Desc": "Yırtılmasız görüntü için FIFO sunumunu kullan.",
"Options.Hdr.Label": "HDR çıkışı",
"Options.Hdr.Desc": "Seçili ekran ve grafik backend'i destekliyorsa HDR kullan. Otomatik mod SDR'ye geri döner.",
"Options.CpuEngine.Label": "CPU motoru",
"Options.CpuEngine.Desc": "Oyun kodunu çalıştırmak için kullanılan yürütme motoru.",
@@ -159,6 +177,8 @@
"Common.Cancel": "İptal",
"PerGame.Title": "Oyuna özel ayarlar — {0} ({1})",
"PerGame.InheritNote": "İşaretlenmemiş satırlar genel varsayılanları kullanır.",
"PerGame.Tab.General": "Genel",
"PerGame.Tab.Graphics": "Grafik",
"PerGame.EnvToggles.Label": "Ortam anahtarları",
"PerGame.EnvToggles.Desc": "Bu oyun için genel SHARPEMU_* anahtar kümesini geçersiz kıl.",
"Options.About": "Hakkında",
+3 -3
View File
@@ -5,6 +5,8 @@ using System.Text.Json;
namespace SharpEmu.GUI;
public sealed record LanguageInfo(string Code, string NativeName);
/// <summary>
/// Loads UI strings for the launcher. Every language ships embedded in the
/// assembly (see SharpEmu.GUI.csproj) so a release build is fully
@@ -16,8 +18,6 @@ public sealed class Localization
{
public static Localization Instance { get; } = new();
public sealed record LanguageInfo(string Code, string NativeName);
private const string EmbeddedResourcePrefix = "Languages.";
private const string EmbeddedResourceSuffix = ".json";
@@ -242,7 +242,7 @@ public sealed class Localization
result = loaded;
return true;
}
private bool TryLoad(string code, string json)
{
if (TryLoad(json, out var dict))
+68 -62
View File
@@ -15,7 +15,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
WindowStartupLocation="CenterScreen"
Background="{StaticResource BgBrush}"
ExtendClientAreaToDecorationsHint="True"
ExtendClientAreaChromeHints="PreferSystemChrome"
WindowDecorations="Full"
ExtendClientAreaTitleBarHeightHint="44"
Icon="avares://SharpEmu.GUI/Assets/SharpEmu.ico"
KeyDown="OnKeyDown">
@@ -60,12 +60,6 @@ SPDX-License-Identifier: GPL-2.0-or-later
<!-- Main content -->
<Grid x:Name="MainContent" Grid.Row="1" Margin="32,24,32,20" RowDefinitions="Auto,*,Auto,Auto">
<!-- The game owns the full client area while running. Session controls
use a native popup so they can stay above this native child surface. -->
<Border x:Name="GameView" Grid.Row="0" Grid.RowSpan="4" IsVisible="False" Background="#000000" ClipToBounds="True">
<Grid x:Name="GameSurfaceContainer" />
</Border>
<!-- Library / Options page switcher, with the library toolbar sharing
the same row on the right. Plain buttons (not TabItem) so there is
no underline; LB/RB hint chips flank the pair and the gamepad's
@@ -84,7 +78,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
<StackPanel Grid.Column="2" x:Name="LibraryToolbar" Orientation="Horizontal" Spacing="8"
VerticalAlignment="Center">
<TextBox x:Name="SearchBox" Watermark="Search library…" Width="240" VerticalAlignment="Center" />
<TextBox x:Name="SearchBox" PlaceholderText="Search library…" Width="240" VerticalAlignment="Center" />
<Button x:Name="AddFolderButton" Classes="ghost" Content=" Add folder" VerticalAlignment="Center" />
<Button x:Name="RescanButton" Classes="ghost" Content="⟳ Rescan" VerticalAlignment="Center" />
<Button x:Name="OpenFileButton" Classes="ghost" Content="Open file…" VerticalAlignment="Center" />
@@ -148,7 +142,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
</ItemsPanelTemplate>
</ListBox.ItemsPanel>
<ListBox.ItemTemplate>
<DataTemplate>
<DataTemplate x:DataType="local:GameEntry" x:CompileBindings="True">
<StackPanel Width="128" Height="172" Spacing="7">
<Border Classes="coverShadow" Width="128" Height="128">
<Border Classes="coverClip">
@@ -273,8 +267,13 @@ SPDX-License-Identifier: GPL-2.0-or-later
<local:SettingRow x:Name="LanguageRow" Label="Emulator language"
Description="Language used throughout the launcher. Applies immediately.">
<ComboBox x:Name="LanguageBox" Width="160"
VerticalAlignment="Center" CornerRadius="8"
DisplayMemberBinding="{Binding NativeName}" />
VerticalAlignment="Center" CornerRadius="8">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="local:LanguageInfo">
<TextBlock Text="{Binding NativeName}" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
</local:SettingRow>
<local:SettingRow x:Name="TitleMusicRow" Label="Title music"
@@ -403,7 +402,6 @@ SPDX-License-Identifier: GPL-2.0-or-later
<TabItem x:Name="GraphicsTabItem" Header="Graphics" FontSize="15">
<ScrollViewer>
<StackPanel Margin="0,14,0,16" Spacing="16" MaxWidth="1280" HorizontalAlignment="Left">
<Border Classes="card">
<StackPanel Spacing="14">
<TextBlock x:Name="RenderingSectionTitle" Classes="sectionTitle" Text="RENDERING" />
@@ -420,6 +418,46 @@ SPDX-License-Identifier: GPL-2.0-or-later
</local:SettingRow>
</StackPanel>
</Border>
<Border Classes="card">
<StackPanel Spacing="14">
<TextBlock x:Name="DisplaySectionTitle" Classes="sectionTitle" Text="DISPLAY" />
<local:SettingRow x:Name="WindowModeRow" Label="Window mode" Description="Regular window, desktop borderless, or exclusive fullscreen.">
<ComboBox x:Name="WindowModeBox" Width="180" SelectedIndex="0" CornerRadius="8">
<ComboBoxItem Content="Windowed" />
<ComboBoxItem Content="Borderless" />
<ComboBoxItem Content="Exclusive" />
</ComboBox>
</local:SettingRow>
<local:SettingRow x:Name="ResolutionRow" Label="Resolution" Description="Initial window size or exclusive fullscreen resolution.">
<ComboBox x:Name="ResolutionBox" Width="180" CornerRadius="8" />
</local:SettingRow>
<local:SettingRow x:Name="DisplayRow" Label="Display" Description="Monitor used for centering and fullscreen.">
<ComboBox x:Name="DisplayBox" Width="260" CornerRadius="8" />
</local:SettingRow>
<local:SettingRow x:Name="RefreshRateRow" Label="Refresh rate" Description="Exclusive fullscreen refresh rate. Automatic selects the closest mode.">
<ComboBox x:Name="RefreshRateBox" Width="180" CornerRadius="8" />
</local:SettingRow>
<local:SettingRow x:Name="ScalingRow" Label="Scaling" Description="Scale the native guest image without changing its internal resolution.">
<ComboBox x:Name="ScalingModeBox" Width="180" SelectedIndex="0" CornerRadius="8">
<ComboBoxItem Content="Fit" />
<ComboBoxItem Content="Cover" />
<ComboBoxItem Content="Stretch" />
<ComboBoxItem Content="Integer" />
</ComboBox>
</local:SettingRow>
<local:SettingRow x:Name="VSyncRow" Label="VSync" Description="Use FIFO presentation for tear-free output.">
<ToggleSwitch x:Name="VSyncToggle" IsChecked="True" OnContent="On" OffContent="Off" />
</local:SettingRow>
<local:SettingRow x:Name="HdrRow" Label="HDR" Description="Use HDR output when the selected display and graphics backend support it.">
<ComboBox x:Name="HdrModeBox" Width="180" SelectedIndex="0" CornerRadius="8">
<ComboBoxItem Content="Auto" />
<ComboBoxItem Content="On" />
<ComboBoxItem Content="Off" />
</ComboBox>
</local:SettingRow>
</StackPanel>
</Border>
</StackPanel>
</ScrollViewer>
</TabItem>
@@ -481,6 +519,12 @@ SPDX-License-Identifier: GPL-2.0-or-later
<ToggleSwitch x:Name="EnvLogNpToggle" OnContent="On" OffContent="Off"
VerticalAlignment="Center" />
</local:SettingRow>
<local:SettingRow x:Name="EnvGuestImageCpuSyncRow" LabelFontFamily="Consolas,monospace" Label="SHARPEMU_GUEST_IMAGE_CPU_SYNC"
Description="Re-upload guest surfaces the game's own CPU code rewrites.&#10;Enabled by default for compatibility.&#10;Disable only for titles that regress with it, such as GTA V.">
<ToggleSwitch x:Name="EnvGuestImageCpuSyncToggle" OnContent="On" OffContent="Off"
VerticalAlignment="Center" />
</local:SettingRow>
</StackPanel>
</Border>
@@ -498,7 +542,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
<Grid Grid.Row="0" ColumnDefinitions="*,Auto,Auto,Auto,Auto,Auto" Margin="16,12,16,8">
<TextBlock x:Name="ConsoleSectionTitle" Classes="sectionTitle" Text="CONSOLE" VerticalAlignment="Center" />
<TextBox Grid.Column="1" FontSize="12" Margin="0,0,12,0" x:Name="ConsoleSearchBox"
Watermark="Search..." Width="320" />
PlaceholderText="Search..." Width="320" />
<CheckBox Grid.Column="2" x:Name="AutoScrollCheck" Content="Auto-scroll" IsChecked="True"
FontSize="12" Margin="0,0,12,0" />
<Button Grid.Column="3" x:Name="DetachConsoleButton" Classes="ghost" Content="Split" FontSize="12"
@@ -511,7 +555,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
<ListBox Grid.Row="1" x:Name="ConsoleList" Classes="console" BorderThickness="0,1,0,0"
BorderBrush="{StaticResource CardBorderBrush}" CornerRadius="0,0,12,12">
<ListBox.ItemTemplate>
<DataTemplate>
<DataTemplate x:DataType="local:LogLine" x:CompileBindings="True">
<TextBlock Text="{Binding Text}" Foreground="{Binding Brush}" TextWrapping="NoWrap" />
</DataTemplate>
</ListBox.ItemTemplate>
@@ -527,7 +571,9 @@ SPDX-License-Identifier: GPL-2.0-or-later
<!-- Selected game cover thumbnail -->
<Border Grid.Column="0" Classes="coverClip" Width="56" Height="56" CornerRadius="8"
VerticalAlignment="Center">
<Panel x:Name="SelectedCoverPanel">
<Panel x:Name="SelectedCoverPanel"
x:DataType="local:GameEntry"
x:CompileBindings="True">
<Border Background="{Binding PlaceholderBrush, FallbackValue={x:Null}}"
IsVisible="{Binding !HasCover, FallbackValue=False}">
<TextBlock Text="{Binding Initials}" FontSize="20" FontWeight="Bold"
@@ -548,7 +594,10 @@ SPDX-License-Identifier: GPL-2.0-or-later
<!-- Title id / version / size badges, right next to the
title. The title's own MaxWidth (not a "*" column) is
what keeps them from drifting to the far right. -->
<StackPanel Grid.Column="1" x:Name="SelectedBadgesRow" Orientation="Horizontal" Spacing="6"
<StackPanel Grid.Column="1" x:Name="SelectedBadgesRow"
x:DataType="local:GameEntry"
x:CompileBindings="True"
Orientation="Horizontal" Spacing="6"
IsVisible="False" VerticalAlignment="Center">
<Border Classes="pill" IsVisible="{Binding HasTitleId, FallbackValue=False}">
<TextBlock Text="{Binding TitleId}" FontSize="10" FontWeight="SemiBold"
@@ -585,51 +634,8 @@ SPDX-License-Identifier: GPL-2.0-or-later
</Border>
</Grid>
<!-- Avalonia's regular overlay layer cannot appear over a native child
HWND/X11/Metal surface. Keep the running-session controls in a native
popup so the game reaches the bottom status bar without losing Stop. -->
<primitives:Popup x:Name="SessionBarPopup"
IsOpen="False"
PlacementTarget="{Binding #GameView}"
Placement="Bottom"
VerticalOffset="-66"
Topmost="True"
ShouldUseOverlayLayer="False"
TakesFocusFromNativeControl="False"
IsLightDismissEnabled="False">
<Border Classes="card" Width="598" Height="58" CornerRadius="16" Padding="14,8">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Spacing="3" VerticalAlignment="Center">
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock x:Name="SessionGameTitle" Text="GAME RUNNING" FontSize="13" FontWeight="SemiBold"
MaxWidth="240" TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
<Border Classes="badge running" VerticalAlignment="Center">
<TextBlock Text="RUNNING" FontSize="9" FontWeight="Bold" LetterSpacing="1"
Foreground="{StaticResource SuccessBrush}" />
</Border>
</StackPanel>
<StackPanel Orientation="Horizontal" Spacing="7">
<Border x:Name="SessionF11Badge" Classes="badge key" VerticalAlignment="Center">
<TextBlock Text="F11" FontSize="9" FontWeight="Bold"
Foreground="{StaticResource InfoBrush}" />
</Border>
<TextBlock x:Name="SessionHintText" Text="Fullscreen" FontSize="11"
Foreground="{StaticResource MutedBrush}" VerticalAlignment="Center" />
</StackPanel>
</StackPanel>
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8" VerticalAlignment="Center">
<Button x:Name="SessionConsoleButton" Classes="ghost" Content="≡ Console" />
<Button x:Name="SessionStopButton" Classes="danger" Content="■ Stop" IsEnabled="False" />
</StackPanel>
</Grid>
</Border>
</primitives:Popup>
<!-- This is a native popup rather than an Avalonia overlay because the
emulated Vulkan surface is a native child window. -->
<!-- Anchored to MainContent, not GameView: the surface host is parked in
a 1x1 corner while loading/closing, which would pull a GameView-
anchored popup into the corner with it. -->
<!-- Keep launch progress above the blurred library while the SDL game
process owns its independent top-level window. -->
<primitives:Popup x:Name="SessionLoadingPopup"
IsOpen="False"
PlacementTarget="{Binding #MainContent}"
@@ -643,7 +649,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
<TextBlock x:Name="SessionLoadingTitle" Text="Loading game" FontSize="16" FontWeight="SemiBold" />
<TextBlock x:Name="SessionLoadingDetail" Text="Preparing the emulation session..." FontSize="12"
Foreground="{StaticResource MutedBrush}" TextTrimming="CharacterEllipsis" />
<ProgressBar IsIndeterminate="True" Height="5" />
<ProgressBar x:Name="SessionLoadingProgress" IsIndeterminate="True" Height="5" />
</StackPanel>
</Border>
</primitives:Popup>
+274 -284
View File
@@ -5,6 +5,7 @@ using Avalonia;
using Avalonia.Collections;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Input.Platform;
using Avalonia.Interactivity;
using Avalonia.Media;
using Avalonia.Media.Imaging;
@@ -15,7 +16,7 @@ using Avalonia.VisualTree;
using SharpEmu.Core.Cpu;
using SharpEmu.Core.Runtime;
using SharpEmu.HLE.Host;
using SharpEmu.HLE.Host.Windows;
using SharpEmu.Libs.Pad;
using SharpEmu.Libs.VideoOut;
using SharpEmu.Logging;
using System.Collections.Concurrent;
@@ -61,18 +62,17 @@ public partial class MainWindow : Window
private bool _clearLibraryBlurWhenComplete;
private GuiSettings _settings = new();
private IReadOnlyList<HostDisplayOption> _hostDisplays = [];
private bool _updatingHostDisplayOptions;
private EmulatorProcess? _emulator;
private GameSurfaceHost? _gameSurfaceHost;
private ConsoleWindow? _consoleWindow;
private GuiConsoleMirror? _consoleMirror;
private StreamWriter? _fileLog;
private readonly SndPreviewPlayer _sndPreview = new();
private string? _emulatorExePath;
private PendingLaunch? _pendingLaunch;
private bool _gameFullscreen;
private bool _isRunning;
private bool _isStopping;
private bool _awaitingFirstFrame;
private int _autoScrollTicks;
private int _activePageIndex;
private Updater.UpdateInfo? _availableUpdate;
@@ -113,7 +113,7 @@ public partial class MainWindow : Window
string EbootPath,
string DisplayName,
string? TitleId,
string LogLevel,
EffectiveLaunchSettings Settings,
SharpEmuRuntimeOptions RuntimeOptions);
public MainWindow()
@@ -159,12 +159,10 @@ public partial class MainWindow : Window
// follow the launcher into the background or a minimized state.
Activated += (_, _) =>
{
UpdateSessionBarVisibility();
SessionLoadingPopup.IsOpen = _sessionLoadingActive;
};
Deactivated += (_, _) =>
{
SessionBarPopup.IsOpen = false;
SessionLoadingPopup.IsOpen = false;
};
@@ -180,8 +178,6 @@ public partial class MainWindow : Window
LaunchButton.Click += (_, _) => LaunchSelected();
ClearLogButton.Click += (_, _) => { _consoleLines.Clear(); _allConsoleLines.Clear(); };
StopButton.Click += (_, _) => StopEmulator();
SessionStopButton.Click += (_, _) => StopEmulator();
SessionConsoleButton.Click += (_, _) => ShowConsoleWindow();
CopyLogButton.Click += async (_, _) => await CopyConsoleAsync();
DetachConsoleButton.Click += (_, _) => ShowConsoleWindow();
LibraryTabButton.Click += (_, _) => SetActivePage(0);
@@ -220,6 +216,13 @@ public partial class MainWindow : Window
};
AutoUpdateToggle.IsCheckedChanged += (_, _) =>
_settings.CheckForUpdatesOnStartup = AutoUpdateToggle.IsChecked == true;
WindowModeBox.SelectionChanged += (_, _) => _settings.WindowMode = SelectedComboText(WindowModeBox, "Windowed");
DisplayBox.SelectionChanged += (_, _) => OnHostDisplayChanged();
ResolutionBox.SelectionChanged += (_, _) => OnHostResolutionChanged();
RefreshRateBox.SelectionChanged += (_, _) => OnHostRefreshRateChanged();
ScalingModeBox.SelectionChanged += (_, _) => _settings.ScalingMode = SelectedComboText(ScalingModeBox, "Fit");
VSyncToggle.IsCheckedChanged += (_, _) => _settings.VSync = VSyncToggle.IsChecked == true;
HdrModeBox.SelectionChanged += (_, _) => _settings.HdrMode = SelectedComboText(HdrModeBox, "Auto");
UpdateButton.Click += async (_, _) => await OnUpdateButtonAsync();
SelectLogFilePathButton.Click += async (_, _) => await SelectLogFilePathAsync();
EnvBthidToggle.IsCheckedChanged += (_, _) =>
@@ -238,6 +241,8 @@ public partial class MainWindow : Window
SetEnvironmentToggle("SHARPEMU_LOG_IO", EnvLogIoToggle.IsChecked == true);
EnvLogNpToggle.IsCheckedChanged += (_, _) =>
SetEnvironmentToggle("SHARPEMU_LOG_NP", EnvLogNpToggle.IsChecked == true);
EnvGuestImageCpuSyncToggle.IsCheckedChanged += (_, _) =>
SetGuestImageCpuSync(EnvGuestImageCpuSyncToggle.IsChecked == true);
LanguageBox.SelectionChanged += (_, _) => OnLanguageChanged();
GameList.AddHandler(ContextRequestedEvent, OnGameContextRequested, RoutingStrategies.Tunnel);
@@ -254,8 +259,7 @@ public partial class MainWindow : Window
Opened += async (_, _) => await OnOpenedAsync();
Closing += (_, _) => OnWindowClosing();
WindowsDualSenseReader.EnsureStarted();
WindowsXInputReader.EnsureStarted();
SdlLauncherGamepad.EnsureStarted();
_gamepadTimer = new DispatcherTimer
{
Interval = TimeSpan.FromMilliseconds(50),
@@ -426,8 +430,7 @@ public partial class MainWindow : Window
private void PollGamepad()
{
// DualSense wins when both are connected; XInput covers Xbox pads.
if (!WindowsDualSenseReader.TryGetState(out var pad) && !WindowsXInputReader.TryGetState(out pad))
if (!SdlLauncherGamepad.TryGetState(out var pad))
{
_previousPadButtons = HostGamepadButtons.None;
return;
@@ -443,9 +446,8 @@ public partial class MainWindow : Window
if (_isRunning || _isStopping)
{
// The game renders inside the launcher window, so the launcher
// stays active while playing. The controller belongs to the game
// then: no navigation, and Circle/B must never stop the session.
// The controller belongs to the separate game window while a
// session is active; Circle/B must never stop the session.
_previousPadButtons = pad.Buttons;
return;
}
@@ -591,7 +593,7 @@ public partial class MainWindow : Window
private void OnLanguageChanged()
{
if (LanguageBox.SelectedItem is not Localization.LanguageInfo language)
if (LanguageBox.SelectedItem is not LanguageInfo language)
{
return;
}
@@ -613,7 +615,7 @@ public partial class MainWindow : Window
LibraryTabButton.Content = loc.Get("Page.Library");
OptionsTabButton.Content = loc.Get("Page.Options");
SearchBox.Watermark = loc.Get("Library.SearchWatermark");
SearchBox.PlaceholderText = loc.Get("Library.SearchWatermark");
AddFolderButton.Content = loc.Get("Library.AddFolder");
RescanButton.Content = loc.Get("Library.Rescan");
OpenFileButton.Content = loc.Get("Library.OpenFile");
@@ -684,14 +686,32 @@ public partial class MainWindow : Window
AutoUpdateRow.Label = loc.Get("Updater.Auto.Label");
AutoUpdateRow.Description = loc.Get("Updater.Auto.Desc");
foreach (var toggle in new[] { StrictToggle, LogToFileToggle, OverrideLogFileToggle, TitleMusicToggle, DiscordToggle, AutoUpdateToggle })
GraphicsTabItem.Header = loc.Get("Options.Graphics");
DisplaySectionTitle.Text = loc.Get("Options.Section.Display");
WindowModeRow.Label = loc.Get("Options.WindowMode.Label");
WindowModeRow.Description = loc.Get("Options.WindowMode.Desc");
ResolutionRow.Label = loc.Get("Options.Resolution.Label");
ResolutionRow.Description = loc.Get("Options.Resolution.Desc");
DisplayRow.Label = loc.Get("Options.Display.Label");
DisplayRow.Description = loc.Get("Options.Display.Desc");
RefreshRateRow.Label = loc.Get("Options.RefreshRate.Label");
RefreshRateRow.Description = loc.Get("Options.RefreshRate.Desc");
ScalingRow.Label = loc.Get("Options.Scaling.Label");
ScalingRow.Description = loc.Get("Options.Scaling.Desc");
VSyncRow.Label = loc.Get("Options.VSync.Label");
VSyncRow.Description = loc.Get("Options.VSync.Desc");
HdrRow.Label = loc.Get("Options.Hdr.Label");
HdrRow.Description = loc.Get("Options.Hdr.Desc");
RefreshHostRefreshRates(_settings.RefreshRate);
foreach (var toggle in new[] { StrictToggle, LogToFileToggle, OverrideLogFileToggle, TitleMusicToggle, DiscordToggle, AutoUpdateToggle, VSyncToggle })
{
toggle.OnContent = loc.Get("Common.On");
toggle.OffContent = loc.Get("Common.Off");
}
ConsoleSectionTitle.Text = loc.Get("Console.Title");
ConsoleSearchBox.Watermark = loc.Get("Console.SearchWatermark");
ConsoleSearchBox.PlaceholderText = loc.Get("Console.SearchWatermark");
AutoScrollCheck.Content = loc.Get("Console.AutoScroll");
DetachConsoleButton.Content = loc.Get("Console.Split");
CopyLogButton.Content = loc.Get("Console.Copy");
@@ -757,91 +777,21 @@ public partial class MainWindow : Window
private void OnKeyDown(object sender, KeyEventArgs args)
{
args.Handled = true;
switch (args.Key)
if (args.Key == Key.F11 && !_isRunning)
{
case Key.F11:
OnWindowFullScreen(this, new RoutedEventArgs());
break;
default:
args.Handled = false;
break;
WindowState = WindowState == WindowState.FullScreen
? WindowState.Maximized
: WindowState.FullScreen;
args.Handled = true;
}
}
private void OnPreviewKeyDown(object? sender, KeyEventArgs args)
{
// While a session is on screen, Enter and Space are game input
// (Cross button). Keyboard focus stays on the launcher window, so a
// previously clicked, still-focused button (console toggle, session
// bar) would also activate and reshape the game view. Swallow the
// keys before button activation; the emulator process reads raw key
// state and is unaffected. Fullscreen hides those buttons, which is
// why this only manifested in windowed sessions.
if (_isRunning && GameView.IsVisible &&
args.Key is Key.Enter or Key.Space)
{
args.Handled = true;
}
}
private void OnWindowFullScreen(object sender, RoutedEventArgs args)
{
if (WindowState == WindowState.FullScreen)
{
// Leaving F11 should restore a monitor-sized window with the
// launcher chrome, not fall back to the design-time window size.
WindowState = WindowState.Maximized;
ExtendClientAreaChromeHints = ExtendClientAreaChromeHints.PreferSystemChrome;
TitleBar.IsVisible = true;
StatusBar.IsVisible = true;
if (_gameFullscreen)
{
_gameFullscreen = false;
Grid.SetRow(MainContent, 1);
Grid.SetRowSpan(MainContent, 1);
MainContent.Margin = _isRunning
? new Thickness(0)
: new Thickness(32, 24, 32, 20);
ContentToolbar.IsVisible = !_isRunning;
ConsolePanel.IsVisible = ConsoleToggle.IsChecked == true && _consoleWindow is null;
LaunchBar.IsVisible = true;
QueueGameSurfaceResize();
UpdateSessionBarVisibility();
}
}
else
{
WindowState = WindowState.FullScreen;
ExtendClientAreaChromeHints = ExtendClientAreaChromeHints.NoChrome;
TitleBar.IsVisible = false;
StatusBar.IsVisible = false;
if (_isRunning && !_isStopping && !_awaitingFirstFrame && GameView.IsVisible)
{
// The native child receives its new physical Bounds as soon
// as this grid spans the monitor. The presenter recreates its
// swapchain from that size, rather than stretching 720p.
_gameFullscreen = true;
// Re-arming restarts the idle countdown, so the cursor also
// hides a moment after F11 even without further mouse motion.
_gameSurfaceHost?.SetCursorAutoHide(true);
Grid.SetRow(MainContent, 0);
Grid.SetRowSpan(MainContent, 3);
MainContent.Margin = new Thickness(0);
ContentToolbar.IsVisible = false;
ConsolePanel.IsVisible = false;
LaunchBar.IsVisible = false;
QueueGameSurfaceResize();
UpdateSessionBarVisibility();
}
}
}
private void QueueGameSurfaceResize()
{
Dispatcher.UIThread.Post(
() => _gameSurfaceHost?.RefreshSurfaceSize(),
DispatcherPriority.Render);
// The session runs in its own SDL window and takes keyboard focus with
// it, so launcher buttons no longer see game input and nothing has to
// be swallowed here. Kept as the wired handler because the launcher
// still needs a preview hook for its own shortcuts.
}
private void OnWindowClosing()
@@ -850,6 +800,7 @@ public partial class MainWindow : Window
_consoleFlushTimer.Stop();
_libraryBlurTimer.Stop();
_gamepadTimer.Stop();
SdlLauncherGamepad.Shutdown();
_sndPreview.Stop();
_discord?.Dispose();
_consoleWindow?.Close();
@@ -902,9 +853,143 @@ public partial class MainWindow : Window
EnvLogDirectMemoryToggle.IsChecked = _settings.EnvironmentToggles.Contains("SHARPEMU_LOG_DIRECT_MEMORY");
EnvLogIoToggle.IsChecked = _settings.EnvironmentToggles.Contains("SHARPEMU_LOG_IO");
EnvLogNpToggle.IsChecked = _settings.EnvironmentToggles.Contains("SHARPEMU_LOG_NP");
EnvGuestImageCpuSyncToggle.IsChecked = IsEnvironmentEnabled(
_settings.EnvironmentToggles,
"SHARPEMU_GUEST_IMAGE_CPU_SYNC",
defaultValue: true);
WindowModeBox.SelectedIndex = ChoiceIndex(_settings.WindowMode, "Windowed", "Borderless", "Exclusive");
LoadHostDisplayOptions();
ScalingModeBox.SelectedIndex = ChoiceIndex(_settings.ScalingMode, "Fit", "Cover", "Stretch", "Integer");
VSyncToggle.IsChecked = _settings.VSync;
HdrModeBox.SelectedIndex = ChoiceIndex(_settings.HdrMode, "Auto", "On", "Off");
UpdateLogFilePathText();
}
private static string SelectedComboText(ComboBox comboBox, string fallback) =>
comboBox.SelectedItem switch
{
ComboBoxItem item => item.Content?.ToString() ?? fallback,
string value => value,
_ => fallback,
};
private void LoadHostDisplayOptions()
{
_updatingHostDisplayOptions = true;
try
{
_hostDisplays = HostDisplayOptions.BuildDisplays(
HostDisplayCatalog.Query(),
_settings.DisplayIndex);
DisplayBox.ItemsSource = _hostDisplays;
var display = HostDisplayOptions.SelectDisplay(_hostDisplays, _settings.DisplayIndex);
DisplayBox.SelectedItem = display;
PopulateHostModes(display, _settings.Resolution, _settings.RefreshRate);
}
finally
{
_updatingHostDisplayOptions = false;
}
SyncHostVideoSettings();
}
private void OnHostDisplayChanged()
{
if (_updatingHostDisplayOptions || DisplayBox.SelectedItem is not HostDisplayOption display)
{
return;
}
_updatingHostDisplayOptions = true;
try
{
PopulateHostModes(display, _settings.Resolution, _settings.RefreshRate);
}
finally
{
_updatingHostDisplayOptions = false;
}
SyncHostVideoSettings();
}
private void OnHostResolutionChanged()
{
if (_updatingHostDisplayOptions || DisplayBox.SelectedItem is not HostDisplayOption)
{
return;
}
_settings.Resolution = SelectedComboText(ResolutionBox, "1920x1080");
RefreshHostRefreshRates(_settings.RefreshRate);
OnHostRefreshRateChanged();
}
private void OnHostRefreshRateChanged()
{
if (!_updatingHostDisplayOptions && RefreshRateBox.SelectedItem is HostRefreshRateOption refreshRate)
{
_settings.RefreshRate = refreshRate.Value;
}
}
private void PopulateHostModes(
HostDisplayOption display,
string selectedResolution,
int selectedRefreshRate)
{
var resolutions = HostDisplayOptions.BuildResolutions(display, selectedResolution);
ResolutionBox.ItemsSource = resolutions;
ResolutionBox.SelectedItem = resolutions.FirstOrDefault(resolution =>
string.Equals(resolution, selectedResolution, StringComparison.OrdinalIgnoreCase)) ?? resolutions[0];
RefreshHostRefreshRates(selectedRefreshRate);
}
private void RefreshHostRefreshRates(int selectedRefreshRate)
{
if (DisplayBox.SelectedItem is not HostDisplayOption display)
{
return;
}
var wasUpdating = _updatingHostDisplayOptions;
_updatingHostDisplayOptions = true;
try
{
var rates = HostDisplayOptions.BuildRefreshRates(
display,
SelectedComboText(ResolutionBox, _settings.Resolution),
selectedRefreshRate,
Localization.Instance.Get("Options.RefreshRate.Automatic"));
RefreshRateBox.ItemsSource = rates;
RefreshRateBox.SelectedItem = rates.FirstOrDefault(rate => rate.Value == selectedRefreshRate) ?? rates[0];
}
finally
{
_updatingHostDisplayOptions = wasUpdating;
}
}
private void SyncHostVideoSettings()
{
if (DisplayBox.SelectedItem is HostDisplayOption display)
{
_settings.DisplayIndex = display.Index;
}
_settings.Resolution = SelectedComboText(ResolutionBox, "1920x1080");
_settings.RefreshRate = RefreshRateBox.SelectedItem is HostRefreshRateOption refreshRate
? refreshRate.Value
: 0;
}
private static int ChoiceIndex(string value, params string[] choices)
{
var index = Array.FindIndex(choices, choice => string.Equals(choice, value, StringComparison.OrdinalIgnoreCase));
return index < 0 ? 0 : index;
}
private async Task OnUpdateButtonAsync()
{
if (_availableUpdate is null)
@@ -998,6 +1083,40 @@ public partial class MainWindow : Window
}
}
private void SetGuestImageCpuSync(bool enabled)
{
const string name = "SHARPEMU_GUEST_IMAGE_CPU_SYNC";
_settings.EnvironmentToggles.RemoveAll(entry =>
string.Equals(entry, name, StringComparison.OrdinalIgnoreCase) ||
string.Equals(entry, name + "=0", StringComparison.OrdinalIgnoreCase));
if (!enabled)
{
_settings.EnvironmentToggles.Add(name + "=0");
}
}
private static bool IsEnvironmentEnabled(
IEnumerable<string> entries,
string name,
bool defaultValue)
{
foreach (var entry in entries)
{
if (string.Equals(entry, name + "=0", StringComparison.OrdinalIgnoreCase))
{
return false;
}
if (string.Equals(entry, name, StringComparison.OrdinalIgnoreCase) ||
string.Equals(entry, name + "=1", StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
return defaultValue;
}
private string SelectedLogLevel()
{
return LogLevelBox.SelectedIndex switch
@@ -1794,16 +1913,23 @@ public partial class MainWindow : Window
// launcher process so every platform receives the same launch options.
foreach (var staleName in _appliedEnvironmentVariables)
{
if (!effective.EnvironmentToggles.Contains(staleName))
if (!effective.EnvironmentToggles.Any(entry =>
TryParseEnvironmentEntry(entry, out var name, out _) &&
string.Equals(name, staleName, StringComparison.OrdinalIgnoreCase)))
{
Environment.SetEnvironmentVariable(staleName, null);
}
}
_appliedEnvironmentVariables.Clear();
foreach (var name in effective.EnvironmentToggles)
foreach (var entry in effective.EnvironmentToggles)
{
Environment.SetEnvironmentVariable(name, "1");
if (!TryParseEnvironmentEntry(entry, out var name, out var value))
{
continue;
}
Environment.SetEnvironmentVariable(name, value);
_appliedEnvironmentVariables.Add(name);
}
@@ -1827,7 +1953,6 @@ public partial class MainWindow : Window
_isRunning = true;
_runningGameName = displayName;
SessionGameTitle.Text = displayName;
_runningGameTitleId = resolvedTitleId;
_runningSinceUnixSeconds = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
StatusDot.Fill = SuccessLineBrush;
@@ -1836,18 +1961,25 @@ public partial class MainWindow : Window
UpdateRunButtons();
UpdateDiscordPresence();
ShowGameView();
BeginSessionUi();
_pendingLaunch = new PendingLaunch(
Path.GetFullPath(ebootPath),
displayName,
_runningGameTitleId,
effective.LogLevel,
effective,
runtimeOptions);
if (_gameSurfaceHost?.Surface is { } surface)
{
StartPendingSession(surface);
}
StartPendingSession();
}
private static bool TryParseEnvironmentEntry(string entry, out string name, out string value)
{
var separator = entry.IndexOf('=');
name = separator >= 0 ? entry[..separator] : entry;
value = separator >= 0 ? entry[(separator + 1)..] : "1";
return name.StartsWith("SHARPEMU_", StringComparison.OrdinalIgnoreCase) &&
name.Length > "SHARPEMU_".Length &&
value.Length != 0;
}
/// <summary>
@@ -1876,9 +2008,6 @@ public partial class MainWindow : Window
_isStopping = true;
StopButton.IsEnabled = false;
SessionStopButton.IsEnabled = false;
SessionHintText.Text = Localization.Instance.Get("Launch.Stopping");
SessionF11Badge.IsVisible = false;
ShowSessionLoading("Closing game", "Waiting for the emulation session to exit...");
_emulator.Stop();
_runningGameName = null;
@@ -1886,7 +2015,6 @@ public partial class MainWindow : Window
StatusText.Text = Localization.Instance.Get("Launch.Stopping");
StatusBarRight.Text = Localization.Instance.Get("Status.Stopping");
UpdateDiscordPresence();
UpdateSessionBarVisibility();
ReturnToLibraryWhileStopping();
}
@@ -1929,8 +2057,7 @@ public partial class MainWindow : Window
_emulator?.Dispose();
_emulator = null;
_pendingLaunch = null;
DisposeGameSurfaceHost();
HideGameView();
EndSessionUi();
var meaningKey = exitCode switch
{
@@ -1963,7 +2090,7 @@ public partial class MainWindow : Window
UpdateDiscordPresence();
}
private void StartPendingSession(VulkanHostSurface surface)
private void StartPendingSession()
{
if (_pendingLaunch is not { } launch || _emulator is not null)
{
@@ -1983,7 +2110,7 @@ public partial class MainWindow : Window
try
{
var arguments = BuildEmulatorArguments(launch, surface);
var arguments = BuildEmulatorArguments(launch);
_emulator = process;
_pendingLaunch = null;
process.Start(
@@ -2005,12 +2132,12 @@ public partial class MainWindow : Window
}
}
private List<string> BuildEmulatorArguments(PendingLaunch launch, VulkanHostSurface surface)
private List<string> BuildEmulatorArguments(PendingLaunch launch)
{
var arguments = new List<string>
{
"--cpu-engine=native",
$"--log-level={launch.LogLevel}",
$"--log-level={launch.Settings.LogLevel}",
};
if (launch.RuntimeOptions.StrictDynlibResolution)
{
@@ -2021,16 +2148,13 @@ public partial class MainWindow : Window
arguments.Add($"--trace-imports={launch.RuntimeOptions.ImportTraceLimit}");
}
if (surface.TryGetChildProcessDescriptor(out var descriptor))
{
arguments.Add($"--host-surface={descriptor}");
}
else
{
AppendConsoleLine(
"[GUI][WARN] Embedded child surfaces are unavailable on this platform; opening a game window instead.",
WarningLineBrush);
}
arguments.Add($"--window-mode={launch.Settings.WindowMode.ToLowerInvariant()}");
arguments.Add($"--resolution={launch.Settings.Resolution}");
arguments.Add($"--display={launch.Settings.DisplayIndex}");
arguments.Add($"--refresh-rate={launch.Settings.RefreshRate}");
arguments.Add($"--scaling={launch.Settings.ScalingMode.ToLowerInvariant()}");
arguments.Add($"--vsync={(launch.Settings.VSync ? "on" : "off")}");
arguments.Add($"--hdr={launch.Settings.HdrMode.ToLowerInvariant()}");
arguments.Add(launch.EbootPath);
return arguments;
@@ -2039,8 +2163,8 @@ public partial class MainWindow : Window
private void OnEmulatorOutput(string line, bool isError)
{
_pendingLines.Enqueue((line, isError));
if (!line.Contains("[VIDEOOUT][INFO] Hosted splash ready.", StringComparison.Ordinal) &&
!line.Contains("[VIDEOOUT][INFO] Hosted first frame presented.", StringComparison.Ordinal))
if (!line.Contains("Vulkan VideoOut presented first frame:", StringComparison.Ordinal) &&
!line.Contains("Vulkan VideoOut ready:", StringComparison.Ordinal))
{
return;
}
@@ -2049,143 +2173,25 @@ public partial class MainWindow : Window
{
if (_isRunning && !_isStopping)
{
_awaitingFirstFrame = false;
ClearLibraryBlur();
MainContent.Margin = new Thickness(0);
RestoreGameViewToFull();
GameView.Background = Brushes.Black;
GameView.IsHitTestVisible = true;
LibraryPage.IsVisible = false;
OptionsPage.IsVisible = false;
LibraryToolbar.IsVisible = false;
ContentToolbar.IsVisible = false;
ConsolePanel.IsVisible = false;
LaunchBar.IsVisible = false;
HideSessionLoading();
UpdateSessionBarVisibility();
// Defer so the layout pass from the margin change above settles first.
Dispatcher.UIThread.Post(() =>
{
if (!_isRunning || _isStopping)
{
return;
}
_gameSurfaceHost?.RefreshSurfaceSize();
_gameSurfaceHost?.SetPresentationVisible(true);
_gameSurfaceHost?.SetCursorAutoHide(true);
});
ShowSessionStatus("Game is running");
}
});
}
private GameSurfaceHost EnsureGameSurfaceHost()
{
if (_gameSurfaceHost is not null)
{
return _gameSurfaceHost;
}
var host = new GameSurfaceHost();
// Configure this before attaching it to Avalonia so its first native
// HWND is hidden while the child process starts.
host.SetPresentationVisible(false);
host.SurfaceAvailable += (_, surface) =>
{
if (ReferenceEquals(_gameSurfaceHost, host))
{
StartPendingSession(surface);
}
};
host.SurfaceDestroyed += (_, surface) => OnGameSurfaceDestroyed(host, surface);
_gameSurfaceHost = host;
GameSurfaceContainer.Children.Add(host);
return host;
}
private void DisposeGameSurfaceHost()
{
var host = _gameSurfaceHost;
if (host is null)
{
return;
}
_gameSurfaceHost = null;
host.SetPresentationVisible(false);
GameSurfaceContainer.Children.Remove(host);
}
private void OnGameSurfaceDestroyed(GameSurfaceHost host, VulkanHostSurface surface)
{
if (ReferenceEquals(_gameSurfaceHost, host) && _isRunning)
{
StopEmulator();
}
}
/// <summary>
/// The native host attachment is a real child window: it sits above every
/// Avalonia control it covers and swallows their mouse input regardless of
/// hit-test settings. While the library must stay interactive (loading,
/// closing), the surface is parked offscreen AT FULL SIZE via a negative
/// margin. It must not be shrunk instead: the emulator child polls the
/// HWND client size and its presenter defers swapchain creation while the
/// surface is 1px, which would deadlock the loading handshake.
/// </summary>
private void ParkGameViewOffscreen()
{
GameView.Margin = new Thickness(-20000, 0, 20000, 0);
}
private void RestoreGameViewToFull()
{
GameView.Margin = new Thickness(0);
}
private void ShowGameView()
private void BeginSessionUi()
{
_isStopping = false;
_awaitingFirstFrame = true;
var host = EnsureGameSurfaceHost();
ParkGameViewOffscreen();
GameView.IsVisible = true;
GameView.Background = Brushes.Transparent;
GameView.IsHitTestVisible = false;
host.SetPresentationVisible(false);
AnimateLibraryBlur(LaunchBlurRadius);
SessionHintText.Text = "Fullscreen";
SessionF11Badge.IsVisible = true;
UpdateSessionBarVisibility();
ShowSessionLoading("Loading game", "Preparing the emulation session...");
LaunchBar.IsVisible = true;
}
private void HideGameView()
private void EndSessionUi()
{
if (_gameFullscreen && WindowState == WindowState.FullScreen)
{
OnWindowFullScreen(this, new RoutedEventArgs());
}
_gameSurfaceHost?.SetCursorAutoHide(false);
_gameSurfaceHost?.SetPresentationVisible(false);
_awaitingFirstFrame = false;
GameView.IsVisible = false;
GameView.IsHitTestVisible = true;
SessionBarPopup.IsOpen = false;
HideSessionLoading();
AnimateLibraryBlur(0, clearWhenComplete: true);
MainContent.Margin = new Thickness(32, 24, 32, 20);
ContentToolbar.IsVisible = true;
ConsolePanel.IsVisible = ConsoleToggle.IsChecked == true && _consoleWindow is null;
LaunchBar.IsVisible = true;
LibraryPage.IsVisible = _activePageIndex == 0;
LibraryToolbar.IsVisible = _activePageIndex == 0;
OptionsPage.IsVisible = _activePageIndex == 1;
// Game art when the source still holds it, otherwise the bundled
// default; a bare color only when neither is available.
BackdropImage.Opacity = BackdropImage.Source is not null ? 1 : 0;
ConsolePanel.IsVisible = ConsoleToggle.IsChecked == true && _consoleWindow is null;
}
private void AnimateLibraryBlur(double targetRadius, bool clearWhenComplete = false)
@@ -2257,7 +2263,20 @@ public partial class MainWindow : Window
private void ShowSessionLoading(string title, string detail)
{
SessionLoadingTitle.Text = title;
SessionLoadingTitle.IsVisible = true;
SessionLoadingDetail.Text = detail;
SessionLoadingDetail.IsVisible = true;
SessionLoadingProgress.IsVisible = true;
_sessionLoadingActive = true;
SessionLoadingPopup.IsOpen = IsActive && WindowState != WindowState.Minimized;
}
private void ShowSessionStatus(string message)
{
SessionLoadingTitle.Text = message;
SessionLoadingTitle.IsVisible = true;
SessionLoadingDetail.IsVisible = false;
SessionLoadingProgress.IsVisible = false;
_sessionLoadingActive = true;
SessionLoadingPopup.IsOpen = IsActive && WindowState != WindowState.Minimized;
}
@@ -2270,33 +2289,11 @@ public partial class MainWindow : Window
private void ReturnToLibraryWhileStopping()
{
if (_gameFullscreen && WindowState == WindowState.FullScreen)
{
OnWindowFullScreen(this, new RoutedEventArgs());
}
// Keep the native child alive until the session exits, but hide it
// immediately. Destroying it while Vulkan still owns the surface can
// crash the GUI; parking it in the 1x1 corner lets the library
// recover — and stay clickable — while the native closing popup
// reports teardown progress.
_gameSurfaceHost?.SetPresentationVisible(false);
_awaitingFirstFrame = false;
ParkGameViewOffscreen();
GameView.Background = Brushes.Transparent;
GameView.IsHitTestVisible = false;
SessionBarPopup.IsOpen = false;
AnimateLibraryBlur(LaunchBlurRadius);
MainContent.Margin = new Thickness(32, 24, 32, 20);
ContentToolbar.IsVisible = true;
ConsolePanel.IsVisible = ConsoleToggle.IsChecked == true && _consoleWindow is null;
LaunchBar.IsVisible = true;
LibraryPage.IsVisible = _activePageIndex == 0;
LibraryToolbar.IsVisible = _activePageIndex == 0;
OptionsPage.IsVisible = _activePageIndex == 1;
BackdropImage.Opacity = BackdropImage.Source is not null ? 1 : 0;
UpdateRunButtons();
Console.Error.WriteLine("[GUI][INFO] Library restored while embedded session is closing.");
Console.Error.WriteLine("[GUI][INFO] Waiting for the SDL game process to exit.");
}
private void OpenFileLog(string? titleId)
@@ -2355,16 +2352,9 @@ public partial class MainWindow : Window
{
LaunchButton.IsEnabled = !_isRunning && GameList.SelectedItem is GameEntry;
StopButton.IsEnabled = _isRunning && !_isStopping;
SessionStopButton.IsEnabled = _isRunning && !_isStopping;
OpenFileButton.IsEnabled = !_isRunning;
}
private void UpdateSessionBarVisibility()
{
SessionBarPopup.IsOpen = _isRunning && !_isStopping && !_awaitingFirstFrame && GameView.IsVisible &&
!_gameFullscreen && WindowState != WindowState.FullScreen;
}
// ---- Console ----
private void FlushPendingConsoleLines()
+35
View File
@@ -21,6 +21,20 @@ public sealed class PerGameSettings
public bool? LogToFile { get; set; }
public string? WindowMode { get; set; }
public string? Resolution { get; set; }
public int? DisplayIndex { get; set; }
public int? RefreshRate { get; set; }
public string? ScalingMode { get; set; }
public bool? VSync { get; set; }
public string? HdrMode { get; set; }
public List<string>? EnvironmentToggles { get; set; }
[JsonIgnore]
@@ -29,6 +43,13 @@ public sealed class PerGameSettings
ImportTraceLimit is null &&
StrictDynlibResolution is null &&
LogToFile is null &&
WindowMode is null &&
Resolution is null &&
DisplayIndex is null &&
RefreshRate is null &&
ScalingMode is null &&
VSync is null &&
HdrMode is null &&
EnvironmentToggles is null;
public static string DirectoryPath =>
@@ -116,6 +137,13 @@ public sealed record EffectiveLaunchSettings(
int ImportTraceLimit,
bool StrictDynlibResolution,
bool LogToFile,
string WindowMode,
string Resolution,
int DisplayIndex,
int RefreshRate,
string ScalingMode,
bool VSync,
string HdrMode,
IReadOnlyList<string> EnvironmentToggles)
{
public static EffectiveLaunchSettings Resolve(GuiSettings global, PerGameSettings? perGame) => new(
@@ -123,5 +151,12 @@ public sealed record EffectiveLaunchSettings(
perGame?.ImportTraceLimit ?? global.ImportTraceLimit,
perGame?.StrictDynlibResolution ?? global.StrictDynlibResolution,
perGame?.LogToFile ?? global.LogToFile,
perGame?.WindowMode ?? global.WindowMode,
perGame?.Resolution ?? global.Resolution,
Math.Max(0, perGame?.DisplayIndex ?? global.DisplayIndex),
Math.Clamp(perGame?.RefreshRate ?? global.RefreshRate, 0, 1000),
perGame?.ScalingMode ?? global.ScalingMode,
perGame?.VSync ?? global.VSync,
perGame?.HdrMode ?? global.HdrMode,
perGame?.EnvironmentToggles ?? global.EnvironmentToggles);
}
+238 -10
View File
@@ -5,6 +5,7 @@ using Avalonia;
using Avalonia.Controls;
using Avalonia.Layout;
using Avalonia.Media;
using SharpEmu.Libs.VideoOut;
namespace SharpEmu.GUI;
@@ -12,6 +13,9 @@ public sealed class PerGameSettingsDialog : Window
{
private static readonly string[] LogLevels =
{ "Trace", "Debug", "Info", "Warning", "Error", "Critical" };
private static readonly string[] WindowModes = { "Windowed", "Borderless", "Exclusive" };
private static readonly string[] ScalingModes = { "Fit", "Cover", "Stretch", "Integer" };
private static readonly string[] HdrModes = { "Auto", "On", "Off" };
private static readonly string[] EnvToggles =
{
@@ -23,9 +27,12 @@ public sealed class PerGameSettingsDialog : Window
"SHARPEMU_LOG_DIRECT_MEMORY",
"SHARPEMU_LOG_IO",
"SHARPEMU_LOG_NP",
"SHARPEMU_GUEST_IMAGE_CPU_SYNC",
};
private readonly string _titleId;
private IReadOnlyList<HostDisplayOption> _hostDisplays = [];
private bool _updatingHostDisplayOptions;
private readonly SettingRow _logLevelRow;
private readonly ComboBox _logLevel = new() { ItemsSource = LogLevels, Width = 160 };
@@ -42,6 +49,27 @@ public sealed class PerGameSettingsDialog : Window
private readonly SettingRow _logToFileRow;
private readonly ToggleSwitch _logToFile = new();
private readonly SettingRow _windowModeRow;
private readonly ComboBox _windowMode = new() { ItemsSource = WindowModes, Width = 160 };
private readonly SettingRow _resolutionRow;
private readonly ComboBox _resolution = new() { Width = 160 };
private readonly SettingRow _displayIndexRow;
private readonly ComboBox _displayIndex = new() { Width = 240 };
private readonly SettingRow _refreshRateRow;
private readonly ComboBox _refreshRate = new() { Width = 160 };
private readonly SettingRow _scalingModeRow;
private readonly ComboBox _scalingMode = new() { ItemsSource = ScalingModes, Width = 160 };
private readonly SettingRow _vsyncRow;
private readonly ToggleSwitch _vsync = new();
private readonly SettingRow _hdrModeRow;
private readonly ComboBox _hdrMode = new() { ItemsSource = HdrModes, Width = 160 };
private readonly SettingRow _envRow;
private readonly StackPanel _envList = new() { Orientation = Orientation.Vertical, Spacing = 8, Margin = new(0, 4, 0, 0) };
private readonly List<(string Name, ToggleSwitch Box)> _envBoxes = new();
@@ -60,13 +88,20 @@ public sealed class PerGameSettingsDialog : Window
Background = new SolidColorBrush(Color.Parse("#0D1017"));
_strict.OnContent = _logToFile.OnContent = loc.Get("Common.On");
_strict.OffContent = _logToFile.OffContent = loc.Get("Common.Off");
_strict.OnContent = _logToFile.OnContent = _vsync.OnContent = loc.Get("Common.On");
_strict.OffContent = _logToFile.OffContent = _vsync.OffContent = loc.Get("Common.Off");
_logLevelRow = Row(loc.Get("Options.LogLevel.Label"), loc.Get("Options.LogLevel.Desc"), _logLevel);
_traceRow = Row(loc.Get("Options.TraceImports.Label"), loc.Get("Options.TraceImports.Desc"), _trace);
_strictRow = Row(loc.Get("Options.Strict.Label"), loc.Get("Options.Strict.Desc"), _strict);
_logToFileRow = Row(loc.Get("Options.LogToFile.Label"), loc.Get("Options.LogToFile.Desc"), _logToFile);
_windowModeRow = Row(loc.Get("Options.WindowMode.Label"), loc.Get("Options.WindowMode.Desc"), _windowMode);
_resolutionRow = Row(loc.Get("Options.Resolution.Label"), loc.Get("Options.Resolution.Desc"), _resolution);
_displayIndexRow = Row(loc.Get("Options.Display.Label"), loc.Get("Options.Display.Desc"), _displayIndex);
_refreshRateRow = Row(loc.Get("Options.RefreshRate.Label"), loc.Get("Options.RefreshRate.Desc"), _refreshRate);
_scalingModeRow = Row(loc.Get("Options.Scaling.Label"), loc.Get("Options.Scaling.Desc"), _scalingMode);
_vsyncRow = Row(loc.Get("Options.VSync.Label"), loc.Get("Options.VSync.Desc"), _vsync);
_hdrModeRow = Row(loc.Get("Options.Hdr.Label"), loc.Get("Options.Hdr.Desc"), _hdrMode);
_envRow = new SettingRow
{
Label = loc.Get("PerGame.EnvToggles.Label"),
@@ -81,6 +116,22 @@ public sealed class PerGameSettingsDialog : Window
_envList.Children.Add(box);
}
var general = new StackPanel { Orientation = Orientation.Vertical, Spacing = 12, Margin = new(0, 12, 0, 0) };
general.Children.Add(Card(loc.Get("Options.Section.Emulation"), _strictRow));
general.Children.Add(Card(loc.Get("Options.Section.Logging"), _logLevelRow, _traceRow, _logToFileRow));
general.Children.Add(Card(loc.Get("Options.Section.Environment"), _envRow, _envList));
var graphics = new StackPanel { Orientation = Orientation.Vertical, Spacing = 12, Margin = new(0, 12, 0, 0) };
graphics.Children.Add(Card(
loc.Get("Options.Section.Display"),
_windowModeRow,
_resolutionRow,
_displayIndexRow,
_refreshRateRow,
_scalingModeRow,
_vsyncRow,
_hdrModeRow));
var content = new StackPanel { Orientation = Orientation.Vertical, Spacing = 12, Margin = new(16) };
content.Children.Add(new TextBlock
{
@@ -88,9 +139,14 @@ public sealed class PerGameSettingsDialog : Window
Foreground = new SolidColorBrush(Color.Parse("#8B94A7")),
FontSize = 12,
});
content.Children.Add(Card(loc.Get("Options.Section.Emulation"), _strictRow));
content.Children.Add(Card(loc.Get("Options.Section.Logging"), _logLevelRow, _traceRow, _logToFileRow));
content.Children.Add(Card(loc.Get("Options.Section.Environment"), _envRow, _envList));
content.Children.Add(new TabControl
{
ItemsSource = new[]
{
new TabItem { Header = loc.Get("PerGame.Tab.General"), Content = general },
new TabItem { Header = loc.Get("PerGame.Tab.Graphics"), Content = graphics },
},
});
var save = new Button { Content = loc.Get("Common.Save"), Classes = { "accent" } };
var cancel = new Button { Content = loc.Get("Common.Cancel"), Classes = { "ghost" } };
@@ -119,6 +175,8 @@ public sealed class PerGameSettingsDialog : Window
root.Children.Add(buttonBar);
Content = root;
_displayIndex.SelectionChanged += (_, _) => OnHostDisplayChanged();
_resolution.SelectionChanged += (_, _) => OnHostResolutionChanged();
LoadValues(global);
_envRow.PropertyChanged += (_, e) =>
{
@@ -154,16 +212,38 @@ public sealed class PerGameSettingsDialog : Window
private void LoadValues(GuiSettings global)
{
var existing = PerGameSettings.Load(_titleId);
var displayIndex = Math.Max(0, existing?.DisplayIndex ?? global.DisplayIndex);
var resolution = existing?.Resolution ?? global.Resolution;
var refreshRate = Math.Clamp(existing?.RefreshRate ?? global.RefreshRate, 0, 1000);
_updatingHostDisplayOptions = true;
try
{
_hostDisplays = HostDisplayOptions.BuildDisplays(HostDisplayCatalog.Query(), displayIndex);
_displayIndex.ItemsSource = _hostDisplays;
var display = HostDisplayOptions.SelectDisplay(_hostDisplays, displayIndex);
_displayIndex.SelectedItem = display;
PopulateHostModes(display, resolution, refreshRate);
}
finally
{
_updatingHostDisplayOptions = false;
}
_logLevel.SelectedItem = Array.IndexOf(LogLevels, global.LogLevel) >= 0 ? global.LogLevel : "Info";
_trace.Value = global.ImportTraceLimit;
_strict.IsChecked = global.StrictDynlibResolution;
_logToFile.IsChecked = global.LogToFile;
_windowMode.SelectedItem = ChoiceOrDefault(WindowModes, global.WindowMode, "Windowed");
_scalingMode.SelectedItem = ChoiceOrDefault(ScalingModes, global.ScalingMode, "Fit");
_vsync.IsChecked = global.VSync;
_hdrMode.SelectedItem = ChoiceOrDefault(HdrModes, global.HdrMode, "Auto");
foreach (var (name, box) in _envBoxes)
{
box.IsChecked = global.EnvironmentToggles.Contains(name);
box.IsChecked = IsEnvironmentEnabled(global.EnvironmentToggles, name, defaultValue: name == "SHARPEMU_GUEST_IMAGE_CPU_SYNC");
}
var existing = PerGameSettings.Load(_titleId);
if (existing is null)
{
return;
@@ -178,16 +258,120 @@ public sealed class PerGameSettingsDialog : Window
if (existing.ImportTraceLimit is { } t) { _traceRow.IsOverridden = true; _trace.Value = t; }
if (existing.StrictDynlibResolution is { } s) { _strictRow.IsOverridden = true; _strict.IsChecked = s; }
if (existing.LogToFile is { } l) { _logToFileRow.IsOverridden = true; _logToFile.IsChecked = l; }
if (existing.WindowMode is { } windowMode && WindowModes.Contains(windowMode, StringComparer.OrdinalIgnoreCase))
{
_windowModeRow.IsOverridden = true;
_windowMode.SelectedItem = ChoiceOrDefault(WindowModes, windowMode, "Windowed");
}
if (existing.Resolution is not null)
{
_resolutionRow.IsOverridden = true;
}
if (existing.DisplayIndex is not null)
{
_displayIndexRow.IsOverridden = true;
}
if (existing.RefreshRate is not null)
{
_refreshRateRow.IsOverridden = true;
}
if (existing.ScalingMode is { } scalingMode && ScalingModes.Contains(scalingMode, StringComparer.OrdinalIgnoreCase))
{
_scalingModeRow.IsOverridden = true;
_scalingMode.SelectedItem = ChoiceOrDefault(ScalingModes, scalingMode, "Fit");
}
if (existing.VSync is { } vsync)
{
_vsyncRow.IsOverridden = true;
_vsync.IsChecked = vsync;
}
if (existing.HdrMode is { } hdrMode && HdrModes.Contains(hdrMode, StringComparer.OrdinalIgnoreCase))
{
_hdrModeRow.IsOverridden = true;
_hdrMode.SelectedItem = ChoiceOrDefault(HdrModes, hdrMode, "Auto");
}
if (existing.EnvironmentToggles is { } env)
{
_envRow.IsOverridden = true;
foreach (var (name, box) in _envBoxes)
{
box.IsChecked = env.Contains(name);
box.IsChecked = IsEnvironmentEnabled(env, name, defaultValue: name == "SHARPEMU_GUEST_IMAGE_CPU_SYNC");
}
}
}
private static string ChoiceOrDefault(string[] choices, string? value, string fallback) =>
choices.FirstOrDefault(choice => string.Equals(choice, value, StringComparison.OrdinalIgnoreCase)) ?? fallback;
private void OnHostDisplayChanged()
{
if (_updatingHostDisplayOptions || _displayIndex.SelectedItem is not HostDisplayOption display)
{
return;
}
_updatingHostDisplayOptions = true;
try
{
PopulateHostModes(
display,
_resolution.SelectedItem as string ?? "1920x1080",
SelectedRefreshRate());
}
finally
{
_updatingHostDisplayOptions = false;
}
}
private void OnHostResolutionChanged()
{
if (_updatingHostDisplayOptions || _displayIndex.SelectedItem is not HostDisplayOption display)
{
return;
}
var selectedRefreshRate = SelectedRefreshRate();
_updatingHostDisplayOptions = true;
try
{
PopulateRefreshRates(display, _resolution.SelectedItem as string, selectedRefreshRate);
}
finally
{
_updatingHostDisplayOptions = false;
}
}
private void PopulateHostModes(
HostDisplayOption display,
string selectedResolution,
int selectedRefreshRate)
{
var resolutions = HostDisplayOptions.BuildResolutions(display, selectedResolution);
_resolution.ItemsSource = resolutions;
_resolution.SelectedItem = resolutions.FirstOrDefault(resolution =>
string.Equals(resolution, selectedResolution, StringComparison.OrdinalIgnoreCase)) ?? resolutions[0];
PopulateRefreshRates(display, _resolution.SelectedItem as string, selectedRefreshRate);
}
private void PopulateRefreshRates(
HostDisplayOption display,
string? resolution,
int selectedRefreshRate)
{
var rates = HostDisplayOptions.BuildRefreshRates(
display,
resolution,
selectedRefreshRate,
Localization.Instance.Get("Options.RefreshRate.Automatic"));
_refreshRate.ItemsSource = rates;
_refreshRate.SelectedItem = rates.FirstOrDefault(rate => rate.Value == selectedRefreshRate) ?? rates[0];
}
private int SelectedRefreshRate() =>
_refreshRate.SelectedItem is HostRefreshRateOption refreshRate ? refreshRate.Value : 0;
private void Persist()
{
var settings = new PerGameSettings
@@ -196,10 +380,54 @@ public sealed class PerGameSettingsDialog : Window
ImportTraceLimit = _traceRow.IsOverridden ? (int)(_trace.Value ?? 0) : null,
StrictDynlibResolution = _strictRow.IsOverridden ? _strict.IsChecked == true : null,
LogToFile = _logToFileRow.IsOverridden ? _logToFile.IsChecked == true : null,
EnvironmentToggles = _envRow.IsOverridden
? _envBoxes.Where(e => e.Box.IsChecked == true).Select(e => e.Name).ToList()
WindowMode = _windowModeRow.IsOverridden ? _windowMode.SelectedItem as string : null,
Resolution = _resolutionRow.IsOverridden ? _resolution.SelectedItem as string : null,
DisplayIndex = _displayIndexRow.IsOverridden && _displayIndex.SelectedItem is HostDisplayOption display
? display.Index
: null,
RefreshRate = _refreshRateRow.IsOverridden ? SelectedRefreshRate() : null,
ScalingMode = _scalingModeRow.IsOverridden ? _scalingMode.SelectedItem as string : null,
VSync = _vsyncRow.IsOverridden ? _vsync.IsChecked == true : null,
HdrMode = _hdrModeRow.IsOverridden ? _hdrMode.SelectedItem as string : null,
EnvironmentToggles = _envRow.IsOverridden ? BuildEnvironmentEntries() : null,
};
settings.Save(_titleId);
}
private List<string> BuildEnvironmentEntries()
{
const string guestImageCpuSync = "SHARPEMU_GUEST_IMAGE_CPU_SYNC";
var entries = _envBoxes
.Where(entry => entry.Name != guestImageCpuSync && entry.Box.IsChecked == true)
.Select(entry => entry.Name)
.ToList();
if (_envBoxes.First(entry => entry.Name == guestImageCpuSync).Box.IsChecked != true)
{
entries.Add(guestImageCpuSync + "=0");
}
return entries;
}
private static bool IsEnvironmentEnabled(
IEnumerable<string> entries,
string name,
bool defaultValue)
{
foreach (var entry in entries)
{
if (string.Equals(entry, name + "=0", StringComparison.OrdinalIgnoreCase))
{
return false;
}
if (string.Equals(entry, name, StringComparison.OrdinalIgnoreCase) ||
string.Equals(entry, name + "=1", StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
return defaultValue;
}
}
+3 -4
View File
@@ -9,16 +9,15 @@ SPDX-License-Identifier: GPL-2.0-or-later
the executable is started without arguments. -->
<PropertyGroup>
<GenerateDocumentationFile>false</GenerateDocumentationFile>
<!-- Required by the source-generated LibraryImport stubs in the linked
controller readers below. -->
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>
<!-- Dependency-free; provides the BuildInfo provenance shown in the
title bar. -->
<ItemGroup>
<!-- The GUI owns the native presentation control while each game runs in
an isolated emulator process. -->
<!-- Games run in isolated SDL-window processes; the GUI owns launch and
session controls only. -->
<ProjectReference Include="..\SharpEmu.LibAtrac9\SharpEmu.LibAtrac9.csproj" />
<ProjectReference Include="..\SharpEmu.Core\SharpEmu.Core.csproj" />
<ProjectReference Include="..\SharpEmu.Libs\SharpEmu.Libs.csproj" />
<ProjectReference Include="..\SharpEmu.Logging\SharpEmu.Logging.csproj" />
+9
View File
@@ -20,6 +20,15 @@ public sealed class CpuContext(ICpuMemory memory, Generation generation)
public ulong Rip { get; set; }
/// <summary>
/// Index of the import this context is currently executing, or -1 when it is
/// running guest code. Only maintained while guest profiling is enabled;
/// <see cref="Rip"/> alone cannot answer "what is this thread inside right
/// now" because it keeps pointing at the last import stub after the call
/// returns.
/// </summary>
public int ActiveImportIndex { get; set; } = -1;
public ulong Rflags { get; set; }
public ulong FsBase { get; set; }
+152 -24
View File
@@ -1,6 +1,7 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Diagnostics;
using System.Globalization;
using System.Runtime.InteropServices;
@@ -30,6 +31,12 @@ public static unsafe class GuestImageWriteTracker
public ulong End;
public int Dirty;
public int Armed;
/// <summary>
/// When false the range is watch-only: managed writes still dirty it via
/// <see cref="NotifyManagedWrite"/>, but pages are never write-protected
/// so native CPU stores do not fault.
/// </summary>
public bool Protect;
public int FirstCpuWriteSeen;
public int PendingFirstCpuWrite;
public long WriteGeneration;
@@ -80,8 +87,14 @@ public static unsafe class GuestImageWriteTracker
private static RangeSnapshot _rangeSnapshot = RangeSnapshot.Empty;
private static readonly bool _enabled = !OperatingSystem.IsWindows() &&
Environment.GetEnvironmentVariable("SHARPEMU_GUEST_IMAGE_CPU_SYNC") != "0";
// CPU-written guest image synchronization is the compatible default. A few
// titles (currently GTA V) require the lower-overhead watch-only path and
// opt out explicitly with SHARPEMU_GUEST_IMAGE_CPU_SYNC=0.
private static readonly bool _enabled =
!string.Equals(
Environment.GetEnvironmentVariable("SHARPEMU_GUEST_IMAGE_CPU_SYNC"),
"0",
StringComparison.Ordinal);
private static readonly (bool Wildcard, ulong[] Addresses) _lifetimeTraceFilter =
ParseAddressList(Environment.GetEnvironmentVariable("SHARPEMU_TRACE_GUEST_IMAGE_ADDRS"));
private static readonly (bool Wildcard, string[] Sources) _lifetimeSourceTraceFilter =
@@ -95,14 +108,67 @@ public static unsafe class GuestImageWriteTracker
_enabled && _lifetimeTraceEnabled ? GetMonotonicNanoseconds() : 0;
private static long _lifetimeTraceSequence;
private const uint PageReadonly = 0x02;
private const uint PageReadWrite = 0x04;
[DllImport("libc", EntryPoint = "mprotect", SetLastError = true)]
private static extern int Mprotect(nint address, nuint length, int protection);
[DllImport("libc", EntryPoint = "clock_gettime", SetLastError = false)]
private static extern int ClockGetTime(int clockId, Timespec* time);
[DllImport("kernel32.dll", SetLastError = true)]
private static extern int VirtualProtect(
nint lpAddress,
nuint dwSize,
uint flNewProtect,
out uint lpflOldProtect);
[DllImport("kernel32.dll", SetLastError = true)]
private static extern nint VirtualAlloc(
nint lpAddress,
nuint dwSize,
uint flAllocationType,
uint flProtect);
[DllImport("kernel32.dll", SetLastError = true)]
private static extern int VirtualFree(nint lpAddress, nuint dwSize, uint dwFreeType);
private const uint MemCommit = 0x1000;
private const uint MemReserve = 0x2000;
private const uint MemRelease = 0x8000;
public static bool Enabled => _enabled;
/// <summary>
/// Test/diagnostics helper: whether <paramref name="address"/> is tracked
/// with write protection armed (watch-only ranges report protect=false).
/// </summary>
public static bool TryGetProtectionState(
ulong address,
out bool protect,
out bool armed)
{
protect = false;
armed = false;
if (!_enabled)
{
return false;
}
lock (_gate)
{
if (!_rangesByAddress.TryGetValue(address, out var range))
{
return false;
}
protect = range.Protect;
armed = Volatile.Read(ref range.Armed) != 0;
return true;
}
}
/// <summary>
/// Exercises the fault-handling path once outside signal context so every
/// branch is JIT-compiled (and, under Rosetta 2, translated) before a real
@@ -115,7 +181,17 @@ public static unsafe class GuestImageWriteTracker
return;
}
var scratch = NativeMemory.AllocZeroed(4096);
// VirtualProtect only belongs on VirtualAlloc/mmap pages. Warming on
// CRT heap memory makes neighbouring heap metadata read-only and
// crashes the process on Windows.
var scratch = OperatingSystem.IsWindows()
? VirtualAlloc(0, 4096, MemCommit | MemReserve, PageReadWrite)
: (nint)NativeMemory.AllocZeroed(4096);
if (scratch == 0)
{
return;
}
try
{
// Warm the timestamp P/Invoke used by the signal-safe scalar
@@ -129,16 +205,29 @@ public static unsafe class GuestImageWriteTracker
}
finally
{
NativeMemory.Free(scratch);
if (OperatingSystem.IsWindows())
{
_ = VirtualFree(scratch, 0, MemRelease);
}
else
{
NativeMemory.Free((void*)scratch);
}
}
}
/// <summary>Registers a range and arms write protection on it.</summary>
/// <summary>
/// Registers a range. When <paramref name="protect"/> is true, arms write
/// protection so native stores fault and mark the range dirty. When false,
/// the range is watch-only (managed HLE writes still dirty via
/// <see cref="NotifyManagedWrite"/>) and never <c>VirtualProtect</c>'d.
/// </summary>
public static void Track(
ulong address,
ulong byteCount,
long sourceSequence = 0,
string source = "unspecified")
string source = "unspecified",
bool protect = true)
{
if (!_enabled || address == 0 || byteCount == 0)
{
@@ -159,6 +248,7 @@ public static unsafe class GuestImageWriteTracker
// a fresh immutable range, carrying the write generation so
// resizes do not hide guest CPU rewrites from cache owners.
var writeGeneration = Volatile.Read(ref range.WriteGeneration);
var keepProtect = range.Protect || protect;
DisarmLocked(range, "replace-range");
_rangesByAddress.Remove(address);
range = new TrackedRange
@@ -167,6 +257,7 @@ public static unsafe class GuestImageWriteTracker
ByteCount = byteCount,
Start = start,
End = start + length,
Protect = keepProtect,
WriteGeneration = writeGeneration,
};
_rangesByAddress[address] = range;
@@ -181,6 +272,7 @@ public static unsafe class GuestImageWriteTracker
ByteCount = byteCount,
Start = start,
End = start + length,
Protect = protect,
TraceLifetime =
ShouldTraceRange(start, start + length) || ShouldTraceSource(source),
SourceSequence = sourceSequence,
@@ -192,13 +284,22 @@ public static unsafe class GuestImageWriteTracker
else
{
FlushPendingFirstCpuWrite(range);
// Protect is sticky: a later watch-only Track (texture cache)
// must not disarm an RT that already needs page faults.
if (protect && !range.Protect)
{
range.Protect = true;
}
}
range.SourceSequence = sourceSequence;
range.Source = source;
range.TraceLifetime =
ShouldTraceRange(range.Start, range.End) || ShouldTraceSource(source);
ArmLocked(range, "arm");
if (range.Protect)
{
ArmLocked(range, "arm");
}
}
}
@@ -277,7 +378,8 @@ public static unsafe class GuestImageWriteTracker
lock (_gate)
{
if (_rangesByAddress.TryGetValue(address, out var range))
if (_rangesByAddress.TryGetValue(address, out var range) &&
range.Protect)
{
ArmLocked(range, "rearm");
}
@@ -445,10 +547,7 @@ public static unsafe class GuestImageWriteTracker
}
if (needsUnprotect &&
Mprotect(
(nint)writableStart,
(nuint)(writableEnd - writableStart),
ProtRead | ProtWrite) != 0)
!TrySetProtection(writableStart, writableEnd - writableStart, writable: true))
{
return false;
}
@@ -462,7 +561,11 @@ public static unsafe class GuestImageWriteTracker
}
var wasArmed = Interlocked.Exchange(ref range.Armed, 0) != 0;
if (wasArmed)
var wasDirty = Interlocked.Exchange(ref range.Dirty, 1) != 0;
// Protected ranges bump generation once per arm/fault cycle.
// Watch-only ranges never arm, so bump on the first dirty mark
// (NotifyManagedWrite) so cache owners still see a rewrite.
if (wasArmed || (!range.Protect && !wasDirty))
{
Interlocked.Increment(ref range.WriteGeneration);
}
@@ -480,8 +583,6 @@ public static unsafe class GuestImageWriteTracker
Volatile.Write(ref range.PendingFirstCpuWrite, 1);
Volatile.Write(ref range.FirstCpuWriteSeen, 2);
}
Volatile.Write(ref range.Dirty, 1);
}
return true;
@@ -497,10 +598,7 @@ public static unsafe class GuestImageWriteTracker
// A new publication/rearm starts a new first-write lifetime.
Volatile.Write(ref range.FirstCpuWriteSeen, 0);
var failed = Mprotect(
(nint)range.Start,
(nuint)(range.End - range.Start),
ProtRead) != 0;
var failed = !TrySetProtection(range.Start, range.End - range.Start, writable: false);
if (failed)
{
Volatile.Write(ref range.Armed, 0);
@@ -520,10 +618,7 @@ public static unsafe class GuestImageWriteTracker
var wasArmed = Interlocked.Exchange(ref range.Armed, 0) == 1;
if (wasArmed)
{
_ = Mprotect(
(nint)range.Start,
(nuint)(range.End - range.Start),
ProtRead | ProtWrite);
_ = TrySetProtection(range.Start, range.End - range.Start, writable: true);
}
if (range.TraceLifetime)
@@ -534,7 +629,13 @@ public static unsafe class GuestImageWriteTracker
private static void RebuildSnapshotLocked()
{
Volatile.Write(ref _rangeSnapshot, new RangeSnapshot(_rangesByAddress.Values.ToArray()));
// Fault / NotifyManagedWrite hot paths must only see protected ranges.
// Watch-only texture-cache registrations used to widen Start..End across
// nearly all GPU memory so every managed guest write walked this path.
var protectedRanges = _rangesByAddress.Values
.Where(static range => range.Protect)
.ToArray();
Volatile.Write(ref _rangeSnapshot, new RangeSnapshot(protectedRanges));
}
private static (ulong Start, ulong Length) PageAlign(ulong address, ulong byteCount)
@@ -679,8 +780,35 @@ public static unsafe class GuestImageWriteTracker
$"fault=0x{faultAddress:X16} page=0x{faultPage:X16}");
}
private static bool TrySetProtection(ulong start, ulong length, bool writable)
{
if (length == 0)
{
return true;
}
if (OperatingSystem.IsWindows())
{
return VirtualProtect(
(nint)start,
(nuint)length,
writable ? PageReadWrite : PageReadonly,
out _) != 0;
}
return Mprotect(
(nint)start,
(nuint)length,
writable ? ProtRead | ProtWrite : ProtRead) == 0;
}
private static long GetMonotonicNanoseconds()
{
if (OperatingSystem.IsWindows())
{
return Stopwatch.GetTimestamp() * 1_000_000_000L / Stopwatch.Frequency;
}
Timespec time;
return ClockGetTime(ClockMonotonicRaw, &time) == 0
? unchecked((time.Seconds * 1_000_000_000L) + time.Nanoseconds)
+69
View File
@@ -0,0 +1,69 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Diagnostics;
namespace SharpEmu.HLE.Host;
/// <summary>
/// How much guest audio the host device has actually played, in seconds.
///
/// This is the only clock in the emulator that advances at the rate the player
/// hears. Wall clock runs ahead of it whenever the guest cannot feed the device
/// (the stream underruns and the missing time is never played), so anything
/// that has to stay in step with the guest's audio — host-decoded video being
/// the case that matters — has to follow this rather than <see cref="Stopwatch"/>.
///
/// Reported per stream and kept as the furthest-along value: the guest's ports
/// all carry one mix, and the leading port is the one whose position the
/// listener perceives.
/// </summary>
public static class GuestAudioClock
{
private static long _playedMicroseconds;
private static long _lastAdvanceTimestamp;
/// <summary>Seconds of guest audio the device has played. Monotonic.</summary>
public static double PlayedSeconds =>
Interlocked.Read(ref _playedMicroseconds) / 1_000_000.0;
/// <summary>
/// True while a stream has reported progress recently. False means no guest
/// audio is playing, and callers must fall back to wall clock rather than
/// stalling on a clock that will never advance.
/// </summary>
public static bool IsRunning
{
get
{
var last = Interlocked.Read(ref _lastAdvanceTimestamp);
return last != 0 &&
Stopwatch.GetElapsedTime(last) < TimeSpan.FromMilliseconds(250);
}
}
public static void Report(double playedSeconds)
{
if (double.IsNaN(playedSeconds) || playedSeconds < 0)
{
return;
}
var microseconds = (long)(playedSeconds * 1_000_000.0);
var current = Interlocked.Read(ref _playedMicroseconds);
while (microseconds > current)
{
var seen = Interlocked.CompareExchange(
ref _playedMicroseconds,
microseconds,
current);
if (seen == current)
{
Interlocked.Exchange(ref _lastAdvanceTimestamp, Stopwatch.GetTimestamp());
return;
}
current = seen;
}
}
}
+90 -1
View File
@@ -28,8 +28,44 @@ public enum HostGamepadButtons : uint
R3 = 1 << 13,
Options = 1 << 14,
TouchPad = 1 << 15,
Create = 1 << 16,
Ps = 1 << 17,
Mic = 1 << 18,
}
public enum HostGamepadType : byte
{
Generic,
DualShock4,
DualSense,
}
public enum HostGamepadConnection : byte
{
Unknown,
Wired,
Wireless,
}
public readonly record struct HostMotionState(
bool Available,
float AccelerationX,
float AccelerationY,
float AccelerationZ,
float AngularVelocityX,
float AngularVelocityY,
float AngularVelocityZ);
public readonly record struct HostTouchPoint(
bool Active,
byte Id,
float X,
float Y);
public readonly record struct HostTouchState(
HostTouchPoint First,
HostTouchPoint Second);
/// <summary>
/// Snapshot of one host gamepad: sticks are 0..255 with 128 centered and Y growing
/// downward; triggers 0..255. Unmanaged on purpose so per-frame polls can stackalloc
@@ -43,4 +79,57 @@ public readonly record struct HostGamepadState(
byte RightX,
byte RightY,
byte LeftTrigger,
byte RightTrigger);
byte RightTrigger,
HostGamepadType Type = HostGamepadType.Generic,
HostGamepadConnection Connection = HostGamepadConnection.Unknown,
HostMotionState Motion = default,
HostTouchState Touch = default,
byte BatteryPercent = 0);
/// <summary>A complete 11-byte DualSense adaptive-trigger command.</summary>
public readonly record struct HostAdaptiveTriggerEffect(
byte B0,
byte B1,
byte B2,
byte B3,
byte B4,
byte B5,
byte B6,
byte B7,
byte B8,
byte B9,
byte B10,
byte FallbackStrength = 0)
{
public static HostAdaptiveTriggerEffect FromBytes(ReadOnlySpan<byte> source, byte fallbackStrength = 0)
{
if (source.Length < 11)
{
throw new ArgumentException("Adaptive-trigger source is too small.", nameof(source));
}
return new HostAdaptiveTriggerEffect(
source[0], source[1], source[2], source[3], source[4], source[5],
source[6], source[7], source[8], source[9], source[10], fallbackStrength);
}
public void CopyTo(Span<byte> destination)
{
if (destination.Length < 11)
{
throw new ArgumentException("Adaptive-trigger destination is too small.", nameof(destination));
}
destination[0] = B0;
destination[1] = B1;
destination[2] = B2;
destination[3] = B3;
destination[4] = B4;
destination[5] = B5;
destination[6] = B6;
destination[7] = B7;
destination[8] = B8;
destination[9] = B9;
destination[10] = B10;
}
}
+7 -1
View File
@@ -19,5 +19,11 @@ public interface IHostAudioOutput
/// Throws when the host has no usable output device; callers degrade to a silent
/// port and pace the guest instead.
/// </summary>
IHostAudioStream OpenStereoPcm16Stream(uint sampleRate);
/// <param name="sampleRate">Host stream sample rate in Hz.</param>
/// <param name="maxQueuedPcmBytes">
/// Soft backpressure cap for queued stereo PCM16. Default 32 KiB (~171 ms at
/// 48 kHz) matches classic AudioOut latency. Bursty AudioOut2 / FMOD feeders
/// may pass a deeper cap to avoid underruns.
/// </param>
IHostAudioStream OpenStereoPcm16Stream(uint sampleRate, int maxQueuedPcmBytes = 32 * 1024);
}
+13
View File
@@ -15,4 +15,17 @@ public interface IHostAudioStream : IDisposable
/// audio, in which case the caller paces the guest itself.
/// </summary>
bool Submit(ReadOnlySpan<byte> stereoPcm16);
/// <summary>
/// Audio already handed to the device and not yet played, in milliseconds —
/// the cushion protecting playback from a late submission. Zero means the
/// device has run dry and is emitting silence.
///
/// Callers that pace the guest against an emulated hardware queue need this:
/// pacing purely on wall clock releases exactly one buffer per buffer-period
/// and so keeps the cushion at zero, which turns any scheduling jitter into
/// an audible dropout. Returns -1 when the backend cannot report a depth, in
/// which case callers must fall back to their own pacing.
/// </summary>
int QueuedMilliseconds => -1;
}
+5
View File
@@ -32,6 +32,11 @@ public interface IHostInput
/// </summary>
void SetTriggerRumble(byte? leftTrigger, byte? rightTrigger);
/// <summary>Applies native DualSense trigger effects when supported.</summary>
void SetAdaptiveTriggerEffect(
HostAdaptiveTriggerEffect? leftTrigger,
HostAdaptiveTriggerEffect? rightTrigger);
void SetLightbar(byte red, byte green, byte blue);
void ResetLightbar();
@@ -0,0 +1,19 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
namespace SharpEmu.HLE.Host;
/// <summary>
/// Optional host-audio extension for backends that can accept the guest's
/// interleaved PCM layout directly and perform device conversion themselves.
/// </summary>
public interface IHostPcmAudioOutput : IHostAudioOutput
{
IHostAudioStream OpenPcmStream(uint sampleRate, int channels, HostPcmFormat format);
}
public enum HostPcmFormat
{
Signed16,
Float32,
}
@@ -0,0 +1,42 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
namespace SharpEmu.HLE.Host;
/// <summary>Input snapshots produced by the active host window.</summary>
public interface IHostWindowInputSource
{
bool HasKeyboardFocus { get; }
bool IsKeyDown(int virtualKey);
int GetGamepadStates(Span<HostGamepadState> destination);
string? DescribeConnectedGamepad();
void SetRumble(byte largeMotor, byte smallMotor);
void SetTriggerRumble(byte? leftTrigger, byte? rightTrigger);
void SetAdaptiveTriggerEffect(
HostAdaptiveTriggerEffect? leftTrigger,
HostAdaptiveTriggerEffect? rightTrigger);
void SetLightbar(byte red, byte green, byte blue);
void ResetLightbar();
}
/// <summary>Process-wide bridge between the window layer and host input.</summary>
public static class HostWindowInputSource
{
private static IHostWindowInputSource? _current;
public static IHostWindowInputSource? Current => Volatile.Read(ref _current);
public static void Set(IHostWindowInputSource source) =>
Volatile.Write(ref _current, source);
public static void Clear(IHostWindowInputSource source) =>
Interlocked.CompareExchange(ref _current, null, source);
}
@@ -14,9 +14,6 @@ namespace SharpEmu.HLE.Host.Posix;
/// </summary>
internal sealed unsafe class PosixAlsaAudioStream : IHostAudioStream
{
// 32KB of stereo PCM16 at 48kHz is ~170ms; keep the same device-side
// queue depth the WinMM/CoreAudio ports enforce in managed code.
private const uint DeviceLatencyMicroseconds = 170_000;
private const int StreamPlayback = 0;
private const int FormatS16LittleEndian = 2;
private const int AccessReadWriteInterleaved = 3;
@@ -27,7 +24,7 @@ internal sealed unsafe class PosixAlsaAudioStream : IHostAudioStream
private nint _pcm;
private bool _disposed;
public PosixAlsaAudioStream(uint sampleRate)
public PosixAlsaAudioStream(uint sampleRate, int maxQueuedPcmBytes = 32 * 1024)
{
if (!OperatingSystem.IsLinux())
{
@@ -47,6 +44,14 @@ internal sealed unsafe class PosixAlsaAudioStream : IHostAudioStream
$"snd_pcm_open(\"{device}\") failed: {DescribeError(status)}.");
}
// Match WinMM/CoreAudio soft queue depth: 32 KiB stereo PCM16 @ 48 kHz
// is ~170 ms. AudioOut2 may request a deeper bed.
var queuedBytes = Math.Max(maxQueuedPcmBytes, 4 * 1024);
var latencyMicroseconds = (uint)Math.Clamp(
(long)queuedBytes * 1_000_000L / Math.Max(sampleRate * 4u, 1u),
20_000L,
2_000_000L);
status = snd_pcm_set_params(
_pcm,
FormatS16LittleEndian,
@@ -54,7 +59,7 @@ internal sealed unsafe class PosixAlsaAudioStream : IHostAudioStream
2,
sampleRate,
1,
DeviceLatencyMicroseconds);
latencyMicroseconds);
if (status != 0)
{
_ = snd_pcm_close(_pcm);
@@ -13,11 +13,11 @@ namespace SharpEmu.HLE.Host.Posix;
/// </summary>
internal sealed unsafe class PosixCoreAudioStream : IHostAudioStream
{
private const int MaximumQueuedPcmBytes = 32 * 1024;
private const uint FormatLinearPcm = 0x6C70636D; // 'lpcm'
private const uint FlagIsSignedInteger = 0x4;
private const uint FlagIsPacked = 0x8;
private readonly int _maximumQueuedPcmBytes;
private readonly object _gate = new();
private readonly AutoResetEvent _completion = new(false);
private readonly Queue<nint> _freeBuffers = new();
@@ -27,13 +27,15 @@ internal sealed unsafe class PosixCoreAudioStream : IHostAudioStream
private bool _started;
private bool _disposed;
public PosixCoreAudioStream(uint sampleRate)
public PosixCoreAudioStream(uint sampleRate, int maxQueuedPcmBytes = 32 * 1024)
{
if (!OperatingSystem.IsMacOS())
{
throw new PlatformNotSupportedException("CoreAudio is only available on macOS.");
}
_maximumQueuedPcmBytes = Math.Max(maxQueuedPcmBytes, 4 * 1024);
var format = new AudioStreamBasicDescription
{
SampleRate = sampleRate,
@@ -73,7 +75,7 @@ internal sealed unsafe class PosixCoreAudioStream : IHostAudioStream
var outputLength = stereoPcm16.Length;
while (_queuedPcmBytes != 0 &&
_queuedPcmBytes + outputLength > MaximumQueuedPcmBytes)
_queuedPcmBytes + outputLength > _maximumQueuedPcmBytes)
{
Monitor.Exit(_gate);
try
@@ -12,10 +12,10 @@ internal sealed class PosixHostAudio : IHostAudioOutput
{
public string BackendName => OperatingSystem.IsMacOS() ? "coreaudio" : "alsa";
public IHostAudioStream OpenStereoPcm16Stream(uint sampleRate)
public IHostAudioStream OpenStereoPcm16Stream(uint sampleRate, int maxQueuedPcmBytes = 32 * 1024)
{
return OperatingSystem.IsMacOS()
? new PosixCoreAudioStream(sampleRate)
: new PosixAlsaAudioStream(sampleRate);
? new PosixCoreAudioStream(sampleRate, maxQueuedPcmBytes)
: new PosixAlsaAudioStream(sampleRate, maxQueuedPcmBytes);
}
}
@@ -1,186 +0,0 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
namespace SharpEmu.HLE.Host.Posix;
/// <summary>
/// Bridges a window-provided input source into the host input seam. POSIX
/// hosts have no user32/XInput/raw-HID readers; keyboard and gamepad state
/// come from the presenter's GLFW window instead, which registers itself via
/// <see cref="SetSource"/> once the window exists. Until then (and with no
/// window at all, e.g. headless runs) every query reports neutral input.
/// Rumble and lightbar are unsupported by the GLFW input layer and no-op.
/// </summary>
public interface IPosixWindowInputSource
{
/// <summary>True while the window's keyboard is delivering events.</summary>
bool HasKeyboardFocus { get; }
/// <summary>Windows virtual-key semantics; the source translates.</summary>
bool IsKeyDown(int virtualKey);
/// <summary>Same contract as <see cref="IHostInput.GetGamepadStates"/>.</summary>
int GetGamepadStates(Span<HostGamepadState> destination);
string? DescribeConnectedGamepad();
}
// Public so the presenter's window layer (SharpEmu.Libs) can register its
// input source; the platform still constructs the singleton itself.
public sealed class PosixHostInput : IHostInput
{
private static volatile IPosixWindowInputSource? _source;
/// <summary>Called by the presenter's window layer when input is ready.</summary>
public static void SetSource(IPosixWindowInputSource source)
{
_source = source;
}
public void EnsureStarted()
{
// Device readers are event-driven off the window thread; nothing to start.
}
public int GetGamepadStates(Span<HostGamepadState> destination)
{
return _source?.GetGamepadStates(destination) ?? 0;
}
public string? DescribeConnectedGamepad() => _source?.DescribeConnectedGamepad();
public void SetRumble(byte largeMotor, byte smallMotor)
{
}
public void SetTriggerRumble(byte? leftTrigger, byte? rightTrigger)
{
}
public void SetLightbar(byte red, byte green, byte blue)
{
}
public void ResetLightbar()
{
}
public bool IsHostWindowFocused()
{
// GLFW only delivers key events to the focused window, so a
// delivering keyboard implies focus.
return _source?.HasKeyboardFocus ?? IsEmbeddedX11WindowFocused();
}
public bool IsKeyDown(int virtualKey)
{
var source = _source;
if (source is not null)
{
return source.IsKeyDown(virtualKey);
}
return IsEmbeddedX11WindowFocused() && IsEmbeddedX11KeyDown(virtualKey);
}
private static bool IsEmbeddedX11WindowFocused()
{
if (!OperatingSystem.IsLinux())
{
return false;
}
var display = HostSessionControl.EmbeddedHostDisplay;
var window = HostSessionControl.EmbeddedHostWindow;
if (display == 0 || window == 0 || XGetInputFocus(display, out var focusedWindow, out _) == 0 || focusedWindow == 0)
{
return false;
}
return GetTopLevelWindow(display, focusedWindow) == GetTopLevelWindow(display, window);
}
private static bool IsEmbeddedX11KeyDown(int virtualKey)
{
var display = HostSessionControl.EmbeddedHostDisplay;
var keysym = ToX11Keysym(virtualKey);
if (display == 0 || keysym == 0)
{
return false;
}
var keycode = XKeysymToKeycode(display, keysym);
if (keycode == 0)
{
return false;
}
var keymap = new byte[32];
XQueryKeymap(display, keymap);
return (keymap[keycode >> 3] & (1 << (keycode & 7))) != 0;
}
private static nint GetTopLevelWindow(nint display, nint window)
{
var current = window;
for (var depth = 0; depth < 16; depth++)
{
if (XQueryTree(display, current, out var root, out var parent, out var children, out _) == 0)
{
return 0;
}
if (children != 0)
{
XFree(children);
}
if (parent == 0 || parent == root)
{
return current;
}
current = parent;
}
return 0;
}
private static nuint ToX11Keysym(int virtualKey)
{
return virtualKey switch
{
0x08 => 0xFF08, // Backspace
0x09 => 0xFF09, // Tab
0x0D => 0xFF0D, // Return
0x1B => 0xFF1B, // Escape
0x25 => 0xFF51, // Left
0x26 => 0xFF52, // Up
0x27 => 0xFF53, // Right
0x28 => 0xFF54, // Down
>= 0x41 and <= 0x5A => (nuint)virtualKey,
_ => 0,
};
}
[System.Runtime.InteropServices.DllImport("libX11.so.6")]
private static extern int XGetInputFocus(nint display, out nint focus, out int revertTo);
[System.Runtime.InteropServices.DllImport("libX11.so.6")]
private static extern int XQueryKeymap(nint display, [System.Runtime.InteropServices.Out] byte[] keysReturn);
[System.Runtime.InteropServices.DllImport("libX11.so.6")]
private static extern byte XKeysymToKeycode(nint display, nuint keysym);
[System.Runtime.InteropServices.DllImport("libX11.so.6")]
private static extern int XQueryTree(
nint display,
nint window,
out nint root,
out nint parent,
out nint children,
out uint childCount);
[System.Runtime.InteropServices.DllImport("libX11.so.6")]
private static extern int XFree(nint data);
}
@@ -1,6 +1,8 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.HLE.Host.Sdl;
namespace SharpEmu.HLE.Host.Posix;
internal sealed class PosixHostPlatform : IHostPlatform
@@ -11,7 +13,7 @@ internal sealed class PosixHostPlatform : IHostPlatform
public IHostSymbolResolver Symbols { get; } = new PosixHostSymbolResolver();
public IHostAudioOutput Audio { get; } = new PosixHostAudio();
public IHostAudioOutput Audio { get; } = new SdlHostAudio();
public IHostInput Input { get; } = new PosixHostInput();
public IHostInput Input { get; } = new WindowHostInput();
}
+325
View File
@@ -0,0 +1,325 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Diagnostics;
using System.Runtime.InteropServices;
using SDL;
using static SDL.SDL3;
namespace SharpEmu.HLE.Host.Sdl;
internal sealed unsafe class SdlHostAudio : IHostPcmAudioOutput
{
/// <summary>
/// Cap for streams this class paces itself (AudioOut). Blocking the guest
/// here is that path's only pacing, so the device settles at this depth —
/// it is the playback latency, and the floor under it is how much jitter the
/// stream can absorb before it runs dry.
/// </summary>
private static readonly int TargetQueuedMilliseconds =
int.TryParse(
Environment.GetEnvironmentVariable("SHARPEMU_AUDIO_LATENCY_MS"),
out var latencyMs) && latencyMs > 0
? latencyMs
: 60;
private const int MaximumWaitMilliseconds = 250;
private static readonly object InitGate = new();
private static bool _initialized;
public string BackendName => "sdl3";
/// <summary>
/// Stereo PCM16 stream with a caller-chosen backpressure cap. Callers that
/// pace the guest themselves pass a deeper cap so this class's backpressure
/// does not fight their pacing.
/// </summary>
public IHostAudioStream OpenStereoPcm16Stream(uint sampleRate, int maxQueuedPcmBytes = 32 * 1024)
=> OpenStream(
sampleRate,
channels: 2,
HostPcmFormat.Signed16,
maxQueuedPcmBytes > 0 ? maxQueuedPcmBytes : 32 * 1024);
/// <summary>
/// Guest-format stream for AudioOut, which has no queue model of its own:
/// blocking here is that path's only pacing, so the device settles at
/// TargetQueuedMilliseconds and that depth is the playback latency.
/// </summary>
public IHostAudioStream OpenPcmStream(uint sampleRate, int channels, HostPcmFormat format)
{
var bytesPerSample = format == HostPcmFormat.Float32 ? sizeof(float) : sizeof(short);
var cap = checked((int)((long)sampleRate * channels * bytesPerSample *
TargetQueuedMilliseconds / 1_000));
return OpenStream(sampleRate, channels, format, cap);
}
private static IHostAudioStream OpenStream(
uint sampleRate,
int channels,
HostPcmFormat format,
int maximumQueuedBytes)
{
if (sampleRate is < 8_000 or > 384_000 || channels is < 1 or > 8)
{
throw new ArgumentOutOfRangeException(
sampleRate is < 8_000 or > 384_000 ? nameof(sampleRate) : nameof(channels));
}
EnsureInitialized();
return new AudioStream(sampleRate, channels, format, maximumQueuedBytes);
}
private static void EnsureInitialized()
{
lock (InitGate)
{
if (_initialized)
{
return;
}
if ((SDL_WasInit(SDL_InitFlags.SDL_INIT_AUDIO) & SDL_InitFlags.SDL_INIT_AUDIO) == 0 &&
!SDL_InitSubSystem(SDL_InitFlags.SDL_INIT_AUDIO))
{
throw new InvalidOperationException($"SDL audio initialization failed: {GetError()}");
}
_initialized = true;
}
}
private static string GetError()
{
var error = Unsafe_SDL_GetError();
return error is null ? "unknown SDL error" : Marshal.PtrToStringUTF8((nint)error) ?? "unknown SDL error";
}
private static readonly bool _traceQueue = string.Equals(
Environment.GetEnvironmentVariable("SHARPEMU_LOG_AUDIO_QUEUE"),
"1",
StringComparison.Ordinal);
private static int _nextStreamId;
private sealed class AudioStream : IHostAudioStream
{
private readonly object _gate = new();
private readonly int _maximumQueuedBytes;
private readonly int _bytesPerFrame;
private readonly uint _sampleRate;
private readonly int _streamId = Interlocked.Increment(ref _nextStreamId);
private SDL_AudioStream* _stream;
private bool _disposed;
private long _totalSubmittedBytes;
// Queue diagnostics for the current report window.
private long _windowStart = Stopwatch.GetTimestamp();
private long _submissions;
private long _submittedBytes;
private long _blockedTicks;
private long _drops;
private long _emptyObservations;
private int _minQueuedBytes = int.MaxValue;
private int _maxQueuedBytes;
private long _queuedByteSum;
public AudioStream(
uint sampleRate,
int channels,
HostPcmFormat format,
int maximumQueuedBytes)
{
var bytesPerSample = format == HostPcmFormat.Float32 ? sizeof(float) : sizeof(short);
_bytesPerFrame = channels * bytesPerSample;
_sampleRate = sampleRate;
var spec = new SDL_AudioSpec
{
format = format == HostPcmFormat.Float32
? SDL_AudioFormat.SDL_AUDIO_F32LE
: SDL_AudioFormat.SDL_AUDIO_S16LE,
channels = checked((byte)channels),
freq = checked((int)sampleRate),
};
_stream = SDL_OpenAudioDeviceStream(
SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK,
&spec,
null,
IntPtr.Zero);
if (_stream is null)
{
throw new InvalidOperationException($"SDL audio stream creation failed: {GetError()}");
}
if (!SDL_ResumeAudioStreamDevice(_stream))
{
SDL_DestroyAudioStream(_stream);
_stream = null;
throw new InvalidOperationException($"SDL audio stream start failed: {GetError()}");
}
_maximumQueuedBytes = maximumQueuedBytes;
}
public int QueuedMilliseconds
{
get
{
lock (_gate)
{
if (_disposed || _stream is null)
{
return -1;
}
var bytesPerSecond = (double)_bytesPerFrame * _sampleRate;
return bytesPerSecond <= 0
? -1
: (int)(SDL_GetAudioStreamQueued(_stream) / bytesPerSecond * 1000.0);
}
}
}
public bool Submit(ReadOnlySpan<byte> pcm)
{
if (pcm.IsEmpty)
{
return true;
}
lock (_gate)
{
if (_disposed || _stream is null)
{
return false;
}
var blockStart = Stopwatch.GetTimestamp();
var deadline = blockStart +
(Stopwatch.Frequency * MaximumWaitMilliseconds / 1_000);
int queued;
var overrun = false;
while ((queued = SDL_GetAudioStreamQueued(_stream)) > _maximumQueuedBytes)
{
if (Stopwatch.GetTimestamp() >= deadline)
{
// Enqueue anyway rather than discarding the buffer. A gap in
// the stream is an audible click; the extra latency of one
// over-deep submission is not, and the queue recovers as soon
// as the device drains back under the cap.
overrun = true;
break;
}
Thread.Sleep(1);
}
RecordSubmission(queued, blockStart, dropped: overrun, bytes: pcm.Length);
bool submitted;
fixed (byte* data = pcm)
{
submitted = SDL_PutAudioStreamData(_stream, (nint)data, pcm.Length);
}
if (submitted)
{
// Everything handed over minus what the device still holds is
// what the player has actually heard.
_totalSubmittedBytes += pcm.Length;
var bytesPerSecond = (double)_bytesPerFrame * _sampleRate;
if (bytesPerSecond > 0)
{
GuestAudioClock.Report(
Math.Max(0, _totalSubmittedBytes - queued - pcm.Length) / bytesPerSecond);
}
}
return submitted;
}
}
/// <summary>
/// Samples the queue depth at the moment the guest was allowed to write.
/// That depth is the playback latency the guest's audio is subject to, so
/// it is the number to look at when the sound is late; an observed depth
/// of zero is a genuine underrun, which is what a crackle sounds like.
/// Caller holds <see cref="_gate"/>.
/// </summary>
private void RecordSubmission(int queuedBytes, long blockStart, bool dropped, int bytes)
{
if (!_traceQueue)
{
return;
}
var now = Stopwatch.GetTimestamp();
_submissions++;
_submittedBytes += bytes;
_blockedTicks += now - blockStart;
_queuedByteSum += queuedBytes;
_minQueuedBytes = Math.Min(_minQueuedBytes, queuedBytes);
_maxQueuedBytes = Math.Max(_maxQueuedBytes, queuedBytes);
if (dropped)
{
_drops++;
}
if (queuedBytes == 0)
{
_emptyObservations++;
}
var elapsedTicks = now - _windowStart;
if (elapsedTicks < Stopwatch.Frequency)
{
return;
}
_windowStart = now;
var seconds = elapsedTicks / (double)Stopwatch.Frequency;
var bytesPerSecond = (double)_bytesPerFrame * _sampleRate;
Console.Error.WriteLine(
$"[PERF][AUDIO] stream#{_streamId} {seconds:F1}s " +
$"queued_ms min={ToMilliseconds(_minQueuedBytes, bytesPerSecond):F0} " +
$"avg={ToMilliseconds((int)(_queuedByteSum / Math.Max(1, _submissions)), bytesPerSecond):F0} " +
$"max={ToMilliseconds(_maxQueuedBytes, bytesPerSecond):F0} " +
$"cap={ToMilliseconds(_maximumQueuedBytes, bytesPerSecond):F0} " +
$"submits/s={_submissions / seconds:F0} " +
$"fill={_submittedBytes / seconds / bytesPerSecond * 100.0:F0}% " +
$"blocked={_blockedTicks * 100.0 / elapsedTicks:F0}% " +
$"empty={_emptyObservations} drops={_drops}");
_submissions = 0;
_submittedBytes = 0;
_blockedTicks = 0;
_drops = 0;
_emptyObservations = 0;
_minQueuedBytes = int.MaxValue;
_maxQueuedBytes = 0;
_queuedByteSum = 0;
}
private static double ToMilliseconds(int bytes, double bytesPerSecond) =>
bytesPerSecond <= 0 ? 0 : bytes / bytesPerSecond * 1000.0;
public void Dispose()
{
lock (_gate)
{
if (_disposed)
{
return;
}
_disposed = true;
if (_stream is not null)
{
SDL_ClearAudioStream(_stream);
SDL_DestroyAudioStream(_stream);
_stream = null;
}
}
}
}
}
+43
View File
@@ -0,0 +1,43 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
namespace SharpEmu.HLE.Host;
/// <summary>
/// Routes emulated input through the active cross-platform host window.
/// </summary>
internal sealed class WindowHostInput : IHostInput
{
public void EnsureStarted()
{
// SDL owns device discovery and pumps it on the window thread.
}
public int GetGamepadStates(Span<HostGamepadState> destination) =>
HostWindowInputSource.Current?.GetGamepadStates(destination) ?? 0;
public string? DescribeConnectedGamepad() =>
HostWindowInputSource.Current?.DescribeConnectedGamepad();
public void SetRumble(byte largeMotor, byte smallMotor) =>
HostWindowInputSource.Current?.SetRumble(largeMotor, smallMotor);
public void SetTriggerRumble(byte? leftTrigger, byte? rightTrigger) =>
HostWindowInputSource.Current?.SetTriggerRumble(leftTrigger, rightTrigger);
public void SetAdaptiveTriggerEffect(
HostAdaptiveTriggerEffect? leftTrigger,
HostAdaptiveTriggerEffect? rightTrigger) =>
HostWindowInputSource.Current?.SetAdaptiveTriggerEffect(leftTrigger, rightTrigger);
public void SetLightbar(byte red, byte green, byte blue) =>
HostWindowInputSource.Current?.SetLightbar(red, green, blue);
public void ResetLightbar() => HostWindowInputSource.Current?.ResetLightbar();
public bool IsHostWindowFocused() =>
HostWindowInputSource.Current?.HasKeyboardFocus ?? false;
public bool IsKeyDown(int virtualKey) =>
HostWindowInputSource.Current?.IsKeyDown(virtualKey) ?? false;
}
@@ -1,439 +0,0 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using Microsoft.Win32.SafeHandles;
namespace SharpEmu.HLE.Host.Windows;
/// <summary>
/// Reads a DualSense controller over raw HID on a background thread.
/// Supports USB (input report 0x01) and Bluetooth (extended report 0x31,
/// activated by requesting feature report 0x05), with hot-plug retry.
/// </summary>
public static class WindowsDualSenseReader
{
private const ushort SonyVendorId = 0x054C;
private const ushort DualSenseProductId = 0x0CE6;
private const ushort DualSenseEdgeProductId = 0x0DF2;
private static readonly object Gate = new();
private static HostGamepadState _state;
private static bool _started;
// Output (rumble/lightbar) state, all guarded by Gate.
private static string? _devicePath;
private static bool _bluetooth;
private static bool _outputReady;
private static bool _lightbarSetupPending;
private static byte _outputSequence;
private static FileStream? _outputStream;
private static byte _motorLeft;
private static byte _motorRight;
private static byte _lightbarRed;
private static byte _lightbarGreen;
private static byte _lightbarBlue = 64; // PS-style blue default
private static byte _playerLeds = 0x04; // center LED = player 1
/// <summary>Starts the background reader once; safe to call repeatedly.</summary>
public static void EnsureStarted()
{
// The GUI source-links this reader and calls it directly, without the
// host-platform resolution that otherwise guarantees Windows.
if (!OperatingSystem.IsWindows())
{
return;
}
lock (Gate)
{
if (_started)
{
return;
}
_started = true;
var thread = new Thread(ReadLoop)
{
IsBackground = true,
Name = "DualSenseReader",
};
thread.Start();
}
}
public static bool TryGetState(out HostGamepadState state)
{
lock (Gate)
{
state = _state;
}
return state.Connected;
}
private static void SetState(in HostGamepadState state)
{
lock (Gate)
{
_state = state;
}
}
/// <summary>Sets rumble; large = left/strong motor, small = right/weak.</summary>
internal static void SetRumble(byte largeMotor, byte smallMotor)
{
lock (Gate)
{
if (_motorLeft == largeMotor && _motorRight == smallMotor)
{
return;
}
_motorLeft = largeMotor;
_motorRight = smallMotor;
SendOutputLocked();
}
}
internal static void SetLightbar(byte red, byte green, byte blue)
{
lock (Gate)
{
if (_lightbarRed == red && _lightbarGreen == green && _lightbarBlue == blue)
{
return;
}
_lightbarRed = red;
_lightbarGreen = green;
_lightbarBlue = blue;
SendOutputLocked();
}
}
internal static void ResetLightbar() => SetLightbar(0, 0, 64);
private static void OnDeviceIdentified(string path, bool bluetooth)
{
lock (Gate)
{
_devicePath = path;
_bluetooth = bluetooth;
_outputReady = true;
_lightbarSetupPending = true;
// Announce ourselves on the hardware: default lightbar + player 1 LED.
SendOutputLocked();
}
}
private static void OnDeviceLost()
{
lock (Gate)
{
_devicePath = null;
_outputReady = false;
_motorLeft = 0;
_motorRight = 0;
_outputStream?.Dispose();
_outputStream = null;
}
}
private static void SendOutputLocked()
{
if (!_outputReady || _devicePath is null)
{
return; // flushed by OnDeviceIdentified once connected
}
try
{
if (_outputStream is null)
{
var handle = WindowsHidNative.CreateFile(
_devicePath,
WindowsHidNative.GenericRead | WindowsHidNative.GenericWrite,
WindowsHidNative.FileShareRead | WindowsHidNative.FileShareWrite,
0, WindowsHidNative.OpenExisting, 0, 0);
if (handle.IsInvalid)
{
handle.Dispose();
return; // read-only device access: outputs unavailable
}
_outputStream = new FileStream(handle, FileAccess.Write, bufferSize: 1);
}
var report = BuildOutputReportLocked();
_outputStream.Write(report, 0, report.Length);
_outputStream.Flush();
}
catch (Exception)
{
_outputStream?.Dispose();
_outputStream = null;
}
}
private static byte[] BuildOutputReportLocked()
{
// Common 47-byte output payload (offsets per the DualSense output
// report layout, same as Linux hid-playstation).
Span<byte> common = stackalloc byte[47];
common[0] = 0x03; // valid_flag0: compatible vibration + haptics select
common[1] = 0x04 | 0x10; // valid_flag1: lightbar + player indicator
common[2] = _motorRight; // right (weak) motor
common[3] = _motorLeft; // left (strong) motor
if (_lightbarSetupPending)
{
common[38] |= 0x02; // valid_flag2: lightbar setup control enable
common[41] = 0x01; // lightbar_setup: light on
_lightbarSetupPending = false;
}
common[43] = _playerLeds;
common[44] = _lightbarRed;
common[45] = _lightbarGreen;
common[46] = _lightbarBlue;
if (!_bluetooth)
{
var usbReport = new byte[48];
usbReport[0] = 0x02;
common.CopyTo(usbReport.AsSpan(1));
return usbReport;
}
// Bluetooth: 0x31 wrapper with sequence tag and CRC32 over a 0xA2
// seed byte plus the first 74 report bytes.
var btReport = new byte[78];
btReport[0] = 0x31;
btReport[1] = (byte)((_outputSequence & 0x0F) << 4);
_outputSequence = (byte)((_outputSequence + 1) & 0x0F);
btReport[2] = 0x10;
common.CopyTo(btReport.AsSpan(3));
var crc = Crc32(0xA2, btReport.AsSpan(0, 74));
btReport[74] = (byte)crc;
btReport[75] = (byte)(crc >> 8);
btReport[76] = (byte)(crc >> 16);
btReport[77] = (byte)(crc >> 24);
return btReport;
}
private static uint Crc32(byte seed, ReadOnlySpan<byte> data)
{
var crc = Crc32Update(0xFFFFFFFFu, seed);
foreach (var value in data)
{
crc = Crc32Update(crc, value);
}
return ~crc;
}
private static uint Crc32Update(uint crc, byte value)
{
crc ^= value;
for (var bit = 0; bit < 8; bit++)
{
crc = (crc >> 1) ^ (0xEDB88320u & (uint)-(int)(crc & 1));
}
return crc;
}
private static void ReadLoop()
{
var announcedConnect = false;
while (true)
{
SafeFileHandle? handle = null;
try
{
handle = OpenDualSense(out var devicePath);
if (handle is null || devicePath is null)
{
SetState(default);
announcedConnect = false;
Thread.Sleep(1000);
continue;
}
// Bluetooth quirk: the DualSense sends a simplified report
// until feature report 0x05 is requested, which switches it
// to the full 0x31 input report. Harmless over USB.
var feature = new byte[41];
feature[0] = 0x05;
_ = WindowsHidNative.HidD_GetFeature(handle, feature, feature.Length);
if (!announcedConnect)
{
Console.Error.WriteLine("[LOADER][INFO] DualSense controller connected.");
announcedConnect = true;
}
using var stream = new FileStream(handle, FileAccess.Read, bufferSize: 1);
handle = null; // stream owns it now
var buffer = new byte[256];
var transportKnown = false;
while (true)
{
var read = stream.Read(buffer, 0, buffer.Length);
if (read <= 0)
{
break;
}
if (TryParseReport(buffer.AsSpan(0, read), out var state))
{
if (!transportKnown)
{
// The first parsed report tells us the transport,
// which the output (rumble/lightbar) path needs.
transportKnown = true;
OnDeviceIdentified(devicePath, bluetooth: buffer[0] == 0x31);
}
SetState(state);
}
}
}
catch (Exception)
{
// Unplugged or read error: fall through and retry.
}
finally
{
handle?.Dispose();
}
if (announcedConnect)
{
Console.Error.WriteLine("[LOADER][INFO] DualSense controller disconnected.");
announcedConnect = false;
}
OnDeviceLost();
SetState(default);
Thread.Sleep(1000);
}
}
private static SafeFileHandle? OpenDualSense(out string? devicePath)
{
devicePath = null;
foreach (var path in WindowsHidNative.EnumerateHidDevicePaths())
{
// Open without access rights just to query VID/PID.
using var probe = WindowsHidNative.CreateFile(
path, 0, WindowsHidNative.FileShareRead | WindowsHidNative.FileShareWrite, 0, WindowsHidNative.OpenExisting, 0, 0);
if (probe.IsInvalid)
{
continue;
}
var attributes = new WindowsHidNative.HiddAttributes { Size = 12 };
if (!WindowsHidNative.HidD_GetAttributes(probe, ref attributes) ||
attributes.VendorId != SonyVendorId ||
(attributes.ProductId != DualSenseProductId && attributes.ProductId != DualSenseEdgeProductId))
{
continue;
}
// Read+write so feature reports work; fall back to read-only.
var handle = WindowsHidNative.CreateFile(
path,
WindowsHidNative.GenericRead | WindowsHidNative.GenericWrite,
WindowsHidNative.FileShareRead | WindowsHidNative.FileShareWrite,
0, WindowsHidNative.OpenExisting, 0, 0);
if (handle.IsInvalid)
{
handle.Dispose();
handle = WindowsHidNative.CreateFile(
path,
WindowsHidNative.GenericRead,
WindowsHidNative.FileShareRead | WindowsHidNative.FileShareWrite,
0, WindowsHidNative.OpenExisting, 0, 0);
}
if (!handle.IsInvalid)
{
devicePath = path;
return handle;
}
handle.Dispose();
}
return null;
}
private static bool TryParseReport(ReadOnlySpan<byte> report, out HostGamepadState state)
{
// USB: report id 0x01, payload starts at [1].
// Bluetooth extended: report id 0x31, sequence byte at [1], payload at [2].
int offset;
if (report.Length >= 11 && report[0] == 0x01)
{
offset = 1;
}
else if (report.Length >= 12 && report[0] == 0x31)
{
offset = 2;
}
else
{
state = default;
return false;
}
var leftX = report[offset + 0];
var leftY = report[offset + 1];
var rightX = report[offset + 2];
var rightY = report[offset + 3];
var l2 = report[offset + 4];
var r2 = report[offset + 5];
var buttons0 = report[offset + 7];
var buttons1 = report[offset + 8];
var buttons2 = report[offset + 9];
var buttons = HostGamepadButtons.None;
buttons |= (buttons0 & 0x10) != 0 ? HostGamepadButtons.Square : 0;
buttons |= (buttons0 & 0x20) != 0 ? HostGamepadButtons.Cross : 0;
buttons |= (buttons0 & 0x40) != 0 ? HostGamepadButtons.Circle : 0;
buttons |= (buttons0 & 0x80) != 0 ? HostGamepadButtons.Triangle : 0;
buttons |= HatToButtons(buttons0 & 0x0F);
buttons |= (buttons1 & 0x01) != 0 ? HostGamepadButtons.L1 : 0;
buttons |= (buttons1 & 0x02) != 0 ? HostGamepadButtons.R1 : 0;
buttons |= (buttons1 & 0x04) != 0 ? HostGamepadButtons.L2 : 0;
buttons |= (buttons1 & 0x08) != 0 ? HostGamepadButtons.R2 : 0;
buttons |= (buttons1 & 0x20) != 0 ? HostGamepadButtons.Options : 0;
buttons |= (buttons1 & 0x40) != 0 ? HostGamepadButtons.L3 : 0;
buttons |= (buttons1 & 0x80) != 0 ? HostGamepadButtons.R3 : 0;
buttons |= (buttons2 & 0x02) != 0 ? HostGamepadButtons.TouchPad : 0;
state = new HostGamepadState(
Connected: true,
Buttons: buttons,
LeftX: leftX,
LeftY: leftY,
RightX: rightX,
RightY: rightY,
LeftTrigger: l2,
RightTrigger: r2);
return true;
}
private static HostGamepadButtons HatToButtons(int hat) => hat switch
{
0 => HostGamepadButtons.Up,
1 => HostGamepadButtons.Up | HostGamepadButtons.Right,
2 => HostGamepadButtons.Right,
3 => HostGamepadButtons.Right | HostGamepadButtons.Down,
4 => HostGamepadButtons.Down,
5 => HostGamepadButtons.Down | HostGamepadButtons.Left,
6 => HostGamepadButtons.Left,
7 => HostGamepadButtons.Left | HostGamepadButtons.Up,
_ => 0,
};
}
@@ -1,141 +0,0 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Runtime.InteropServices;
using Microsoft.Win32.SafeHandles;
namespace SharpEmu.HLE.Host.Windows;
/// <summary>
/// Minimal Win32 HID interop used to talk to a DualSense controller
/// directly, without any external input library.
/// </summary>
internal static partial class WindowsHidNative
{
internal const int DigcfPresent = 0x02;
internal const int DigcfDeviceInterface = 0x10;
internal const uint GenericRead = 0x80000000;
internal const uint GenericWrite = 0x40000000;
internal const uint FileShareRead = 0x1;
internal const uint FileShareWrite = 0x2;
internal const uint OpenExisting = 3;
[StructLayout(LayoutKind.Sequential)]
internal struct SpDeviceInterfaceData
{
public int CbSize;
public Guid InterfaceClassGuid;
public int Flags;
public nint Reserved;
}
[StructLayout(LayoutKind.Sequential)]
internal struct HiddAttributes
{
public int Size;
public ushort VendorId;
public ushort ProductId;
public ushort VersionNumber;
}
[LibraryImport("hid.dll")]
internal static partial void HidD_GetHidGuid(out Guid hidGuid);
[LibraryImport("hid.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
internal static partial bool HidD_GetAttributes(SafeFileHandle hidDeviceObject, ref HiddAttributes attributes);
[LibraryImport("hid.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
internal static partial bool HidD_GetFeature(SafeFileHandle hidDeviceObject, [In, Out] byte[] reportBuffer, int reportBufferLength);
[LibraryImport("setupapi.dll", EntryPoint = "SetupDiGetClassDevsW")]
internal static partial nint SetupDiGetClassDevs(ref Guid classGuid, nint enumerator, nint hwndParent, int flags);
[LibraryImport("setupapi.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
internal static partial bool SetupDiEnumDeviceInterfaces(
nint deviceInfoSet,
nint deviceInfoData,
ref Guid interfaceClassGuid,
int memberIndex,
ref SpDeviceInterfaceData deviceInterfaceData);
[LibraryImport("setupapi.dll", EntryPoint = "SetupDiGetDeviceInterfaceDetailW")]
[return: MarshalAs(UnmanagedType.Bool)]
internal static partial bool SetupDiGetDeviceInterfaceDetail(
nint deviceInfoSet,
ref SpDeviceInterfaceData deviceInterfaceData,
nint deviceInterfaceDetailData,
int deviceInterfaceDetailDataSize,
out int requiredSize,
nint deviceInfoData);
[LibraryImport("setupapi.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
internal static partial bool SetupDiDestroyDeviceInfoList(nint deviceInfoSet);
[LibraryImport("kernel32.dll", EntryPoint = "CreateFileW", SetLastError = true, StringMarshalling = StringMarshalling.Utf16)]
internal static partial SafeFileHandle CreateFile(
string fileName,
uint desiredAccess,
uint shareMode,
nint securityAttributes,
uint creationDisposition,
uint flagsAndAttributes,
nint templateFile);
/// <summary>
/// Enumerates the device paths of all present HID interfaces.
/// </summary>
internal static List<string> EnumerateHidDevicePaths()
{
var paths = new List<string>();
HidD_GetHidGuid(out var hidGuid);
var deviceInfoSet = SetupDiGetClassDevs(ref hidGuid, 0, 0, DigcfPresent | DigcfDeviceInterface);
if (deviceInfoSet == -1 || deviceInfoSet == 0)
{
return paths;
}
try
{
var interfaceData = new SpDeviceInterfaceData
{
CbSize = Marshal.SizeOf<SpDeviceInterfaceData>(),
};
for (var index = 0; SetupDiEnumDeviceInterfaces(deviceInfoSet, 0, ref hidGuid, index, ref interfaceData); index++)
{
SetupDiGetDeviceInterfaceDetail(deviceInfoSet, ref interfaceData, 0, 0, out var requiredSize, 0);
if (requiredSize <= 0)
{
continue;
}
var detailBuffer = Marshal.AllocHGlobal(requiredSize);
try
{
// SP_DEVICE_INTERFACE_DETAIL_DATA_W.cbSize is 8 on x64
// (DWORD + aligned WCHAR[1]); the path string follows it.
Marshal.WriteInt32(detailBuffer, 8);
if (SetupDiGetDeviceInterfaceDetail(deviceInfoSet, ref interfaceData, detailBuffer, requiredSize, out _, 0) &&
Marshal.PtrToStringUni(detailBuffer + 4) is { Length: > 0 } path)
{
paths.Add(path);
}
}
finally
{
Marshal.FreeHGlobal(detailBuffer);
}
}
}
finally
{
SetupDiDestroyDeviceInfoList(deviceInfoSet);
}
return paths;
}
}
@@ -1,101 +0,0 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Runtime.InteropServices;
namespace SharpEmu.HLE.Host.Windows;
/// <summary>
/// Windows input backend: DualSense over raw HID plus XInput controllers for gamepads,
/// user32 for the keyboard-fallback queries. Rumble fans out to every reader; lightbar
/// only exists on the DualSense.
/// </summary>
internal sealed partial class WindowsHostInput : IHostInput
{
public void EnsureStarted()
{
WindowsDualSenseReader.EnsureStarted();
WindowsXInputReader.EnsureStarted();
}
public int GetGamepadStates(Span<HostGamepadState> destination)
{
var count = 0;
if (count < destination.Length && WindowsDualSenseReader.TryGetState(out var dualSense))
{
destination[count++] = dualSense;
}
if (count < destination.Length && WindowsXInputReader.TryGetState(out var xinput))
{
destination[count++] = xinput;
}
return count;
}
public string? DescribeConnectedGamepad()
{
if (WindowsDualSenseReader.TryGetState(out _))
{
return "DualSense";
}
return WindowsXInputReader.TryGetState(out _) ? "Xbox controller" : null;
}
public void SetRumble(byte largeMotor, byte smallMotor)
{
WindowsDualSenseReader.SetRumble(largeMotor, smallMotor);
WindowsXInputReader.SetRumble(largeMotor, smallMotor);
}
public void SetTriggerRumble(byte? leftTrigger, byte? rightTrigger) =>
WindowsXInputReader.SetTriggerRumble(leftTrigger, rightTrigger);
public void SetLightbar(byte red, byte green, byte blue) =>
WindowsDualSenseReader.SetLightbar(red, green, blue);
public void ResetLightbar() => WindowsDualSenseReader.ResetLightbar();
public bool IsHostWindowFocused()
{
var foregroundWindow = GetForegroundWindow();
if (foregroundWindow == 0)
{
return false;
}
GetWindowThreadProcessId(foregroundWindow, out var processId);
if (processId == (uint)Environment.ProcessId)
{
return true;
}
// The GUI runs the emulator in an isolated child process. Its native
// Vulkan surface is a child of the GUI window, so the foreground
// window belongs to the launcher process rather than this one.
var embeddedHostWindow = HostSessionControl.EmbeddedHostWindow;
var hostTopLevelWindow = embeddedHostWindow == 0
? 0
: GetAncestor(embeddedHostWindow, GetAncestorRoot);
return hostTopLevelWindow != 0 && foregroundWindow == hostTopLevelWindow;
}
public bool IsKeyDown(int virtualKey) =>
(GetAsyncKeyState(virtualKey) & 0x8000) != 0;
[LibraryImport("user32.dll")]
private static partial short GetAsyncKeyState(int vKey);
[LibraryImport("user32.dll")]
private static partial nint GetForegroundWindow();
[LibraryImport("user32.dll")]
private static partial uint GetWindowThreadProcessId(nint hWnd, out uint processId);
[LibraryImport("user32.dll")]
private static partial nint GetAncestor(nint hWnd, uint gaFlags);
private const uint GetAncestorRoot = 2;
}
@@ -1,6 +1,8 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.HLE.Host.Sdl;
namespace SharpEmu.HLE.Host.Windows;
internal sealed class WindowsHostPlatform : IHostPlatform
@@ -11,7 +13,7 @@ internal sealed class WindowsHostPlatform : IHostPlatform
public IHostSymbolResolver Symbols { get; } = new WindowsHostSymbolResolver();
public IHostAudioOutput Audio { get; } = new WindowsWaveOutAudio();
public IHostAudioOutput Audio { get; } = new SdlHostAudio();
public IHostInput Input { get; } = new WindowsHostInput();
public IHostInput Input { get; } = new WindowHostInput();
}
@@ -9,7 +9,8 @@ internal sealed partial class WindowsWaveOutAudio : IHostAudioOutput
{
public string BackendName => "winmm";
public IHostAudioStream OpenStereoPcm16Stream(uint sampleRate) => new WaveOutStream(sampleRate);
public IHostAudioStream OpenStereoPcm16Stream(uint sampleRate, int maxQueuedPcmBytes = 32 * 1024) =>
new WaveOutStream(sampleRate, maxQueuedPcmBytes);
private sealed partial class WaveOutStream : IHostAudioStream
{
@@ -17,8 +18,8 @@ internal sealed partial class WindowsWaveOutAudio : IHostAudioOutput
private const uint CallbackEvent = 0x0005_0000;
private const ushort WaveFormatPcm = 1;
private const uint WaveHeaderDone = 0x0000_0001;
private const int MaximumQueuedPcmBytes = 32 * 1024;
private readonly int _maximumQueuedPcmBytes;
private readonly object _gate = new();
private readonly AutoResetEvent _completion = new(false);
private readonly Queue<NativeBuffer> _buffers = new();
@@ -26,8 +27,9 @@ internal sealed partial class WindowsWaveOutAudio : IHostAudioOutput
private int _queuedPcmBytes;
private bool _disposed;
public WaveOutStream(uint sampleRate)
public WaveOutStream(uint sampleRate, int maxQueuedPcmBytes)
{
_maximumQueuedPcmBytes = Math.Max(maxQueuedPcmBytes, 4 * 1024);
var format = new WaveFormat
{
FormatTag = WaveFormatPcm,
@@ -62,7 +64,7 @@ internal sealed partial class WindowsWaveOutAudio : IHostAudioOutput
ReapCompletedBuffers();
while (_queuedPcmBytes != 0 &&
_queuedPcmBytes + stereoPcm16.Length > MaximumQueuedPcmBytes)
_queuedPcmBytes + stereoPcm16.Length > _maximumQueuedPcmBytes)
{
if (!_completion.WaitOne(TimeSpan.FromSeconds(1)))
{
@@ -1,277 +0,0 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Runtime.InteropServices;
namespace SharpEmu.HLE.Host.Windows;
/// <summary>
/// Reads Xbox 360 / Xbox One (and other XInput-compatible) controllers via
/// the Windows XInput API on a background thread, translated to
/// <see cref="HostGamepadState"/> conventions. Supports rumble and hot-plug
/// retry; the first connected slot (of four) is used.
/// </summary>
public static partial class WindowsXInputReader
{
private const uint ErrorSuccess = 0;
private const int SlotCount = 4;
private const byte TriggerThreshold = 30; // XINPUT_GAMEPAD_TRIGGER_THRESHOLD
// XINPUT_GAMEPAD wButtons bit values.
private const ushort XinputDpadUp = 0x0001;
private const ushort XinputDpadDown = 0x0002;
private const ushort XinputDpadLeft = 0x0004;
private const ushort XinputDpadRight = 0x0008;
private const ushort XinputStart = 0x0010;
private const ushort XinputBack = 0x0020;
private const ushort XinputLeftThumb = 0x0040;
private const ushort XinputRightThumb = 0x0080;
private const ushort XinputLeftShoulder = 0x0100;
private const ushort XinputRightShoulder = 0x0200;
private const ushort XinputA = 0x1000;
private const ushort XinputB = 0x2000;
private const ushort XinputX = 0x4000;
private const ushort XinputY = 0x8000;
private static readonly object Gate = new();
private static HostGamepadState _state;
private static bool _started;
private static int _slot = -1; // connected XInput user index, -1 when none
private static byte _motorLeft;
private static byte _motorRight;
private static byte _triggerLeft;
private static byte _triggerRight;
/// <summary>Starts the background reader once; safe to call repeatedly.</summary>
public static void EnsureStarted()
{
// The GUI source-links this reader and calls it directly, without the
// host-platform resolution that otherwise guarantees Windows.
if (!OperatingSystem.IsWindows())
{
return;
}
lock (Gate)
{
if (_started)
{
return;
}
_started = true;
var thread = new Thread(ReadLoop)
{
IsBackground = true,
Name = "XInputReader",
};
thread.Start();
}
}
public static bool TryGetState(out HostGamepadState state)
{
lock (Gate)
{
state = _state;
}
return state.Connected;
}
private static void SetState(in HostGamepadState state)
{
lock (Gate)
{
_state = state;
}
}
/// <summary>Sets rumble; large = left/strong motor, small = right/weak.</summary>
internal static void SetRumble(byte largeMotor, byte smallMotor)
{
lock (Gate)
{
if (_motorLeft == largeMotor && _motorRight == smallMotor)
{
return;
}
_motorLeft = largeMotor;
_motorRight = smallMotor;
SendRumbleLocked();
}
}
/// <summary>Approximates per-trigger vibration on the two XInput body motors.</summary>
internal static void SetTriggerRumble(byte? leftTrigger, byte? rightTrigger)
{
lock (Gate)
{
var changed = false;
if (leftTrigger is { } left)
{
changed |= _triggerLeft != left;
_triggerLeft = left;
}
if (rightTrigger is { } right)
{
changed |= _triggerRight != right;
_triggerRight = right;
}
if (changed)
{
SendRumbleLocked();
}
}
}
private static void SendRumbleLocked()
{
if (_slot < 0)
{
return; // resent on connect
}
var vibration = new XInputVibration
{
LeftMotorSpeed = (ushort)(Math.Max(_motorLeft, _triggerLeft) * 257),
RightMotorSpeed = (ushort)(Math.Max(_motorRight, _triggerRight) * 257),
};
_ = XInputSetState((uint)_slot, ref vibration);
}
private static void ReadLoop()
{
try
{
while (true)
{
var slot = FindConnectedSlot();
if (slot < 0)
{
SetState(default);
Thread.Sleep(1000);
continue;
}
lock (Gate)
{
_slot = slot;
SendRumbleLocked();
}
Console.Error.WriteLine("[LOADER][INFO] XInput (Xbox) controller connected.");
while (XInputGetState((uint)slot, out var state) == ErrorSuccess)
{
SetState(Translate(state.Gamepad));
Thread.Sleep(8);
}
Console.Error.WriteLine("[LOADER][INFO] XInput (Xbox) controller disconnected.");
lock (Gate)
{
_slot = -1;
_motorLeft = 0;
_motorRight = 0;
_triggerLeft = 0;
_triggerRight = 0;
_state = default;
}
Thread.Sleep(1000);
}
}
catch (DllNotFoundException)
{
// XInput unavailable on this system; leave the reader disconnected.
}
catch (EntryPointNotFoundException)
{
}
}
private static int FindConnectedSlot()
{
for (var index = 0; index < SlotCount; index++)
{
if (XInputGetState((uint)index, out _) == ErrorSuccess)
{
return index;
}
}
return -1;
}
private static HostGamepadState Translate(in XInputGamepad pad)
{
var buttons = HostGamepadButtons.None;
buttons |= (pad.Buttons & XinputDpadUp) != 0 ? HostGamepadButtons.Up : 0;
buttons |= (pad.Buttons & XinputDpadDown) != 0 ? HostGamepadButtons.Down : 0;
buttons |= (pad.Buttons & XinputDpadLeft) != 0 ? HostGamepadButtons.Left : 0;
buttons |= (pad.Buttons & XinputDpadRight) != 0 ? HostGamepadButtons.Right : 0;
buttons |= (pad.Buttons & XinputStart) != 0 ? HostGamepadButtons.Options : 0;
buttons |= (pad.Buttons & XinputBack) != 0 ? HostGamepadButtons.TouchPad : 0;
buttons |= (pad.Buttons & XinputLeftThumb) != 0 ? HostGamepadButtons.L3 : 0;
buttons |= (pad.Buttons & XinputRightThumb) != 0 ? HostGamepadButtons.R3 : 0;
buttons |= (pad.Buttons & XinputLeftShoulder) != 0 ? HostGamepadButtons.L1 : 0;
buttons |= (pad.Buttons & XinputRightShoulder) != 0 ? HostGamepadButtons.R1 : 0;
buttons |= (pad.Buttons & XinputA) != 0 ? HostGamepadButtons.Cross : 0;
buttons |= (pad.Buttons & XinputB) != 0 ? HostGamepadButtons.Circle : 0;
buttons |= (pad.Buttons & XinputX) != 0 ? HostGamepadButtons.Square : 0;
buttons |= (pad.Buttons & XinputY) != 0 ? HostGamepadButtons.Triangle : 0;
buttons |= pad.LeftTrigger > TriggerThreshold ? HostGamepadButtons.L2 : 0;
buttons |= pad.RightTrigger > TriggerThreshold ? HostGamepadButtons.R2 : 0;
return new HostGamepadState(
Connected: true,
Buttons: buttons,
LeftX: AxisToByte(pad.ThumbLX),
LeftY: AxisToByteInverted(pad.ThumbLY),
RightX: AxisToByte(pad.ThumbRX),
RightY: AxisToByteInverted(pad.ThumbRY),
LeftTrigger: pad.LeftTrigger,
RightTrigger: pad.RightTrigger);
}
private static byte AxisToByte(short value) => (byte)((value + 32768) >> 8);
// XInput Y grows upward, host pad conventions report Y growing downward.
private static byte AxisToByteInverted(short value) => (byte)(255 - ((value + 32768) >> 8));
[StructLayout(LayoutKind.Sequential)]
private struct XInputGamepad
{
public ushort Buttons;
public byte LeftTrigger;
public byte RightTrigger;
public short ThumbLX;
public short ThumbLY;
public short ThumbRX;
public short ThumbRY;
}
[StructLayout(LayoutKind.Sequential)]
private struct XInputState
{
public uint PacketNumber;
public XInputGamepad Gamepad;
}
[StructLayout(LayoutKind.Sequential)]
private struct XInputVibration
{
public ushort LeftMotorSpeed;
public ushort RightMotorSpeed;
}
// xinput1_4.dll ships with Windows 8 and later.
[LibraryImport("xinput1_4.dll")]
private static partial uint XInputGetState(uint userIndex, out XInputState state);
[LibraryImport("xinput1_4.dll")]
private static partial uint XInputSetState(uint userIndex, ref XInputVibration vibration);
}
+2 -2
View File
@@ -6,8 +6,8 @@ using System.Collections.Concurrent;
namespace SharpEmu.HLE;
/// <summary>
/// Runs work on the real process main thread. macOS only allows AppKit (and
/// therefore GLFW windowing) on that thread, so the CLI moves emulation onto
/// Runs work on the real process main thread. macOS requires its windowing
/// event loop on that thread, so the CLI moves emulation onto
/// a worker thread, parks the main thread in <see cref="Pump"/>, and the
/// video presenter posts its window loop here. On other platforms
/// <see cref="IsAvailable"/> stays false and nothing changes.
-17
View File
@@ -12,8 +12,6 @@ public static class HostSessionControl
private static Action<string>? _shutdownHandler;
private static string? _pendingShutdownReason;
private static int _shutdownRequested;
private static long _embeddedHostWindow;
private static long _embeddedHostDisplay;
/// <summary>
/// Indicates that the active host session is being stopped. Runtime code
@@ -22,21 +20,6 @@ public static class HostSessionControl
/// </summary>
public static bool IsShutdownRequested => Volatile.Read(ref _shutdownRequested) != 0;
/// <summary>
/// Native GUI surface used by an isolated emulator child. Input backends
/// use it to treat the launcher window as the active game window.
/// </summary>
public static nint EmbeddedHostWindow => unchecked((nint)Interlocked.Read(ref _embeddedHostWindow));
/// <summary>X11 Display* paired with <see cref="EmbeddedHostWindow"/> when available.</summary>
public static nint EmbeddedHostDisplay => unchecked((nint)Interlocked.Read(ref _embeddedHostDisplay));
public static void SetEmbeddedHostSurface(nint window, nint display = 0)
{
Interlocked.Exchange(ref _embeddedHostDisplay, unchecked((long)display));
Interlocked.Exchange(ref _embeddedHostWindow, unchecked((long)window));
}
/// <summary>
/// Starts a fresh session after the previous guest has fully left its
/// execution backend.
+4
View File
@@ -21,6 +21,10 @@ SPDX-License-Identifier: GPL-2.0-or-later
<ProjectReference Include="..\SharpEmu.Logging\SharpEmu.Logging.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="ppy.SDL3-CS" />
</ItemGroup>
<ItemGroup>
<!-- Forces build ordering for the aerolib task below; loaded as a build component,
never a runtime dependency. -->
@@ -1,3 +1,4 @@
// SPDX-License-Identifier: MIT
#nullable disable
using System.IO;
using LibAtrac9.Utilities;
@@ -1,3 +1,4 @@
// SPDX-License-Identifier: MIT
#nullable disable
using System;
using LibAtrac9.Utilities;
@@ -1,3 +1,4 @@
// SPDX-License-Identifier: MIT
#nullable disable
namespace LibAtrac9
{
@@ -1,3 +1,4 @@
// SPDX-License-Identifier: MIT
#nullable disable
using System;
@@ -1,3 +1,4 @@
// SPDX-License-Identifier: MIT
#nullable disable
using System;
@@ -1,3 +1,4 @@
// SPDX-License-Identifier: MIT
#nullable disable
namespace LibAtrac9
{
@@ -1,3 +1,4 @@
// SPDX-License-Identifier: MIT
#nullable disable
using LibAtrac9.Utilities;
@@ -1,3 +1,4 @@
// SPDX-License-Identifier: MIT
#nullable disable
namespace LibAtrac9
{
@@ -1,3 +1,4 @@
// SPDX-License-Identifier: MIT
#nullable disable
namespace LibAtrac9
{
@@ -1,3 +1,4 @@
// SPDX-License-Identifier: MIT
#nullable disable
using System;
using LibAtrac9.Utilities;
@@ -1,3 +1,4 @@
// SPDX-License-Identifier: MIT
#nullable disable
namespace LibAtrac9
{
@@ -1350,4 +1351,4 @@ namespace LibAtrac9
new byte[] {0, 0, 0, 0}
};
}
}
}
@@ -1,3 +1,4 @@
// SPDX-License-Identifier: MIT
#nullable disable
using System;
@@ -1,3 +1,4 @@
// SPDX-License-Identifier: MIT
#nullable disable
using System;
using System.IO;
@@ -0,0 +1,12 @@
<!--
Copyright (C) 2018 VGMToolbox Project
SPDX-License-Identifier: MIT
-->
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<AssemblyName>SharpEmu.LibAtrac9</AssemblyName>
<RootNamespace>LibAtrac9</RootNamespace>
<GenerateDocumentationFile>false</GenerateDocumentationFile>
</PropertyGroup>
</Project>
@@ -1,3 +1,4 @@
// SPDX-License-Identifier: MIT
#nullable disable
namespace LibAtrac9
{
@@ -1,3 +1,4 @@
// SPDX-License-Identifier: MIT
#nullable disable
using System;
using static LibAtrac9.HuffmanCodebooks;
@@ -1,3 +1,4 @@
// SPDX-License-Identifier: MIT
#nullable disable
using System;
using System.IO;
@@ -1,3 +1,4 @@
// SPDX-License-Identifier: MIT
#nullable disable
namespace LibAtrac9.Utilities
{
@@ -1,3 +1,4 @@
// SPDX-License-Identifier: MIT
#nullable disable
using System;
using System.Diagnostics;
@@ -1,3 +1,4 @@
// SPDX-License-Identifier: MIT
#nullable disable
using System.Runtime.CompilerServices;
@@ -1,3 +1,4 @@
// SPDX-License-Identifier: MIT
#nullable disable
using System;
using System.Collections.Generic;
+205 -7
View File
@@ -2,13 +2,17 @@
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Buffers.Binary;
using System.Collections.Concurrent;
using SharpEmu.HLE;
namespace SharpEmu.Libs.Acm;
public static class AcmExports
{
private const int AcmBatchErrorBytes = 32;
private static readonly ConcurrentDictionary<uint, byte> Contexts = new();
private static int _nextContextHandle;
private static int _nextBatchHandle;
[SysAbiExport(
Nid = "ZIXln2K3XMk",
@@ -23,12 +27,17 @@ public static class AcmExports
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
}
var handle = (ulong)Interlocked.Increment(ref _nextContextHandle);
Span<byte> handleBytes = stackalloc byte[sizeof(ulong)];
BinaryPrimitives.WriteUInt64LittleEndian(handleBytes, handle);
return ctx.Memory.TryWrite(outContextAddress, handleBytes)
? ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_OK)
: ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
var handle = unchecked((uint)Interlocked.Increment(ref _nextContextHandle));
Span<byte> handleBytes = stackalloc byte[sizeof(uint)];
BinaryPrimitives.WriteUInt32LittleEndian(handleBytes, handle);
if (!ctx.Memory.TryWrite(outContextAddress, handleBytes))
{
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
}
Contexts[handle] = 0;
Trace($"context_create context={handle}");
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_OK);
}
[SysAbiExport(
@@ -38,7 +47,196 @@ public static class AcmExports
LibraryName = "libSceAcm")]
public static int AcmContextDestroy(CpuContext ctx)
{
_ = ctx;
var context = unchecked((uint)ctx[CpuRegister.Rdi]);
Contexts.TryRemove(context, out _);
Trace($"context_destroy context={context}");
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_OK);
}
[SysAbiExport(
Nid = "tW9W+CAG4FE",
ExportName = "sceAcmBatchStartBuffer",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceAcm")]
public static int AcmBatchStartBuffer(CpuContext ctx)
{
var context = unchecked((uint)ctx[CpuRegister.Rdi]);
var commandsAddress = ctx[CpuRegister.Rsi];
var commandsSize = ctx[CpuRegister.Rdx];
var errorAddress = ctx[CpuRegister.Rcx];
var batchAddress = ctx[CpuRegister.R8];
if (!Contexts.ContainsKey(context) ||
batchAddress == 0 ||
(commandsSize != 0 && commandsAddress == 0))
{
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
}
return CompleteBatchStart(ctx, context, 1, errorAddress, batchAddress);
}
[SysAbiExport(
Nid = "8fe55ktlNVo",
ExportName = "sceAcmBatchStartBuffers",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceAcm")]
public static int AcmBatchStartBuffers(CpuContext ctx)
{
var context = unchecked((uint)ctx[CpuRegister.Rdi]);
var infoCount = unchecked((uint)ctx[CpuRegister.Rsi]);
var infoArrayAddress = ctx[CpuRegister.Rdx];
var errorAddress = ctx[CpuRegister.Rcx];
var batchAddress = ctx[CpuRegister.R8];
if (!Contexts.ContainsKey(context) ||
batchAddress == 0 ||
(infoCount != 0 && infoArrayAddress == 0))
{
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
}
return CompleteBatchStart(ctx, context, infoCount, errorAddress, batchAddress);
}
[SysAbiExport(
Nid = "RLN3gRlXJLE",
ExportName = "sceAcmBatchWait",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceAcm")]
public static int AcmBatchWait(CpuContext ctx)
{
var context = unchecked((uint)ctx[CpuRegister.Rdi]);
return ctx.SetReturn(
Contexts.ContainsKey(context)
? OrbisGen2Result.ORBIS_GEN2_OK
: OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
}
[SysAbiExport(
Nid = "r7z5YQFZo+U",
ExportName = "sceAcmBatchJobNotification",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceAcm")]
public static int AcmBatchJobNotification(CpuContext ctx) =>
AdvanceBatchInfo(ctx, 32);
[SysAbiExport(
Nid = "u70oWo92SYQ",
ExportName = "sceAcm_ConvReverb_SharedInput",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceAcm")]
public static int AcmConvReverbSharedInput(CpuContext ctx) =>
AdvanceBatchInfo(ctx, 1024);
[SysAbiExport(
Nid = "9nLbWmRDpa8",
ExportName = "sceAcm_ConvReverb_SharedIr",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceAcm")]
public static int AcmConvReverbSharedIr(CpuContext ctx) =>
AdvanceBatchInfo(ctx, 1024);
[SysAbiExport(
Nid = "KovqaFbmtsM",
ExportName = "sceAcm_FFT",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceAcm")]
public static int AcmFft(CpuContext ctx) =>
AdvanceBatchInfo(ctx, 256);
[SysAbiExport(
Nid = "DR-ZCmvVR9Q",
ExportName = "sceAcm_IFFT",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceAcm")]
public static int AcmIfft(CpuContext ctx) =>
AdvanceBatchInfo(ctx, 256);
[SysAbiExport(
Nid = "LA4RCNKnFjg",
ExportName = "sceAcm_Panner",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceAcm")]
public static int AcmPanner(CpuContext ctx) =>
AdvanceBatchInfo(ctx, 512);
internal static void ResetForTests()
{
Contexts.Clear();
Interlocked.Exchange(ref _nextContextHandle, 0);
Interlocked.Exchange(ref _nextBatchHandle, 0);
}
private static int CompleteBatchStart(
CpuContext ctx,
uint context,
uint infoCount,
ulong errorAddress,
ulong batchAddress)
{
if (errorAddress != 0)
{
Span<byte> error = stackalloc byte[AcmBatchErrorBytes];
error.Clear();
if (!ctx.Memory.TryWrite(errorAddress, error))
{
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
}
}
var batch = unchecked((uint)Interlocked.Increment(ref _nextBatchHandle));
Span<byte> batchBytes = stackalloc byte[sizeof(uint)];
BinaryPrimitives.WriteUInt32LittleEndian(batchBytes, batch);
if (!ctx.Memory.TryWrite(batchAddress, batchBytes))
{
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
}
Trace($"batch_start context={context} count={infoCount} batch={batch}");
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_OK);
}
private static int AdvanceBatchInfo(CpuContext ctx, ulong byteCount)
{
var infoAddress = ctx[CpuRegister.Rdi];
if (infoAddress == 0)
{
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_OK);
}
Span<byte> info = stackalloc byte[24];
if (!ctx.Memory.TryRead(infoAddress, info))
{
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
}
var buffer = BinaryPrimitives.ReadUInt64LittleEndian(info);
var offset = BinaryPrimitives.ReadUInt64LittleEndian(info[8..]);
var size = BinaryPrimitives.ReadUInt64LittleEndian(info[16..]);
if (buffer != 0 && size != 0)
{
var nextOffset = offset > ulong.MaxValue - byteCount
? ulong.MaxValue
: offset + byteCount;
BinaryPrimitives.WriteUInt64LittleEndian(info[8..], Math.Min(size, nextOffset));
if (!ctx.Memory.TryWrite(infoAddress, info))
{
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
}
}
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_OK);
}
private static void Trace(string message)
{
if (string.Equals(
Environment.GetEnvironmentVariable("SHARPEMU_LOG_ACM"),
"1",
StringComparison.Ordinal))
{
Console.Error.WriteLine($"[LOADER][TRACE] acm.{message}");
}
}
}
File diff suppressed because it is too large Load Diff
+49
View File
@@ -0,0 +1,49 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Buffers.Binary;
namespace SharpEmu.Libs.Agc;
/// <summary>Prospero/AGC IndexType helpers (gpu_defs / renderDraw index8 expand).</summary>
internal static class AgcIndexHelpers
{
internal enum ProsperoIndexType : uint
{
Index16 = 0,
Index32 = 1,
Index8 = 2,
}
internal static ProsperoIndexType Decode(uint raw) =>
(raw & 0x3u) switch
{
1 => ProsperoIndexType.Index32,
2 => ProsperoIndexType.Index8,
_ => ProsperoIndexType.Index16,
};
internal static int GetGuestStrideBytes(ProsperoIndexType indexType) =>
indexType switch
{
ProsperoIndexType.Index32 => sizeof(uint),
ProsperoIndexType.Index8 => sizeof(byte),
_ => sizeof(ushort),
};
/// <summary>Expand guest u8 indices to host u16 (Vulkan/Metal bindable).</summary>
internal static void ExpandIndex8ToU16(ReadOnlySpan<byte> source, Span<byte> destination)
{
if (destination.Length < source.Length * sizeof(ushort))
{
throw new ArgumentException("destination too small for u8->u16 expansion.");
}
for (var index = 0; index < source.Length; index++)
{
BinaryPrimitives.WriteUInt16LittleEndian(
destination.Slice(index * sizeof(ushort), sizeof(ushort)),
source[index]);
}
}
}
@@ -0,0 +1,107 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
namespace SharpEmu.Libs.Agc;
/// <summary>
/// Shared Prospero/AGC primitive-type helpers (renderDraw / gpu_defs).
/// </summary>
internal static class AgcPrimitiveHelpers
{
internal const uint PrimitiveRectList = 7;
internal const uint PrimitiveRectListLegacy = 0x11;
internal enum GsOutputPrimitiveType : uint
{
Points = 0,
Lines = 1,
Triangles = 2,
Rectangle2D = 3,
RectList = 4,
}
internal static bool IsRectListPrimitive(uint primitiveType) =>
primitiveType is PrimitiveRectList or PrimitiveRectListLegacy;
/// <summary>
/// Maps draw prim type to VGT_GS_OUT_PRIM_TYPE when NGG is not enabled
/// on the GS (GraphicsPrimitiveTypeToGsOut).
/// </summary>
internal static uint PrimitiveTypeToGsOut(uint primitiveType) =>
primitiveType switch
{
1 => (uint)GsOutputPrimitiveType.Points, // PointList
2 or 3 or 10 or 11 or 18 => (uint)GsOutputPrimitiveType.Lines,
PrimitiveRectList => (uint)GsOutputPrimitiveType.Rectangle2D,
PrimitiveRectListLegacy => (uint)GsOutputPrimitiveType.RectList,
_ => (uint)GsOutputPrimitiveType.Triangles,
};
/// <summary>
/// Rect-list auto-draw topology selection.
/// NGG single-rect UI quads (DualSense prompts) submit count 1/3/4 and
/// must become a 4-vert triangle strip — even when the VS has embedded
/// vertex-buffer fetches (those still show up as host VBs). Indexed and
/// larger auto counts stay triangle list so the loading video is safe.
/// </summary>
internal static bool ShouldDrawRectListAsTriangleStrip(
uint primitiveType,
bool indexed,
uint vertexCount,
bool hasVertexBuffers = false)
{
_ = hasVertexBuffers;
if (indexed || !IsRectListPrimitive(primitiveType))
{
return false;
}
if (primitiveType == PrimitiveRectListLegacy)
{
return true;
}
// NGG kRectList: strip for auto + ngg_rectlist_draw.
// Restrict to the single-rect counts GTA UI actually submits.
return vertexCount is 1 or 3 or 4;
}
/// <summary>
/// Host vertex count for auto rect-list draws that expand to a strip.
/// NGG single-rect: always 4. Legacy 0x11: 3 -> 4.
/// </summary>
internal static uint GetRectListDrawVertexCount(
uint primitiveType,
uint vertexCount,
bool indexed,
bool hasVertexBuffers = false)
{
if (!ShouldDrawRectListAsTriangleStrip(
primitiveType,
indexed,
vertexCount,
hasVertexBuffers))
{
return vertexCount;
}
if (primitiveType == PrimitiveRectList)
{
return 4;
}
if (primitiveType == PrimitiveRectListLegacy && vertexCount == 3)
{
return 4;
}
return vertexCount;
}
/// <summary>
/// Legacy helper — prefer
/// <see cref="ShouldDrawRectListAsTriangleStrip"/>.
/// </summary>
internal static bool IsRectListTriangleStrip(uint primitiveType) =>
IsRectListPrimitive(primitiveType);
}
+788
View File
@@ -0,0 +1,788 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.HLE;
using SharpEmu.ShaderCompiler;
namespace SharpEmu.Libs.Agc;
/// <summary>
/// AGC embedded vertex metadata. Locates
/// PtrVertexBufferTable / PtrVertexAttribDescTable and builds authoritative
/// attribute layouts that draw translation merges onto IR-discovered fetches.
/// </summary>
internal static class AgcVertexMetadata
{
private const ushort IllegalDirectOffset = 0xFFFF;
private const ulong ShaderUserDataOffset = 0x08;
private const ulong ShaderInputSemanticsOffset = 0x30;
private const ulong ShaderNumInputSemanticsOffset = 0x50;
internal enum AgcDirectResourceType : uint
{
PtrVertexBufferTable = 8,
PtrVertexAttribDescTable = 10,
Last = PtrVertexAttribDescTable,
}
internal readonly record struct VertexTableRegisters(
int VertexBufferReg,
int VertexAttribReg,
uint InputSemanticsCount,
ulong InputSemanticsAddress);
/// <summary>
/// One AGC attrib-table resource.
/// Representation: <see cref="SharpBase"/> is the V# base; attribute byte
/// offset is applied as <see cref="OffsetBytes"/> (Vulkan bind offset),
/// not folded into the base — avoids double-counting when the IR prolog
/// already bumped the sharp address.
/// </summary>
internal readonly record struct MetadataVertexResource(
uint Location,
uint Semantic,
uint HardwareMapping,
uint SizeInElements,
ulong SharpBase,
uint Stride,
uint OffsetBytes,
uint DataFormat,
uint NumberFormat,
uint ComponentCount,
bool PerInstance);
/// <summary>
/// Reads AGC user-data direct-resource offsets for the ES header mapped to
/// <paramref name="shaderCodeAddress"/>. Returns false when the header is
/// unknown or the tables are absent (attribute-less clears).
/// </summary>
internal static bool TryGetVertexTableRegisters(
CpuContext ctx,
ulong shaderCodeAddress,
ulong shaderHeaderAddress,
out VertexTableRegisters registers)
{
registers = new VertexTableRegisters(-1, -1, 0, 0);
if (shaderHeaderAddress == 0 ||
!TryReadUInt64(ctx, shaderHeaderAddress + ShaderUserDataOffset, out var userDataAddress) ||
userDataAddress == 0)
{
return false;
}
// ShaderUserData layout:
// 0x00: uint16_t* direct_resource_offset
// 0x08: sharp_resource_offset[4]
// 0x28: eud_size_dw, srt_size_dw
// 0x2C: direct_resource_count
if (!TryReadUInt64(ctx, userDataAddress, out var directResourceOffset) ||
!TryReadUInt16(ctx, userDataAddress + 0x2C, out var directResourceCount))
{
return false;
}
var maxTypes = (uint)AgcDirectResourceType.Last + 1u;
if (directResourceCount > maxTypes || directResourceOffset == 0)
{
return false;
}
var vertexBufferReg = -1;
var vertexAttribReg = -1;
for (uint type = 0; type < directResourceCount; type++)
{
if (!TryReadUInt16(
ctx,
directResourceOffset + (type * sizeof(ushort)),
out var reg) ||
reg == IllegalDirectOffset)
{
continue;
}
switch ((AgcDirectResourceType)type)
{
case AgcDirectResourceType.PtrVertexBufferTable:
vertexBufferReg = reg;
break;
case AgcDirectResourceType.PtrVertexAttribDescTable:
vertexAttribReg = reg;
break;
}
}
if (vertexBufferReg < 0 || vertexAttribReg < 0)
{
return false;
}
if (!TryReadUInt64(
ctx,
shaderHeaderAddress + ShaderInputSemanticsOffset,
out var inputSemanticsAddress) ||
!TryReadUInt32(
ctx,
shaderHeaderAddress + ShaderNumInputSemanticsOffset,
out var inputSemanticsCount) ||
inputSemanticsCount == 0 ||
inputSemanticsAddress == 0)
{
return false;
}
registers = new VertexTableRegisters(
vertexBufferReg,
vertexAttribReg,
inputSemanticsCount,
inputSemanticsAddress);
return true;
}
/// <summary>
/// Builds attrib resources from AGC input_semantics + tables.
/// ShaderSemantic packing:
/// bits [7:0] semantic → attrib table index
/// bits [15:8] hardware_mapping → VGPR destination
/// bits [19:16] size_in_elements
/// </summary>
internal static bool TryBuildVertexResourcesFromMetadata(
CpuContext ctx,
IReadOnlyList<uint> scalarRegisters,
VertexTableRegisters tables,
out IReadOnlyList<MetadataVertexResource> resources)
{
resources = Array.Empty<MetadataVertexResource>();
if (tables.VertexAttribReg < 0 ||
tables.VertexBufferReg < 0 ||
tables.VertexAttribReg + 1 >= scalarRegisters.Count ||
tables.VertexBufferReg + 1 >= scalarRegisters.Count ||
tables.InputSemanticsCount == 0)
{
return false;
}
var attribTable =
((ulong)scalarRegisters[tables.VertexAttribReg + 1] << 32) |
scalarRegisters[tables.VertexAttribReg];
var bufferTable =
((ulong)scalarRegisters[tables.VertexBufferReg + 1] << 32) |
scalarRegisters[tables.VertexBufferReg];
if (attribTable == 0 || bufferTable == 0)
{
return false;
}
var built = new List<MetadataVertexResource>((int)tables.InputSemanticsCount);
for (uint i = 0; i < tables.InputSemanticsCount; i++)
{
if (!TryReadUInt32(
ctx,
tables.InputSemanticsAddress + (i * sizeof(uint)),
out var semanticWord))
{
return false;
}
// Attrib index is semantic bits [7:0], not hardware_mapping.
var semantic = semanticWord & 0xFFu;
var hardwareMapping = (semanticWord >> 8) & 0xFFu;
var sizeInElements = (semanticWord >> 16) & 0xFu;
if (!TryReadUInt32(ctx, attribTable + (semantic * sizeof(uint)), out var attribWord))
{
return false;
}
// Attrib dword: buffer index [4:0], format [13:5], offset [25:14], fetch [26].
var bufferIndex = attribWord & 0x1Fu;
var format = (attribWord >> 5) & 0x1FFu;
var offset = (attribWord >> 14) & 0xFFFu;
var fetchIndex = (attribWord >> 26) & 0x1u;
var sharpAddress = bufferTable + (bufferIndex * 16u);
if (!TryReadUInt32(ctx, sharpAddress, out var sharp0) ||
!TryReadUInt32(ctx, sharpAddress + 4, out var sharp1))
{
return false;
}
var sharpBase = sharp0 | ((ulong)(sharp1 & 0xFFFFu) << 32);
var stride = (sharp1 >> 16) & 0x3FFFu;
if (sharpBase == 0 || stride == 0)
{
continue;
}
var fallbackComponents = sizeInElements != 0 ? sizeInElements : 4u;
var (dataFormat, numberFormat, components) =
MapAttribFormat(format, fallbackComponents);
built.Add(new MetadataVertexResource(
Location: i,
Semantic: semantic,
HardwareMapping: hardwareMapping,
SizeInElements: sizeInElements,
SharpBase: sharpBase,
Stride: stride,
OffsetBytes: offset,
DataFormat: dataFormat,
NumberFormat: numberFormat,
ComponentCount: components,
PerInstance: fetchIndex != 0));
}
if (built.Count == 0)
{
return false;
}
resources = built;
return true;
}
/// <summary>
/// Patch IR-discovered fetches from the attrib table onto the V# format/offset.
/// Prefer 1:1 Location pairing when counts match on one interleaved stream
/// (GTA UI glyphs). Otherwise match by stride + byte offset. Never rebases
/// BaseAddress/Data/Location/Pc/PerInstance.
/// </summary>
internal static IReadOnlyList<Gen5VertexInputBinding> MergeVertexInputsFromMetadata(
CpuContext ctx,
IReadOnlyList<uint> scalarRegisters,
VertexTableRegisters tables,
IReadOnlyList<Gen5VertexInputBinding> discovered)
{
if (discovered.Count == 0 ||
!TryBuildVertexResourcesFromMetadata(
ctx,
scalarRegisters,
tables,
out var resources))
{
return discovered;
}
if (TryMergeByLocationPairing(discovered, resources, out var paired))
{
return paired;
}
var merged = new List<Gen5VertexInputBinding>(discovered.Count);
var usedResources = new bool[resources.Count];
var changed = false;
foreach (var input in discovered)
{
if (!TryMatchMetadataResource(input, resources, usedResources, out var resource, out var fillOffset))
{
merged.Add(input);
continue;
}
var refined = ApplyMetadataFormat(input, resource, fillOffset);
changed |= refined != input;
merged.Add(refined);
}
return changed ? merged : discovered;
}
/// <summary>
/// When discovery and metadata describe the same interleaved stream with
/// equal attribute counts, pair by sorted Location (semantic order).
/// Keeps each binding's Pc/Location for SPIR-V; overlays format + offset.
/// </summary>
private static bool TryMergeByLocationPairing(
IReadOnlyList<Gen5VertexInputBinding> discovered,
IReadOnlyList<MetadataVertexResource> resources,
out IReadOnlyList<Gen5VertexInputBinding> merged)
{
merged = discovered;
if (discovered.Count != resources.Count || discovered.Count == 0)
{
return false;
}
var orderedInputs = discovered.OrderBy(static input => input.Location).ToArray();
var orderedResources = resources.OrderBy(static resource => resource.Location).ToArray();
var streamBase = orderedResources[0].SharpBase;
var streamStride = orderedResources[0].Stride;
for (var index = 0; index < orderedResources.Length; index++)
{
var resource = orderedResources[index];
var input = orderedInputs[index];
if (resource.SharpBase != streamBase ||
resource.Stride != streamStride ||
(input.Stride != 0 && input.Stride != streamStride) ||
!IsSameVertexStream(input, resource))
{
return false;
}
}
var byPc = new Dictionary<uint, Gen5VertexInputBinding>(discovered.Count);
var changed = false;
for (var index = 0; index < orderedInputs.Length; index++)
{
var input = orderedInputs[index];
var resource = orderedResources[index];
var fillOffset = input.BaseAddress == resource.SharpBase ||
IsAddressInsideCapturedSpan(input, resource.SharpBase);
var refined = ApplyMetadataFormat(input, resource, fillOffset);
changed |= refined != input;
byPc[input.Pc] = refined;
}
if (!changed)
{
return false;
}
var result = new Gen5VertexInputBinding[discovered.Count];
for (var index = 0; index < discovered.Count; index++)
{
result[index] = byPc[discovered[index].Pc];
}
merged = result;
return true;
}
private static Gen5VertexInputBinding ApplyMetadataFormat(
Gen5VertexInputBinding input,
MetadataVertexResource resource,
bool fillOffsetBytes)
{
var components = input.ComponentCount != 0 &&
input.ComponentCount < resource.ComponentCount
? input.ComponentCount
: resource.ComponentCount;
return input with
{
DataFormat = resource.DataFormat,
NumberFormat = resource.NumberFormat,
ComponentCount = components,
OffsetBytes = fillOffsetBytes ? resource.OffsetBytes : input.OffsetBytes,
};
}
/// <summary>
/// Legacy entry point — forwards to <see cref="MergeVertexInputsFromMetadata"/>.
/// </summary>
internal static IReadOnlyList<Gen5VertexInputBinding> RefineVertexInputs(
CpuContext ctx,
IReadOnlyList<uint> scalarRegisters,
VertexTableRegisters tables,
IReadOnlyList<Gen5VertexInputBinding> discovered) =>
MergeVertexInputsFromMetadata(ctx, scalarRegisters, tables, discovered);
/// <summary>
/// Collects SBufferLoad / SLoad PCs that read the AGC attrib or buffer
/// tables (embedded-fetch prolog). Those loads are executed on the
/// CPU during scalar evaluation; once vertex inputs are bound they must
/// not run again as live SSBOs on the GPU.
/// </summary>
internal static HashSet<uint> CollectFetchPrologPcs(
Gen5ShaderProgram program,
VertexTableRegisters tables)
{
var pcs = new HashSet<uint>();
if (tables.VertexAttribReg < 0 || tables.VertexBufferReg < 0)
{
return pcs;
}
var tableRegs = new HashSet<uint>
{
(uint)tables.VertexAttribReg,
(uint)tables.VertexAttribReg + 1u,
(uint)tables.VertexBufferReg,
(uint)tables.VertexBufferReg + 1u,
};
foreach (var instruction in program.Instructions)
{
var isScalarLoad =
instruction.Opcode.StartsWith("SBufferLoad", StringComparison.Ordinal) ||
instruction.Opcode.StartsWith("SLoad", StringComparison.Ordinal);
if (!isScalarLoad)
{
continue;
}
// SMEM loads encode the scalar base pointer in Sources[0].
if (instruction.Sources.Count > 0 &&
instruction.Sources[0] is
{
Kind: Gen5OperandKind.ScalarRegister,
Value: var scalarBase,
} &&
tableRegs.Contains(scalarBase))
{
pcs.Add(instruction.Pc);
continue;
}
if (instruction.Control is Gen5BufferMemoryControl buffer &&
tableRegs.Contains(buffer.ScalarResource))
{
pcs.Add(instruction.Pc);
}
}
return pcs;
}
private static bool TryMatchMetadataResource(
Gen5VertexInputBinding input,
IReadOnlyList<MetadataVertexResource> resources,
bool[] usedResources,
out MetadataVertexResource resource,
out bool fillOffsetBytes)
{
resource = default;
fillOffsetBytes = false;
var bestScore = int.MinValue;
var bestIndex = -1;
var bestFillOffset = false;
for (var index = 0; index < resources.Count; index++)
{
if (usedResources[index])
{
continue;
}
var candidate = resources[index];
if (candidate.Stride != 0 &&
input.Stride != 0 &&
candidate.Stride != input.Stride)
{
continue;
}
if (!IsSameVertexStream(input, candidate))
{
continue;
}
var attrAddress = candidate.SharpBase + candidate.OffsetBytes;
var score = int.MinValue;
var fillOffset = false;
// Post-capture interleaved: shared BaseAddress, distinct OffsetBytes.
if (input.OffsetBytes == candidate.OffsetBytes &&
(input.BaseAddress == candidate.SharpBase ||
IsAddressInsideCapturedSpan(input, candidate.SharpBase)))
{
score = 400;
}
// IR prolog baked attrib offset into the V# base.
else if (input.BaseAddress == attrAddress)
{
score = 350;
}
// Discovery never saw the attrib offset — only safe when this
// resource's offset uniquely identifies it among unused entries.
else if (input.BaseAddress == candidate.SharpBase &&
input.OffsetBytes == 0 &&
candidate.OffsetBytes != 0 &&
IsUniqueUnusedOffset(resources, usedResources, candidate.OffsetBytes, index))
{
score = 300;
fillOffset = true;
}
else if (input.BaseAddress == candidate.SharpBase &&
input.OffsetBytes == 0 &&
candidate.OffsetBytes == 0)
{
score = 250;
}
if (score > bestScore)
{
bestScore = score;
bestIndex = index;
bestFillOffset = fillOffset;
}
}
// Require an offset-aware match. Bare SharpBase ties (score 250) are
// only accepted when a single unused resource remains for that stream.
if (bestIndex < 0 || bestScore < 300)
{
if (bestIndex < 0 || bestScore < 250)
{
return false;
}
var unusedSameStream = 0;
for (var index = 0; index < resources.Count; index++)
{
if (!usedResources[index] && IsSameVertexStream(input, resources[index]))
{
unusedSameStream++;
}
}
if (unusedSameStream != 1)
{
return false;
}
}
usedResources[bestIndex] = true;
resource = resources[bestIndex];
fillOffsetBytes = bestFillOffset;
return true;
}
private static bool IsSameVertexStream(
Gen5VertexInputBinding input,
MetadataVertexResource resource)
{
if (input.BaseAddress == resource.SharpBase ||
input.BaseAddress == resource.SharpBase + resource.OffsetBytes)
{
return true;
}
return IsAddressInsideCapturedSpan(input, resource.SharpBase);
}
private static bool IsAddressInsideCapturedSpan(
Gen5VertexInputBinding input,
ulong address) =>
input.DataLength > 0 &&
address >= input.BaseAddress &&
address < input.BaseAddress + (ulong)input.DataLength;
private static bool IsUniqueUnusedOffset(
IReadOnlyList<MetadataVertexResource> resources,
bool[] usedResources,
uint offsetBytes,
int candidateIndex)
{
for (var index = 0; index < resources.Count; index++)
{
if (index == candidateIndex || usedResources[index])
{
continue;
}
if (resources[index].OffsetBytes == offsetBytes)
{
return false;
}
}
return true;
}
/// <summary>
/// Attrib-table format
/// fields are VertexAttribFormat; V# / Vulkan paths need BufferFormat.
/// Unknown values pass through (already BufferFormat).
/// </summary>
private static uint VertexAttribFormatToBufferFormat(uint format) =>
format switch
{
0 => 0, // Invalid
4 => 1, // k8UNorm
8 => 2, // k8SNorm
12 => 3, // k8UScaled
16 => 4, // k8SScaled
20 => 5, // k8UInt
24 => 6, // k8SInt
28 => 7, // k16UNorm
32 => 8, // k16SNorm
36 => 9, // k16UScaled
40 => 10, // k16SScaled
44 => 11, // k16UInt
48 => 12, // k16SInt
52 => 13, // k16Float
57 => 14, // k8_8UNorm
61 => 15, // k8_8SNorm
65 => 16, // k8_8UScaled
69 => 17, // k8_8SScaled
73 => 18, // k8_8UInt
77 => 19, // k8_8SInt
80 => 20, // k32UInt
84 => 21, // k32SInt
88 => 22, // k32Float
93 => 23, // k16_16UNorm
97 => 24, // k16_16SNorm
101 => 25, // k16_16UScaled
105 => 26, // k16_16SScaled
109 => 27, // k16_16UInt
113 => 28, // k16_16SInt
117 => 29, // k16_16Float
122 => 30, // k11_11_10UNorm
126 => 31,
130 => 32,
134 => 33,
138 => 34,
142 => 35,
146 => 36,
150 => 37, // k10_11_11UNorm
154 => 38,
158 => 39,
162 => 40,
166 => 41,
170 => 42,
174 => 43,
179 => 44, // k2_10_10_10UNorm
183 => 45,
187 => 46,
191 => 47,
195 => 48,
199 => 49,
203 => 50, // k10_10_10_2UNorm
207 => 51,
211 => 52,
215 => 53,
219 => 54,
223 => 55,
227 => 56, // k8_8_8_8UNorm
231 => 57,
235 => 58,
239 => 59,
243 => 60,
247 => 61,
249 => 62, // k32_32UInt
253 => 63,
257 => 64, // k32_32Float
263 => 65, // k16_16_16_16UNorm
267 => 66,
271 => 67,
275 => 68,
279 => 69,
283 => 70,
287 => 71, // k16_16_16_16Float
290 => 72, // k32_32_32UInt
294 => 73,
298 => 74,
303 => 75, // k32_32_32_32UInt
307 => 76,
311 => 77, // k32_32_32_32Float
_ => format,
};
/// <summary>
/// Maps Prospero attrib-table formats onto GNM (DataFormat, NumberFormat,
/// Components) for <c>ToVkVertexFormat</c>. Accepts VertexAttribFormat
/// or BufferFormat (pass-through). NumberFormat: 0 Unorm, 1 SNorm,
/// 2 UScaled, 3 SScaled, 4 UInt, 5 SInt, 7 Float.
/// </summary>
private static (uint DataFormat, uint NumberFormat, uint Components) MapAttribFormat(
uint attribFormat,
uint fallbackComponents)
{
// Prospero VertexAttribFormat quirks before BufferFormat conversion.
if (attribFormat == 113)
{
return (14, 7, 4); // R32G32B32A32_SFLOAT
}
if (attribFormat == 121)
{
return (5, 7, 2); // R16G16_SFLOAT
}
var bufferFormat = VertexAttribFormatToBufferFormat(attribFormat);
// Prospero::BufferFormat numeric values (gpu_defs.h).
return bufferFormat switch
{
1 => (1, 0, 1), // k8UNorm
2 => (1, 1, 1), // k8SNorm
3 => (1, 2, 1), // k8UScaled
4 => (1, 3, 1), // k8SScaled
5 => (1, 4, 1), // k8UInt
6 => (1, 5, 1), // k8SInt
7 => (2, 0, 1), // k16UNorm
8 => (2, 1, 1), // k16SNorm
9 => (2, 2, 1), // k16UScaled
10 => (2, 3, 1), // k16SScaled
11 => (2, 4, 1), // k16UInt
12 => (2, 5, 1), // k16SInt
13 => (2, 7, 1), // k16Float
14 => (3, 0, 2), // k8_8UNorm
15 => (3, 1, 2), // k8_8SNorm
16 => (3, 2, 2), // k8_8UScaled
17 => (3, 3, 2), // k8_8SScaled
18 => (3, 4, 2), // k8_8UInt
19 => (3, 5, 2), // k8_8SInt
20 => (4, 4, 1), // k32UInt
21 => (4, 5, 1), // k32SInt
22 => (4, 7, 1), // k32Float
23 => (5, 0, 2), // k16_16UNorm
24 => (5, 1, 2), // k16_16SNorm
25 => (5, 2, 2), // k16_16UScaled
26 => (5, 3, 2), // k16_16SScaled
27 => (5, 4, 2), // k16_16UInt
28 => (5, 5, 2), // k16_16SInt
29 => (5, 7, 2), // k16_16Float
50 => (9, 0, 4), // k10_10_10_2UNorm
51 => (9, 1, 4), // k10_10_10_2SNorm
56 => (10, 0, 4), // k8_8_8_8UNorm
57 => (10, 1, 4), // k8_8_8_8SNorm
58 => (10, 2, 4), // k8_8_8_8UScaled
59 => (10, 3, 4), // k8_8_8_8SScaled
60 => (10, 4, 4), // k8_8_8_8UInt
61 => (10, 5, 4), // k8_8_8_8SInt
62 => (11, 4, 2), // k32_32UInt
63 => (11, 5, 2), // k32_32SInt
64 => (11, 7, 2), // k32_32Float
65 => (12, 0, 4), // k16_16_16_16UNorm
66 => (12, 1, 4), // k16_16_16_16SNorm
67 => (12, 2, 4), // k16_16_16_16UScaled
68 => (12, 3, 4), // k16_16_16_16SScaled
69 => (12, 4, 4), // k16_16_16_16UInt
70 => (12, 5, 4), // k16_16_16_16SInt
71 => (12, 7, 4), // k16_16_16_16Float
72 => (13, 4, 3), // k32_32_32UInt
73 => (13, 5, 3), // k32_32_32SInt
74 => (13, 7, 3), // k32_32_32Float
75 => (14, 4, 4), // k32_32_32_32UInt
76 => (14, 5, 4), // k32_32_32_32SInt
77 => (14, 7, 4), // k32_32_32_32Float
_ => (14, 7, Math.Clamp(fallbackComponents, 1u, 4u)),
};
}
private static bool TryReadUInt16(CpuContext ctx, ulong address, out ushort value)
{
Span<byte> buffer = stackalloc byte[2];
if (!ctx.Memory.TryRead(address, buffer))
{
value = 0;
return false;
}
value = System.Buffers.Binary.BinaryPrimitives.ReadUInt16LittleEndian(buffer);
return true;
}
private static bool TryReadUInt32(CpuContext ctx, ulong address, out uint value)
{
Span<byte> buffer = stackalloc byte[4];
if (!ctx.Memory.TryRead(address, buffer))
{
value = 0;
return false;
}
value = System.Buffers.Binary.BinaryPrimitives.ReadUInt32LittleEndian(buffer);
return true;
}
private static bool TryReadUInt64(CpuContext ctx, ulong address, out ulong value)
{
Span<byte> buffer = stackalloc byte[8];
if (!ctx.Memory.TryRead(address, buffer))
{
value = 0;
return false;
}
value = System.Buffers.Binary.BinaryPrimitives.ReadUInt64LittleEndian(buffer);
return true;
}
}
+14 -1
View File
@@ -12,7 +12,7 @@ internal enum DetileEquation
/// <summary>Unsupported mode/format; caller must use the CPU path or raw upload.</summary>
None,
/// <summary>Exact AddrLib XOR equation (RDNA2 modes 5/9/24/27): factored X/Y terms.</summary>
/// <summary>Exact AddrLib XOR equation (RDNA2 modes 1/5/9/24/27): factored X/Y terms.</summary>
ExactXor,
/// <summary>Other modes: a precomputed in-block Morton/standard element-offset table.</summary>
@@ -145,6 +145,18 @@ internal static unsafe class GnmTiling
Y(2), X(2), Y(3), X(3), Y(4), X(4), Y(5), X(5)],
];
// GFX10 256B_S: 8-bit micro-tile equation (low octet of the 4K_S pattern).
// The generic StandardSwizzle bit-interleave is a different layout and leaves
// a broken grid on Gen5 UI atlases that ship as Standard256B.
private static readonly AddressBit[][] Standard256 =
[
[X(0), X(1), X(2), X(3), Y(0), Y(1), Y(2), Y(3)],
[Zero, X(0), X(1), X(2), Y(0), Y(1), Y(2), X(3)],
[Zero, Zero, X(0), X(1), Y(0), Y(1), Y(2), X(2)],
[Zero, Zero, Zero, X(0), Y(0), Y(1), X(1), X(2)],
[Zero, Zero, Zero, Zero, Y(0), Y(1), X(0), X(1)],
];
// GFX10 4K_S has a separate 12-bit micro-tile equation. It is not the
// generic x/y interleave used by the 64K standard block; using that larger
// equation leaves a regular grid in linearized atlases.
@@ -789,6 +801,7 @@ internal static unsafe class GnmTiling
pattern = swizzleMode switch
{
1 => Standard256[bytesPerElementLog2],
5 => Standard4K[bytesPerElementLog2],
9 => RbPlus64KStandard[bytesPerElementLog2],
24 => RbPlus64KDepthX[bytesPerElementLog2],
+160
View File
@@ -0,0 +1,160 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Diagnostics;
namespace SharpEmu.Libs.Agc;
/// <summary>
/// Aggregate accounting for suspended WAIT_REG_MEM packets, enabled with
/// SHARPEMU_PROFILE_GPU_WAIT=1.
///
/// The existing <c>agc.wait_suspended</c> warning is deduplicated per label, so
/// a label that suspends every frame is reported once and then goes silent —
/// which makes the log useless for judging whether GPU waits cost frame time.
/// This counts every suspension and every resume, and reports how long the
/// queues actually sat blocked.
/// </summary>
internal static class GpuWaitProfile
{
public static readonly bool Enabled = string.Equals(
Environment.GetEnvironmentVariable("SHARPEMU_PROFILE_GPU_WAIT"),
"1",
StringComparison.Ordinal);
private static readonly double _reportSeconds =
double.TryParse(
Environment.GetEnvironmentVariable("SHARPEMU_PROFILE_GPU_WAIT_REPORT_S"),
System.Globalization.CultureInfo.InvariantCulture,
out var seconds) && seconds > 0
? seconds
: 5.0;
private static readonly object _gate = new();
private static readonly Dictionary<ulong, (long Count, double Milliseconds)> _byLabel = new();
private static long _suspensions;
private static long _resumes;
private static long _producerless;
private static long _monitorPolls;
private static long _monitorEmptyPolls;
private static double _totalWaitMilliseconds;
private static double _maxWaitMilliseconds;
private static long _windowStart = Stopwatch.GetTimestamp();
public static void RecordSuspend(bool hasProducer)
{
if (!Enabled)
{
return;
}
lock (_gate)
{
_suspensions++;
if (!hasProducer)
{
_producerless++;
}
}
}
public static void RecordResume(ulong label, double waitedMilliseconds)
{
if (!Enabled)
{
return;
}
lock (_gate)
{
_resumes++;
_totalWaitMilliseconds += waitedMilliseconds;
if (waitedMilliseconds > _maxWaitMilliseconds)
{
_maxWaitMilliseconds = waitedMilliseconds;
}
if (_byLabel.Count < 4096)
{
var existing = _byLabel.TryGetValue(label, out var entry) ? entry : default;
_byLabel[label] = (existing.Count + 1, existing.Milliseconds + waitedMilliseconds);
}
}
}
/// <summary>
/// Called once per wake of the wait monitor. An empty poll means the monitor
/// burned a wakeup without resuming anything, which is the cost of the
/// backoff loop rather than of the wait itself.
/// </summary>
public static void RecordMonitorPoll(bool resumedAny)
{
if (!Enabled)
{
return;
}
lock (_gate)
{
_monitorPolls++;
if (!resumedAny)
{
_monitorEmptyPolls++;
}
}
}
public static void ReportIfDue(int remainingWaiters)
{
if (!Enabled)
{
return;
}
string line;
lock (_gate)
{
var now = Stopwatch.GetTimestamp();
var elapsedTicks = now - _windowStart;
if (elapsedTicks < _reportSeconds * Stopwatch.Frequency)
{
return;
}
_windowStart = now;
var seconds = elapsedTicks / (double)Stopwatch.Frequency;
// Total blocked time across all queues. Above 1000ms/s the queues are
// overlapping their stalls, so compare it against the frame budget,
// not against wall time.
var top = _byLabel
.OrderByDescending(entry => entry.Value.Milliseconds)
.Take(5)
.Select(entry =>
$"0x{entry.Key:X}={entry.Value.Milliseconds / seconds:F0}ms/s" +
$"/n{entry.Value.Count}")
.ToArray();
line =
$"[PERF][GPUWAIT] {seconds:F1}s suspend/s={_suspensions / seconds:F0} " +
$"resume/s={_resumes / seconds:F0} producerless/s={_producerless / seconds:F0} " +
$"blocked_ms/s={_totalWaitMilliseconds / seconds:F0} " +
$"avg_ms={(_resumes > 0 ? _totalWaitMilliseconds / _resumes : 0):F2} " +
$"max_ms={_maxWaitMilliseconds:F1} " +
$"monitor_polls/s={_monitorPolls / seconds:F0} " +
$"empty={(_monitorPolls > 0 ? _monitorEmptyPolls * 100.0 / _monitorPolls : 0):F0}% " +
$"outstanding={remainingWaiters} top: {string.Join(" | ", top)}";
_suspensions = 0;
_resumes = 0;
_producerless = 0;
_monitorPolls = 0;
_monitorEmptyPolls = 0;
_totalWaitMilliseconds = 0;
_maxWaitMilliseconds = 0;
_byLabel.Clear();
}
Console.Error.WriteLine(line);
}
}
+60
View File
@@ -1,6 +1,8 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Diagnostics;
namespace SharpEmu.Libs.Agc;
/// <summary>
@@ -92,6 +94,64 @@ internal static class GpuWaitRegistry
}
}
public readonly record struct OutstandingSnapshot(
int Outstanding,
int Latched,
long OldestAgeMs,
ulong SampleWaitAddress,
string? SampleQueueName);
/// <summary>
/// Diagnostics snapshot of suspended WAIT_REG_MEM / dims waiters.
/// </summary>
public static OutstandingSnapshot SnapshotOutstanding(object? memory = null)
{
lock (_gate)
{
var outstanding = 0;
var latched = 0;
var oldestTicks = long.MaxValue;
ulong sampleAddress = 0;
string? sampleQueue = null;
var now = Stopwatch.GetTimestamp();
foreach (var (_, list) in _waiters)
{
foreach (var waiter in list)
{
if (memory is not null &&
!ReferenceEquals(waiter.Memory, memory))
{
continue;
}
outstanding++;
if (waiter.Latched)
{
latched++;
}
if (waiter.RegisteredTicks != 0 &&
waiter.RegisteredTicks < oldestTicks)
{
oldestTicks = waiter.RegisteredTicks;
sampleAddress = waiter.WaitAddress;
sampleQueue = waiter.QueueName;
}
}
}
var oldestAgeMs = oldestTicks == long.MaxValue || oldestTicks == 0
? 0L
: (now - oldestTicks) * 1000L / Stopwatch.Frequency;
return new OutstandingSnapshot(
outstanding,
latched,
oldestAgeMs,
sampleAddress,
sampleQueue);
}
}
public static void Register(ulong address, WaitingDcb waiter)
{
waiter.WaitAddress = address;
+15
View File
@@ -275,6 +275,19 @@ public static class AmprExports
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND;
}
// Offset -1 means "continue after the previous read of this file id".
// #216 dropped this wiring; without it sequential pack/streamer reads
// fail as INVALID_ARGUMENT and RAGE load jobs never complete while the
// North Yankton UI keeps flipping.
if (fileOffset == unchecked((ulong)(long)-1))
{
fileOffset = PakDirectoryTracker.ResolveSequentialOffset(fileId, size);
}
else if (fileOffset > long.MaxValue)
{
fileOffset = 0;
}
var result = TryReadFileToGuestMemory(ctx, hostPath, fileOffset, destination, size, out var bytesRead);
if (result != (int)OrbisGen2Result.ORBIS_GEN2_OK)
{
@@ -282,6 +295,8 @@ public static class AmprExports
return result;
}
PakDirectoryTracker.OnReadCompleted(ctx, fileId, destination, fileOffset, bytesRead);
if (!AppendReadFileRecord(ctx, commandBuffer, fileId, destination, size, fileOffset, bytesRead))
{
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
File diff suppressed because it is too large Load Diff
+231
View File
@@ -0,0 +1,231 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using NLayer;
using System.Buffers.Binary;
using System.Reflection;
namespace SharpEmu.Libs.Audio;
/// <summary>
/// Stateful AJM MP3 (codec 0) decoder. GTA menu music arrives as ~960-byte
/// packets that NLayer must decode with a persistent bit-reservoir.
/// </summary>
internal sealed class AjmMp3Decoder
{
private static readonly Type? MpegStreamReaderType =
typeof(MpegFrameDecoder).Assembly.GetType("NLayer.Decoder.MpegStreamReader");
private static readonly MethodInfo? NextFrameMethod =
MpegStreamReaderType?.GetMethod(
"NextFrame",
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
private static readonly MethodInfo? ClearBufferMethod =
typeof(MpegFrameDecoder).Assembly.GetType("NLayer.Decoder.FrameBase")
?.GetMethod("ClearBuffer", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
private readonly MpegFrameDecoder _decoder = new();
private readonly object _gate = new();
private byte[] _pending = Array.Empty<byte>();
private readonly float[] _floatScratch = new float[1152 * 2];
public ulong TotalDecodedSamples { get; private set; }
public void Reset()
{
lock (_gate)
{
_decoder.Reset();
_pending = Array.Empty<byte>();
TotalDecodedSamples = 0;
}
}
public DecodeResult Decode(ReadOnlySpan<byte> input, Span<byte> output, bool pcm16)
{
lock (_gate)
{
if (MpegStreamReaderType is null || NextFrameMethod is null)
{
return DecodeResult.Failed;
}
var merged = new byte[_pending.Length + input.Length];
if (_pending.Length != 0)
{
_pending.CopyTo(merged, 0);
}
input.CopyTo(merged.AsSpan(_pending.Length));
using var stream = new MemoryStream(merged, writable: false);
object? reader;
try
{
reader = Activator.CreateInstance(
MpegStreamReaderType,
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic,
binder: null,
args: [stream],
culture: null);
}
catch
{
return DecodeResult.Failed;
}
if (reader is null)
{
return DecodeResult.Failed;
}
var outputOffset = 0;
var inputConsumed = 0;
var frames = 0u;
var samplesThisCall = 0u;
while (outputOffset < output.Length)
{
object? frameObj;
try
{
frameObj = NextFrameMethod.Invoke(reader, null);
}
catch
{
break;
}
if (frameObj is not IMpegFrame frame)
{
break;
}
try
{
var frameOffset = GetFrameOffset(frameObj);
var frameLength = frame.FrameLength;
if (frameLength <= 0 || frameOffset + frameLength > merged.Length)
{
break;
}
int sampleCount;
try
{
sampleCount = _decoder.DecodeFrame(frame, _floatScratch, 0);
}
catch
{
_decoder.Reset();
inputConsumed = frameOffset + frameLength;
continue;
}
if (sampleCount <= 0)
{
inputConsumed = frameOffset + frameLength;
continue;
}
var channels = frame.ChannelMode == MpegChannelMode.Mono ? 1 : 2;
var bytesPerSample = pcm16 ? 2 : 4;
var byteCount = sampleCount * bytesPerSample;
if (outputOffset + byteCount > output.Length)
{
// Not enough room for this frame — leave it for next job.
break;
}
if (pcm16)
{
WritePcm16(_floatScratch.AsSpan(0, sampleCount), output[outputOffset..]);
}
else
{
WriteFloat(_floatScratch.AsSpan(0, sampleCount), output[outputOffset..]);
}
outputOffset += byteCount;
inputConsumed = frameOffset + frameLength;
frames++;
samplesThisCall += (uint)(sampleCount / Math.Max(channels, 1));
TotalDecodedSamples += (ulong)(sampleCount / Math.Max(channels, 1));
}
finally
{
try
{
ClearBufferMethod?.Invoke(frameObj, null);
}
catch
{
// best-effort
}
}
}
_pending = inputConsumed < merged.Length
? merged[inputConsumed..]
: Array.Empty<byte>();
// Consume the portion of *this* input that left the pending window.
var pendingBefore = merged.Length - input.Length;
var consumedFromInput = Math.Clamp(inputConsumed - pendingBefore, 0, input.Length);
return new DecodeResult(
Success: frames > 0 || consumedFromInput > 0,
InputConsumed: consumedFromInput,
OutputWritten: outputOffset,
Frames: frames,
SamplesThisCall: samplesThisCall);
}
}
private static int GetFrameOffset(object frameObj)
{
for (var type = frameObj.GetType(); type is not null; type = type.BaseType)
{
var prop = type.GetProperty(
"Offset",
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.DeclaredOnly);
if (prop?.GetValue(frameObj) is long offset)
{
return checked((int)offset);
}
}
return 0;
}
private static void WritePcm16(ReadOnlySpan<float> samples, Span<byte> destination)
{
for (var i = 0; i < samples.Length; i++)
{
var sample = samples[i];
var scaled = sample < 0f ? sample * 32768f : sample * 32767f;
var value = (short)Math.Clamp(MathF.Round(scaled), short.MinValue, short.MaxValue);
BinaryPrimitives.WriteInt16LittleEndian(destination[(i * 2)..], value);
}
}
private static void WriteFloat(ReadOnlySpan<float> samples, Span<byte> destination)
{
for (var i = 0; i < samples.Length; i++)
{
var bits = BitConverter.SingleToInt32Bits(samples[i]);
BinaryPrimitives.WriteInt32LittleEndian(destination[(i * 4)..], bits);
}
}
internal readonly record struct DecodeResult(
bool Success,
int InputConsumed,
int OutputWritten,
uint Frames,
uint SamplesThisCall)
{
public static DecodeResult Failed { get; } = new(false, 0, 0, 0, 0);
}
}
@@ -0,0 +1,409 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Buffers.Binary;
using LibAtrac9;
namespace SharpEmu.Libs.Audio;
internal enum Atrac9PcmEncoding
{
Signed16,
Signed32,
Float,
}
internal readonly record struct Atrac9DecodeResult(
int Status,
int InputConsumed,
int OutputWritten,
ulong TotalDecodedSamples,
uint Frames);
internal sealed class Atrac9DecodeState
{
internal const int ResultNotInitialized = 0x00000001;
internal const int ResultInvalidData = 0x00000002;
internal const int ResultInvalidParameter = 0x00000004;
internal const int ResultPartialInput = 0x00000008;
internal const int ResultNotEnoughRoom = 0x00000010;
internal const int ResultCodecError = 0x40000000;
private const int MaxContainerHeaderBytes = 8 * 1024;
private enum ContainerScan
{
NotContainer,
NeedMoreData,
Found,
}
private readonly object _gate = new();
private Atrac9Decoder? _decoder;
private byte[]? _configData;
private byte[]? _compressed;
private short[][]? _planarPcm;
private byte[]? _containerHeader;
private int _containerHeaderLength;
private int _compressedLength;
private ulong _totalDecodedSamples;
public Atrac9Config? Config
{
get
{
lock (_gate)
{
return _decoder?.Config;
}
}
}
public bool TryInitialize(ReadOnlySpan<byte> configData)
{
if (configData.Length < 4)
{
return false;
}
lock (_gate)
{
try
{
var normalizedConfig = configData[..4].ToArray();
var decoder = new Atrac9Decoder();
decoder.Initialize(normalizedConfig);
var config = decoder.Config;
_decoder = decoder;
_configData = normalizedConfig;
_compressed = new byte[config.SuperframeBytes];
_planarPcm = CreatePcmBuffer(config.ChannelCount, config.SuperframeSamples);
_compressedLength = 0;
_totalDecodedSamples = 0;
_containerHeaderLength = 0;
Trace(
$"initialized config={Convert.ToHexString(normalizedConfig)} channels={config.ChannelCount} " +
$"rate={config.SampleRate} frame_samples={config.FrameSamples} " +
$"superframe_samples={config.SuperframeSamples} superframe_bytes={config.SuperframeBytes} " +
$"frames_per_superframe={config.FramesPerSuperframe}");
return true;
}
catch (Exception exception) when (
exception is ArgumentException or InvalidDataException or InvalidOperationException)
{
Clear();
return false;
}
}
}
public void Reset()
{
lock (_gate)
{
if (_configData is null)
{
Clear();
return;
}
var decoder = new Atrac9Decoder();
decoder.Initialize((byte[])_configData.Clone());
_decoder = decoder;
_compressedLength = 0;
_totalDecodedSamples = 0;
_containerHeaderLength = 0;
if (_compressed is not null)
{
Array.Clear(_compressed);
}
}
}
public Atrac9DecodeResult Decode(
ReadOnlySpan<byte> input,
Span<byte> output,
Atrac9PcmEncoding encoding,
int requestedChannels,
bool multipleFrames)
{
lock (_gate)
{
if (_decoder is null || _compressed is null || _planarPcm is null)
{
return new Atrac9DecodeResult(
ResultNotInitialized,
0,
0,
_totalDecodedSamples,
0);
}
var config = _decoder.Config;
var channels = requestedChannels > 0 ? requestedChannels : config.ChannelCount;
if (channels is < 1 or > 16)
{
return new Atrac9DecodeResult(
ResultInvalidParameter,
0,
0,
_totalDecodedSamples,
0);
}
var bytesPerSample = GetBytesPerSample(encoding);
var outputBytesPerSuperframe = checked(config.SuperframeSamples * channels * bytesPerSample);
var consumed = 0;
var written = 0;
uint frames = 0;
var status = 0;
while (_compressedLength == config.SuperframeBytes ||
consumed < input.Length)
{
// Titles that stream whole .at9 files hand AJM the RIFF/WAVE
// container rather than a pointer into its `data` chunk, so the
// stream has to be advanced past the header before the first
// superframe — otherwise every job fails with invalid data and
// the title drops the voice. This is checked at every superframe
// boundary, not just after initialize, because a looping voice
// rewinds to the file header without reinitialising.
if (_compressedLength == 0 && (_containerHeaderLength != 0 || consumed < input.Length))
{
var scan = ScanContainerHeader(input[consumed..], out var headerBytes);
if (scan == ContainerScan.NeedMoreData)
{
consumed = input.Length;
status |= ResultPartialInput;
break;
}
if (scan == ContainerScan.Found)
{
_containerHeader = null;
_containerHeaderLength = 0;
consumed += headerBytes;
continue;
}
}
if (_compressedLength < config.SuperframeBytes)
{
var copied = Math.Min(config.SuperframeBytes - _compressedLength, input.Length - consumed);
input.Slice(consumed, copied).CopyTo(_compressed.AsSpan(_compressedLength));
_compressedLength += copied;
consumed += copied;
}
if (_compressedLength < config.SuperframeBytes)
{
status |= ResultPartialInput;
break;
}
if (output.Length - written < outputBytesPerSuperframe)
{
status |= ResultNotEnoughRoom;
break;
}
try
{
_decoder.Decode(_compressed, _planarPcm);
}
catch (Exception exception) when (
exception is ArgumentException or InvalidDataException or InvalidOperationException or IndexOutOfRangeException)
{
Trace(
$"decode_failed superframe_bytes={config.SuperframeBytes} " +
$"config={Convert.ToHexString(_configData ?? [])} " +
$"head={Convert.ToHexString(_compressed.AsSpan(0, Math.Min(16, _compressed.Length)))} " +
$"error={exception.GetType().Name}: {exception.Message}");
_compressedLength = 0;
return new Atrac9DecodeResult(
status | ResultInvalidData | ResultCodecError,
consumed,
written,
_totalDecodedSamples,
frames);
}
WriteInterleaved(
_planarPcm,
output.Slice(written, outputBytesPerSuperframe),
config.SuperframeSamples,
channels,
encoding);
written += outputBytesPerSuperframe;
_compressedLength = 0;
_totalDecodedSamples += unchecked((uint)config.SuperframeSamples);
frames += unchecked((uint)config.FramesPerSuperframe);
if (!multipleFrames)
{
break;
}
}
return new Atrac9DecodeResult(
status,
consumed,
written,
_totalDecodedSamples,
frames);
}
}
private ContainerScan ScanContainerHeader(ReadOnlySpan<byte> input, out int inputHeaderBytes)
{
inputHeaderBytes = 0;
if (_containerHeaderLength == 0 &&
(input.Length < 4 || !input[..4].SequenceEqual("RIFF"u8)))
{
return ContainerScan.NotContainer;
}
_containerHeader ??= new byte[MaxContainerHeaderBytes];
var previousLength = _containerHeaderLength;
var copied = Math.Min(input.Length, MaxContainerHeaderBytes - previousLength);
input[..copied].CopyTo(_containerHeader.AsSpan(previousLength));
var totalLength = previousLength + copied;
if (!TryFindRiffDataOffset(_containerHeader.AsSpan(0, totalLength), out var dataOffset))
{
if (totalLength >= MaxContainerHeaderBytes)
{
// Not a shape we understand — fall back to treating the stream
// as raw superframes rather than swallowing it forever. The
// scratch is dropped without advancing the caller's cursor, so
// the bytes are still decoded normally.
Trace($"container_scan_gave_up bytes={totalLength}");
_containerHeader = null;
_containerHeaderLength = 0;
return ContainerScan.NotContainer;
}
_containerHeaderLength = totalLength;
return ContainerScan.NeedMoreData;
}
inputHeaderBytes = dataOffset - previousLength;
Trace($"container_header_skipped bytes={dataOffset} from_this_input={inputHeaderBytes}");
return ContainerScan.Found;
}
private static bool TryFindRiffDataOffset(ReadOnlySpan<byte> header, out int dataOffset)
{
dataOffset = 0;
if (header.Length < 12 ||
!header[..4].SequenceEqual("RIFF"u8) ||
!header.Slice(8, 4).SequenceEqual("WAVE"u8))
{
return false;
}
var offset = 12;
while (offset + 8 <= header.Length)
{
var chunkId = header.Slice(offset, 4);
var chunkSize = BinaryPrimitives.ReadUInt32LittleEndian(header.Slice(offset + 4, 4));
offset += 8;
if (chunkId.SequenceEqual("data"u8))
{
dataOffset = offset;
return true;
}
// RIFF chunks are word aligned.
var advance = chunkSize + (chunkSize & 1);
if (advance > (ulong)(int.MaxValue - offset))
{
return false;
}
offset += (int)advance;
}
return false;
}
private static short[][] CreatePcmBuffer(int channels, int samples)
{
var result = new short[channels][];
for (var channel = 0; channel < channels; channel++)
{
result[channel] = new short[samples];
}
return result;
}
private static int GetBytesPerSample(Atrac9PcmEncoding encoding) =>
encoding switch
{
Atrac9PcmEncoding.Signed16 => sizeof(short),
Atrac9PcmEncoding.Signed32 => sizeof(int),
Atrac9PcmEncoding.Float => sizeof(float),
_ => throw new ArgumentOutOfRangeException(nameof(encoding)),
};
private static void WriteInterleaved(
short[][] source,
Span<byte> destination,
int samples,
int channels,
Atrac9PcmEncoding encoding)
{
var offset = 0;
for (var sample = 0; sample < samples; sample++)
{
for (var channel = 0; channel < channels; channel++)
{
var sourceChannel = Math.Min(channel, source.Length - 1);
var value = source[sourceChannel][sample];
switch (encoding)
{
case Atrac9PcmEncoding.Signed16:
BinaryPrimitives.WriteInt16LittleEndian(destination[offset..], value);
offset += sizeof(short);
break;
case Atrac9PcmEncoding.Signed32:
BinaryPrimitives.WriteInt32LittleEndian(destination[offset..], value << 16);
offset += sizeof(int);
break;
case Atrac9PcmEncoding.Float:
BinaryPrimitives.WriteInt32LittleEndian(
destination[offset..],
BitConverter.SingleToInt32Bits(value / 32768.0f));
offset += sizeof(float);
break;
default:
throw new ArgumentOutOfRangeException(nameof(encoding));
}
}
}
}
private static void Trace(string message)
{
if (string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_LOG_AJM"), "1", StringComparison.Ordinal))
{
Console.Error.WriteLine($"[LOADER][TRACE] ajm.at9.{message}");
}
}
private void Clear()
{
_decoder = null;
_configData = null;
_compressed = null;
_planarPcm = null;
_containerHeader = null;
_containerHeaderLength = 0;
_compressedLength = 0;
_totalDecodedSamples = 0;
}
}
File diff suppressed because it is too large Load Diff
+54 -20
View File
@@ -44,6 +44,7 @@ public static class AudioOutExports
int channels,
int bytesPerSample,
bool isFloat,
bool preservesGuestFormat,
IHostAudioStream? backend)
{
UserId = userId;
@@ -54,6 +55,7 @@ public static class AudioOutExports
Channels = channels;
BytesPerSample = bytesPerSample;
IsFloat = isFloat;
PreservesGuestFormat = preservesGuestFormat;
Backend = backend;
}
@@ -65,6 +67,7 @@ public static class AudioOutExports
public int Channels { get; }
public int BytesPerSample { get; }
public bool IsFloat { get; }
public bool PreservesGuestFormat { get; }
public IHostAudioStream? Backend { get; }
public object SubmissionGate { get; } = new();
public volatile float Volume = 1.0f;
@@ -140,6 +143,7 @@ public static class AudioOutExports
}
IHostAudioStream? backend = null;
var preservesGuestFormat = false;
string backendName;
try
{
@@ -152,7 +156,18 @@ public static class AudioOutExports
else
{
var audio = HostPlatform.Current.Audio;
backend = audio.OpenStereoPcm16Stream(frequency);
if (audio is IHostPcmAudioOutput pcmAudio)
{
backend = pcmAudio.OpenPcmStream(
frequency,
channels,
isFloat ? HostPcmFormat.Float32 : HostPcmFormat.Signed16);
preservesGuestFormat = true;
}
else
{
backend = audio.OpenStereoPcm16Stream(frequency);
}
backendName = audio.BackendName;
}
}
@@ -173,6 +188,7 @@ public static class AudioOutExports
channels,
bytesPerSample,
isFloat,
preservesGuestFormat,
backend);
Console.Error.WriteLine(
$"[LOADER][INFO] AudioOut port {handle}: {frequency} Hz, " +
@@ -212,6 +228,14 @@ public static class AudioOutExports
return ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
}
// Same rule as AudioOut2 PortGetState: never bulk-write onto the caller
// stack. Some titles place small locals next to the canary; a full
// SceAudioOutPortState write smashes it.
if (IsGuestStackAddress(stateAddress))
{
return ctx.SetReturn(0);
}
// SceAudioOutPortState: report a connected primary output at full volume
// so pacing/mixing code sees a live port. We do no host rerouting, so
// rerouteCounter and flag stay zero.
@@ -229,6 +253,9 @@ public static class AudioOutExports
return ctx.SetReturn(0);
}
private static bool IsGuestStackAddress(ulong value) =>
value >= 0x0000_7FF0_0000_0000UL && value <= 0x0000_7FFF_FFFF_FFFFUL;
[SysAbiExport(
Nid = "w3PdaSTSwGE",
ExportName = "sceAudioOutOutputs",
@@ -310,18 +337,13 @@ public static class AudioOutExports
return ctx.SetReturn(0);
}
var outputLength = checked((int)port.BufferLength * AudioPcmConversion.OutputFrameSize);
var outputLength = port.PreservesGuestFormat
? port.BufferByteLength
: checked((int)port.BufferLength * AudioPcmConversion.OutputFrameSize);
var output = ArrayPool<byte>.Shared.Rent(outputLength);
try
{
AudioPcmConversion.ConvertToStereoPcm16(
source,
output.AsSpan(0, outputLength),
checked((int)port.BufferLength),
port.Channels,
port.BytesPerSample,
port.IsFloat,
port.Volume);
ConvertForHost(port, source, output.AsSpan(0, outputLength));
if (!port.Backend.Submit(output.AsSpan(0, outputLength)))
{
port.PaceSilence();
@@ -438,17 +460,11 @@ public static class AudioOutExports
TraceOutput(output.Handle, output.Port, source);
output.HostBufferLength = checked(
(int)output.Port.BufferLength * AudioPcmConversion.OutputFrameSize);
output.HostBufferLength = output.Port.PreservesGuestFormat
? output.Port.BufferByteLength
: checked((int)output.Port.BufferLength * AudioPcmConversion.OutputFrameSize);
output.HostBuffer = ArrayPool<byte>.Shared.Rent(output.HostBufferLength);
AudioPcmConversion.ConvertToStereoPcm16(
source,
output.HostBuffer.AsSpan(0, output.HostBufferLength),
checked((int)output.Port.BufferLength),
output.Port.Channels,
output.Port.BytesPerSample,
output.Port.IsFloat,
output.Port.Volume);
ConvertForHost(output.Port, source, output.HostBuffer.AsSpan(0, output.HostBufferLength));
}
finally
{
@@ -502,6 +518,24 @@ public static class AudioOutExports
(ulong)candidate.BufferLength * current.Frequency >
(ulong)current.BufferLength * candidate.Frequency;
private static void ConvertForHost(PortState port, ReadOnlySpan<byte> source, Span<byte> destination)
{
if (port.PreservesGuestFormat)
{
AudioPcmConversion.CopyWithVolume(source, destination, port.IsFloat, port.Volume);
return;
}
AudioPcmConversion.ConvertToStereoPcm16(
source,
destination,
checked((int)port.BufferLength),
port.Channels,
port.BytesPerSample,
port.IsFloat,
port.Volume);
}
private static void TraceOutput(int handle, PortState port, ReadOnlySpan<byte> source)
{
if (!_traceOutput)
@@ -43,6 +43,46 @@ internal static class AudioPcmConversion
}
}
/// <summary>
/// Copies interleaved PCM without changing its channel layout. SDL can convert
/// this directly to the physical device, which preserves surround mixes that
/// would otherwise be truncated to the first two guest channels.
/// </summary>
public static void CopyWithVolume(
ReadOnlySpan<byte> source,
Span<byte> destination,
bool isFloat,
float volume)
{
var clampedVolume = Math.Clamp(volume, 0.0f, 1.0f);
if (clampedVolume >= 1.0f)
{
source.CopyTo(destination);
return;
}
if (isFloat)
{
for (var offset = 0; offset < source.Length; offset += sizeof(float))
{
var sample = BinaryPrimitives.ReadSingleLittleEndian(source.Slice(offset, sizeof(float)));
BinaryPrimitives.WriteSingleLittleEndian(
destination.Slice(offset, sizeof(float)),
sample * clampedVolume);
}
return;
}
for (var offset = 0; offset < source.Length; offset += sizeof(short))
{
var sample = BinaryPrimitives.ReadInt16LittleEndian(source.Slice(offset, sizeof(short)));
BinaryPrimitives.WriteInt16LittleEndian(
destination.Slice(offset, sizeof(short)),
ApplyVolume(sample, clampedVolume));
}
}
private static short ReadSample(
ReadOnlySpan<byte> frame,
int channel,
+3 -1
View File
@@ -133,10 +133,12 @@ internal static class Bink2MovieBridge
if (_playback.IsFinished)
{
var completedPath = _activePath;
var progress = _playback.PlaybackProgress;
CloseActiveLocked();
Console.Error.WriteLine(
"[LOADER][INFO] Bink2 bridge completed: " +
Path.GetFileName(completedPath));
$"{Path.GetFileName(completedPath)} after " +
$"{progress.Seconds:F2}s at frame {progress.FrameIndex}");
AttachNextQueuedMovieLocked();
}
return false;
+129 -7
View File
@@ -2,6 +2,7 @@
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Diagnostics;
using SharpEmu.HLE.Host;
namespace SharpEmu.Libs.Bink;
@@ -36,6 +37,8 @@ internal sealed class BinkFramePlayback : IDisposable
private long _currentFrameIndex = -1;
private long _nextDecodedFrameIndex;
private long _playbackStartTimestamp;
private double _audioStartSeconds;
private long _lastSkewTraceTimestamp;
private bool _playbackClockStarted;
private bool _decoderCompleted;
private bool _stopRequested;
@@ -83,6 +86,26 @@ internal sealed class BinkFramePlayback : IDisposable
}
}
/// <summary>
/// Wall-clock seconds since the first frame was presented, and the index of
/// the last frame shown. Playback is on its own time base when these agree
/// with the movie's frame rate.
/// </summary>
internal (double Seconds, long FrameIndex) PlaybackProgress
{
get
{
lock (_gate)
{
return (
_playbackClockStarted
? Stopwatch.GetElapsedTime(_playbackStartTimestamp).TotalSeconds
: 0,
_currentFrameIndex);
}
}
}
internal bool TryGetFrame(
bool advanceClock,
out byte[] pixels,
@@ -118,14 +141,13 @@ internal sealed class BinkFramePlayback : IDisposable
if (advanceClock && !_playbackClockStarted)
{
_playbackStartTimestamp = Stopwatch.GetTimestamp();
_audioStartSeconds = GuestAudioClock.PlayedSeconds;
_playbackClockStarted = true;
}
var elapsedSeconds = _playbackClockStarted
? Stopwatch.GetElapsedTime(_playbackStartTimestamp).TotalSeconds
: 0;
var targetFrameIndex = (long)Math.Floor(
elapsedSeconds * FramesPerSecondNumerator / FramesPerSecondDenominator);
var elapsedSeconds = CurrentPlaybackSecondsLocked();
TraceClockSkewLocked();
var targetFrameIndex = CurrentTargetFrameIndexLocked();
DecodedFrame? replacement = null;
while (_decodedFrames.Count > 0 &&
_decodedFrames.Peek().Index <= targetFrameIndex)
@@ -166,6 +188,86 @@ internal sealed class BinkFramePlayback : IDisposable
}
}
/// <summary>
/// Time base for playback. A host-decoded movie runs on whatever clock it is
/// given, but the audio that belongs to it comes from the guest, which does
/// not advance at wall-clock rate on a slow frame. Following the audio keeps
/// the two together; SHARPEMU_MOVIE_CLOCK=wall restores the old behaviour.
/// </summary>
private static readonly bool _followGuestAudioClock = !string.Equals(
Environment.GetEnvironmentVariable("SHARPEMU_MOVIE_CLOCK"),
"wall",
StringComparison.OrdinalIgnoreCase);
/// <summary>
/// Seconds of playback elapsed on the movie's time base. Falls back to wall
/// clock whenever guest audio is not flowing: a movie whose audio never
/// starts — or stops early — must still finish rather than hang on a clock
/// that will never advance again.
/// </summary>
private double CurrentPlaybackSecondsLocked()
{
if (!_playbackClockStarted)
{
return 0;
}
var wallSeconds = Stopwatch.GetElapsedTime(_playbackStartTimestamp).TotalSeconds;
if (!_followGuestAudioClock || !GuestAudioClock.IsRunning)
{
return wallSeconds;
}
return Math.Clamp(GuestAudioClock.PlayedSeconds - _audioStartSeconds, 0, wallSeconds);
}
private static readonly bool _traceClockSkew = string.Equals(
Environment.GetEnvironmentVariable("SHARPEMU_LOG_MOVIE_SYNC"),
"1",
StringComparison.Ordinal);
/// <summary>
/// Logs how far the movie's wall clock has drifted from the guest audio the
/// movie is supposed to be in step with. A skew that is flat across playback
/// is a late audio start; one that grows is a rate mismatch, and the two need
/// different fixes. Caller holds <see cref="_gate"/>.
/// </summary>
private void TraceClockSkewLocked()
{
if (!_traceClockSkew || !_playbackClockStarted)
{
return;
}
var now = Stopwatch.GetTimestamp();
if (_lastSkewTraceTimestamp != 0 &&
Stopwatch.GetElapsedTime(_lastSkewTraceTimestamp) < TimeSpan.FromSeconds(1))
{
return;
}
_lastSkewTraceTimestamp = now;
var wallSeconds = Stopwatch.GetElapsedTime(_playbackStartTimestamp).TotalSeconds;
var audioSeconds = GuestAudioClock.PlayedSeconds - _audioStartSeconds;
Console.Error.WriteLine(
$"[PERF][MOVIE] wall_s={wallSeconds:F2} audio_s={audioSeconds:F2} " +
$"playback_s={CurrentPlaybackSecondsLocked():F2} " +
$"skew_s={wallSeconds - audioSeconds:F2} frame={_currentFrameIndex} " +
$"audio_running={GuestAudioClock.IsRunning}");
}
/// <summary>
/// The frame the movie's own time base says should be on screen right now.
/// Returns -1 until the first frame is presented, so the queue prefills
/// instead of instantly declaring everything late.
/// </summary>
private long CurrentTargetFrameIndexLocked() =>
_playbackClockStarted
? (long)Math.Floor(
CurrentPlaybackSecondsLocked() *
FramesPerSecondNumerator / FramesPerSecondDenominator)
: -1;
private void DecodeLoop()
{
try
@@ -199,8 +301,28 @@ internal sealed class BinkFramePlayback : IDisposable
lock (_gate)
{
_decodedFrames.Enqueue(new DecodedFrame(
_nextDecodedFrameIndex++, destination));
var frameIndex = _nextDecodedFrameIndex++;
// Frames are pulled once per guest flip, so a title running
// well under the movie's frame rate cannot drain a queue
// this shallow fast enough and the movie stretches past its
// real duration — audio finishes while the last picture sits
// on screen and the next movie starts late. Once the clock
// has passed a queued frame it can never be shown, so retire
// it in favour of this newer one. Only superseded frames are
// dropped, never the newest, so a decoder that cannot keep
// up still advances the picture instead of freezing it.
var targetFrameIndex = CurrentTargetFrameIndexLocked();
if (frameIndex <= targetFrameIndex)
{
while (_decodedFrames.Count > 0 &&
_decodedFrames.Peek().Index <= targetFrameIndex)
{
_freeBuffers.Enqueue(_decodedFrames.Dequeue().Pixels);
}
}
_decodedFrames.Enqueue(new DecodedFrame(frameIndex, destination));
Monitor.PulseAll(_gate);
}
}
@@ -1,7 +1,9 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Buffers;
using FFmpeg.AutoGen;
using SharpEmu.HLE.Host;
namespace SharpEmu.Libs.Bink;
@@ -13,13 +15,29 @@ namespace SharpEmu.Libs.Bink;
/// </summary>
internal sealed unsafe class FfmpegNativeBinkFrameSource : IBinkFrameDecoder
{
private const int OutputAudioChannels = 2;
private const int OutputAudioBytesPerSample = sizeof(short);
private readonly object _decodeGate = new();
private AVFormatContext* _formatContext;
private AVCodecContext* _codecContext;
private AVCodecContext* _audioCodecContext;
private SwsContext* _swsContext;
private SwrContext* _swrContext;
private AVFrame* _frame;
private AVFrame* _audioFrame;
private AVPacket* _packet;
private IHostAudioStream? _audioStream;
private readonly int _videoStreamIndex;
private readonly int _audioStreamIndex;
private readonly int _audioOutputSampleRate;
private AVChannelLayout _swrInputLayout;
private AVSampleFormat _swrInputFormat = AVSampleFormat.AV_SAMPLE_FMT_NONE;
private int _swrInputSampleRate;
private bool _swrInputLayoutValid;
private bool _draining;
private bool _audioDraining;
private bool _audioFailed;
private int _disposed;
public uint Width { get; }
@@ -34,6 +52,10 @@ internal sealed unsafe class FfmpegNativeBinkFrameSource : IBinkFrameDecoder
AVFormatContext* formatContext,
AVCodecContext* codecContext,
int videoStreamIndex,
AVCodecContext* audioCodecContext,
int audioStreamIndex,
IHostAudioStream? audioStream,
int audioOutputSampleRate,
uint width,
uint height,
uint framesPerSecondNumerator,
@@ -42,11 +64,16 @@ internal sealed unsafe class FfmpegNativeBinkFrameSource : IBinkFrameDecoder
_formatContext = formatContext;
_codecContext = codecContext;
_videoStreamIndex = videoStreamIndex;
_audioCodecContext = audioCodecContext;
_audioStreamIndex = audioStreamIndex;
_audioStream = audioStream;
_audioOutputSampleRate = audioOutputSampleRate;
Width = width;
Height = height;
FramesPerSecondNumerator = framesPerSecondNumerator;
FramesPerSecondDenominator = framesPerSecondDenominator;
_frame = ffmpeg.av_frame_alloc();
_audioFrame = audioCodecContext is null ? null : ffmpeg.av_frame_alloc();
_packet = ffmpeg.av_packet_alloc();
}
@@ -93,6 +120,8 @@ internal sealed unsafe class FfmpegNativeBinkFrameSource : IBinkFrameDecoder
AVFormatContext* formatContext = null;
AVCodecContext* codecContext = null;
AVCodecContext* audioCodecContext = null;
IHostAudioStream? audioStream = null;
try
{
if (ffmpeg.avformat_open_input(&formatContext, path, null, null) < 0)
@@ -151,6 +180,28 @@ internal sealed unsafe class FfmpegNativeBinkFrameSource : IBinkFrameDecoder
frameRate = new AVRational { num = 30, den = 1 };
}
var audioStreamIndex = TryOpenAudioDecoder(
formatContext,
out audioCodecContext,
out var audioOutputSampleRate);
if (audioStreamIndex >= 0 && audioCodecContext is not null)
{
try
{
audioStream = HostPlatform.Current.Audio.OpenStereoPcm16Stream(
checked((uint)audioOutputSampleRate));
}
catch (Exception exception) when (exception is InvalidOperationException or
ArgumentOutOfRangeException)
{
Console.Error.WriteLine(
$"[LOADER][WARN] Bink audio output unavailable: {exception.Message}");
ffmpeg.avcodec_free_context(&audioCodecContext);
audioStreamIndex = -1;
audioOutputSampleRate = 0;
}
}
var outputWidth = (uint)codecContext->width;
var outputHeight = (uint)codecContext->height;
if (maximumWidth > 0 && maximumHeight > 0 &&
@@ -175,12 +226,18 @@ internal sealed unsafe class FfmpegNativeBinkFrameSource : IBinkFrameDecoder
formatContext,
codecContext,
videoStreamIndex,
audioCodecContext,
audioStreamIndex,
audioStream,
audioOutputSampleRate,
outputWidth,
outputHeight,
(uint)frameRate.num,
(uint)frameRate.den);
formatContext = null;
codecContext = null;
audioCodecContext = null;
audioStream = null;
return true;
}
catch (DllNotFoundException)
@@ -194,6 +251,13 @@ internal sealed unsafe class FfmpegNativeBinkFrameSource : IBinkFrameDecoder
ffmpeg.avcodec_free_context(&codecContext);
}
if (audioCodecContext is not null)
{
ffmpeg.avcodec_free_context(&audioCodecContext);
}
audioStream?.Dispose();
if (formatContext is not null)
{
ffmpeg.avformat_close_input(&formatContext);
@@ -201,52 +265,102 @@ internal sealed unsafe class FfmpegNativeBinkFrameSource : IBinkFrameDecoder
}
}
private static int TryOpenAudioDecoder(
AVFormatContext* formatContext,
out AVCodecContext* codecContext,
out int outputSampleRate)
{
codecContext = null;
outputSampleRate = 0;
AVCodec* decoder = null;
var streamIndex = ffmpeg.av_find_best_stream(
formatContext, AVMediaType.AVMEDIA_TYPE_AUDIO, -1, -1, &decoder, 0);
if (streamIndex < 0 || decoder is null)
{
return -1;
}
var candidate = ffmpeg.avcodec_alloc_context3(decoder);
if (candidate is null)
{
return -1;
}
var stream = formatContext->streams[streamIndex];
if (ffmpeg.avcodec_parameters_to_context(candidate, stream->codecpar) < 0)
{
ffmpeg.avcodec_free_context(&candidate);
return -1;
}
candidate->thread_count = 0;
candidate->thread_type = ffmpeg.FF_THREAD_FRAME | ffmpeg.FF_THREAD_SLICE;
if (ffmpeg.avcodec_open2(candidate, decoder, null) < 0)
{
ffmpeg.avcodec_free_context(&candidate);
return -1;
}
outputSampleRate = candidate->sample_rate > 0 ? candidate->sample_rate : 48_000;
codecContext = candidate;
return streamIndex;
}
public bool TryDecodeNextFrame(Span<byte> destination)
{
var stride = checked((int)(Width * 4));
var required = (long)stride * Height;
if (destination.Length < required)
lock (_decodeGate)
{
return false;
}
if (Volatile.Read(ref _disposed) != 0)
{
return false;
}
if (!TryReceiveFrame())
{
return false;
}
var stride = checked((int)(Width * 4));
var required = (long)stride * Height;
if (destination.Length < required)
{
return false;
}
_swsContext = ffmpeg.sws_getCachedContext(
_swsContext,
_frame->width,
_frame->height,
(AVPixelFormat)_frame->format,
(int)Width,
(int)Height,
AVPixelFormat.AV_PIX_FMT_BGRA,
ffmpeg.SWS_FAST_BILINEAR,
null,
null,
null);
if (_swsContext is null)
{
ffmpeg.av_frame_unref(_frame);
return false;
}
if (!TryReceiveFrame())
{
return false;
}
fixed (byte* destinationPointer = destination)
{
var destinationPlanes = new byte*[4] { destinationPointer, null, null, null };
var destinationStrides = new int[4] { stride, 0, 0, 0 };
var convertedRows = ffmpeg.sws_scale(
_swsContext = ffmpeg.sws_getCachedContext(
_swsContext,
_frame->data,
_frame->linesize,
0,
_frame->width,
_frame->height,
destinationPlanes,
destinationStrides);
ffmpeg.av_frame_unref(_frame);
return convertedRows == (int)Height;
(AVPixelFormat)_frame->format,
(int)Width,
(int)Height,
AVPixelFormat.AV_PIX_FMT_BGRA,
ffmpeg.SWS_FAST_BILINEAR,
null,
null,
null);
if (_swsContext is null)
{
ffmpeg.av_frame_unref(_frame);
return false;
}
fixed (byte* destinationPointer = destination)
{
var destinationPlanes = new byte*[4] { destinationPointer, null, null, null };
var destinationStrides = new int[4] { stride, 0, 0, 0 };
var convertedRows = ffmpeg.sws_scale(
_swsContext,
_frame->data,
_frame->linesize,
0,
_frame->height,
destinationPlanes,
destinationStrides);
ffmpeg.av_frame_unref(_frame);
return convertedRows == (int)Height;
}
}
}
@@ -291,9 +405,17 @@ internal sealed unsafe class FfmpegNativeBinkFrameSource : IBinkFrameDecoder
{
_draining = true;
ffmpeg.avcodec_send_packet(_codecContext, null);
DrainAudioDecoder();
return true;
}
if (_packet->stream_index == _audioStreamIndex)
{
DecodeAudioPacket(_packet);
ffmpeg.av_packet_unref(_packet);
continue;
}
if (_packet->stream_index != _videoStreamIndex)
{
ffmpeg.av_packet_unref(_packet);
@@ -311,6 +433,248 @@ internal sealed unsafe class FfmpegNativeBinkFrameSource : IBinkFrameDecoder
}
}
private void DecodeAudioPacket(AVPacket* packet)
{
if (_audioCodecContext is null || _audioFrame is null || _audioFailed)
{
return;
}
var sendResult = ffmpeg.avcodec_send_packet(_audioCodecContext, packet);
if (sendResult == ffmpeg.AVERROR(ffmpeg.EAGAIN))
{
DrainAvailableAudioFrames();
sendResult = ffmpeg.avcodec_send_packet(_audioCodecContext, packet);
}
if (sendResult < 0)
{
DisableAudio("packet decode failed");
return;
}
DrainAvailableAudioFrames();
}
private void DrainAudioDecoder()
{
if (_audioCodecContext is null || _audioFrame is null ||
_audioDraining || _audioFailed)
{
return;
}
_audioDraining = true;
var sendResult = ffmpeg.avcodec_send_packet(_audioCodecContext, null);
if (sendResult >= 0 || sendResult == ffmpeg.AVERROR(ffmpeg.EAGAIN))
{
DrainAvailableAudioFrames();
}
}
private void DrainAvailableAudioFrames()
{
while (_audioCodecContext is not null && _audioFrame is not null)
{
var receiveResult = ffmpeg.avcodec_receive_frame(_audioCodecContext, _audioFrame);
if (receiveResult == ffmpeg.AVERROR(ffmpeg.EAGAIN) ||
receiveResult == ffmpeg.AVERROR_EOF)
{
return;
}
if (receiveResult < 0)
{
DisableAudio("frame decode failed");
return;
}
if (!SubmitAudioFrame())
{
ffmpeg.av_frame_unref(_audioFrame);
DisableAudio("host submission failed");
return;
}
ffmpeg.av_frame_unref(_audioFrame);
}
}
private bool SubmitAudioFrame()
{
if (_audioStream is null || _audioFrame is null ||
_audioFrame->nb_samples <= 0 || _audioFrame->extended_data is null)
{
return true;
}
var sampleRate = _audioFrame->sample_rate > 0
? _audioFrame->sample_rate
: _audioCodecContext->sample_rate;
if (sampleRate <= 0)
{
return false;
}
var inputLayout = _audioFrame->ch_layout;
var ownsInputLayout = false;
if (ffmpeg.av_channel_layout_check(&inputLayout) == 0)
{
inputLayout = _audioCodecContext->ch_layout;
}
if (ffmpeg.av_channel_layout_check(&inputLayout) == 0)
{
ffmpeg.av_channel_layout_default(
&inputLayout,
Math.Max(1, _audioFrame->ch_layout.nb_channels));
ownsInputLayout = true;
}
try
{
if (!EnsureAudioResampler(
&inputLayout,
(AVSampleFormat)_audioFrame->format,
sampleRate))
{
return false;
}
var maximumSamples = ffmpeg.swr_get_out_samples(
_swrContext, _audioFrame->nb_samples);
if (maximumSamples <= 0)
{
return true;
}
var outputBytes = checked(
maximumSamples * OutputAudioChannels * OutputAudioBytesPerSample);
var buffer = ArrayPool<byte>.Shared.Rent(outputBytes);
try
{
fixed (byte* output = buffer)
{
var outputPlanes = stackalloc byte*[1];
outputPlanes[0] = output;
var convertedSamples = ffmpeg.swr_convert(
_swrContext,
outputPlanes,
maximumSamples,
_audioFrame->extended_data,
_audioFrame->nb_samples);
if (convertedSamples < 0)
{
return false;
}
var convertedBytes = checked(
convertedSamples * OutputAudioChannels * OutputAudioBytesPerSample);
return _audioStream.Submit(buffer.AsSpan(0, convertedBytes));
}
}
finally
{
ArrayPool<byte>.Shared.Return(buffer);
}
}
finally
{
if (ownsInputLayout)
{
ffmpeg.av_channel_layout_uninit(&inputLayout);
}
}
}
private bool EnsureAudioResampler(
AVChannelLayout* inputLayout,
AVSampleFormat inputFormat,
int inputSampleRate)
{
var storedInputLayout = _swrInputLayout;
if (_swrContext is not null &&
_swrInputFormat == inputFormat &&
_swrInputSampleRate == inputSampleRate &&
ffmpeg.av_channel_layout_compare(&storedInputLayout, inputLayout) == 0)
{
return true;
}
FreeAudioResampler();
AVChannelLayout copiedInputLayout = default;
if (ffmpeg.av_channel_layout_copy(&copiedInputLayout, inputLayout) < 0)
{
return false;
}
AVChannelLayout outputLayout = default;
ffmpeg.av_channel_layout_default(&outputLayout, OutputAudioChannels);
SwrContext* context = null;
var allocateResult = ffmpeg.swr_alloc_set_opts2(
&context,
&outputLayout,
AVSampleFormat.AV_SAMPLE_FMT_S16,
_audioOutputSampleRate,
&copiedInputLayout,
inputFormat,
inputSampleRate,
0,
null);
ffmpeg.av_channel_layout_uninit(&outputLayout);
if (allocateResult < 0 || context is null || ffmpeg.swr_init(context) < 0)
{
if (context is not null)
{
ffmpeg.swr_free(&context);
}
ffmpeg.av_channel_layout_uninit(&copiedInputLayout);
return false;
}
_swrContext = context;
_swrInputLayout = copiedInputLayout;
_swrInputLayoutValid = true;
_swrInputFormat = inputFormat;
_swrInputSampleRate = inputSampleRate;
return true;
}
private void DisableAudio(string reason)
{
if (_audioFailed)
{
return;
}
_audioFailed = true;
Console.Error.WriteLine($"[LOADER][WARN] Bink audio disabled: {reason}.");
FreeAudioResampler();
_audioStream?.Dispose();
_audioStream = null;
}
private void FreeAudioResampler()
{
if (_swrContext is not null)
{
var context = _swrContext;
ffmpeg.swr_free(&context);
_swrContext = null;
}
if (_swrInputLayoutValid)
{
var inputLayout = _swrInputLayout;
ffmpeg.av_channel_layout_uninit(&inputLayout);
_swrInputLayout = default;
_swrInputLayoutValid = false;
}
_swrInputFormat = AVSampleFormat.AV_SAMPLE_FMT_NONE;
_swrInputSampleRate = 0;
}
public void Dispose()
{
if (Interlocked.Exchange(ref _disposed, 1) != 0)
@@ -318,38 +682,59 @@ internal sealed unsafe class FfmpegNativeBinkFrameSource : IBinkFrameDecoder
return;
}
if (_swsContext is not null)
lock (_decodeGate)
{
ffmpeg.sws_freeContext(_swsContext);
_swsContext = null;
}
FreeAudioResampler();
_audioStream?.Dispose();
_audioStream = null;
if (_packet is not null)
{
var packet = _packet;
ffmpeg.av_packet_free(&packet);
_packet = null;
}
if (_swsContext is not null)
{
ffmpeg.sws_freeContext(_swsContext);
_swsContext = null;
}
if (_frame is not null)
{
var frame = _frame;
ffmpeg.av_frame_free(&frame);
_frame = null;
}
if (_packet is not null)
{
var packet = _packet;
ffmpeg.av_packet_free(&packet);
_packet = null;
}
if (_codecContext is not null)
{
var codecContext = _codecContext;
ffmpeg.avcodec_free_context(&codecContext);
_codecContext = null;
}
if (_frame is not null)
{
var frame = _frame;
ffmpeg.av_frame_free(&frame);
_frame = null;
}
if (_formatContext is not null)
{
var formatContext = _formatContext;
ffmpeg.avformat_close_input(&formatContext);
_formatContext = null;
if (_audioFrame is not null)
{
var frame = _audioFrame;
ffmpeg.av_frame_free(&frame);
_audioFrame = null;
}
if (_codecContext is not null)
{
var codecContext = _codecContext;
ffmpeg.avcodec_free_context(&codecContext);
_codecContext = null;
}
if (_audioCodecContext is not null)
{
var codecContext = _audioCodecContext;
ffmpeg.avcodec_free_context(&codecContext);
_audioCodecContext = null;
}
if (_formatContext is not null)
{
var formatContext = _formatContext;
ffmpeg.avformat_close_input(&formatContext);
_formatContext = null;
}
}
}
}
+609 -15
View File
@@ -2,28 +2,84 @@
// SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.HLE;
using SharpEmu.Libs.Audio;
using SharpEmu.Libs.Kernel;
using System.Buffers;
using System.Buffers.Binary;
using System.Collections.Concurrent;
using System.Threading;
namespace SharpEmu.Libs.Codec;
/// <summary>
/// libSceVideodec / libSceAudiodec handle management. Actual H.264/HEVC and
/// AAC/AT9 decoding requires an external codec, which is out of scope; these
/// exports keep the decoder lifecycle resolvable (create/decode/flush/delete)
/// and report "no output produced" so guests advance instead of failing on
/// unresolved imports.
/// libSceVideodec / libSceAudiodec compatibility exports.
/// </summary>
public static class CodecExports
{
private const int Ok = 0;
private const int VideodecErrorInvalidArg = unchecked((int)0x80620801);
private const int AudiodecErrorInvalidType = unchecked((int)0x807F0001);
private const int AudiodecErrorInvalidArg = unchecked((int)0x807F0002);
private const int AudiodecErrorInvalidParamSize = unchecked((int)0x807F0004);
private const int AudiodecErrorInvalidBsiInfoSize = unchecked((int)0x807F0005);
private const int AudiodecErrorInvalidAuInfoSize = unchecked((int)0x807F0006);
private const int AudiodecErrorInvalidPcmItemSize = unchecked((int)0x807F0007);
private const int AudiodecErrorInvalidCtrlPointer = unchecked((int)0x807F0008);
private const int AudiodecErrorInvalidParamPointer = unchecked((int)0x807F0009);
private const int AudiodecErrorInvalidBsiInfoPointer = unchecked((int)0x807F000A);
private const int AudiodecErrorInvalidAuInfoPointer = unchecked((int)0x807F000B);
private const int AudiodecErrorInvalidPcmItemPointer = unchecked((int)0x807F000C);
private const int AudiodecErrorInvalidAuPointer = unchecked((int)0x807F000D);
private const int AudiodecErrorInvalidPcmPointer = unchecked((int)0x807F000E);
private const int AudiodecErrorInvalidHandle = unchecked((int)0x807F000F);
private const int AudiodecErrorInvalidWordLength = unchecked((int)0x807F0010);
private const int AudiodecErrorInvalidAuSize = unchecked((int)0x807F0011);
private const int AudiodecErrorInvalidPcmSize = unchecked((int)0x807F0012);
private const uint AudiodecTypeAt9 = 1;
private const uint AudiodecTypeMp3 = 2;
private const uint AudiodecTypeAac = 3;
private const int MaxAudioDecoders = 64;
private const int MaxDecodeBufferBytes = 64 * 1024 * 1024;
private static readonly ConcurrentDictionary<ulong, byte> VideoDecoders = new();
private static readonly ConcurrentDictionary<ulong, byte> AudioDecoders = new();
private static readonly ConcurrentDictionary<int, AudioDecoderState> AudioDecoders = new();
private static readonly ConcurrentDictionary<uint, int> AudioCodecInitCounts = new();
private static readonly object AudioDecoderGate = new();
private static long _nextHandle = 1;
private static int _nextAudioHandle;
private sealed class AudioDecoderState
{
public required uint CodecType { get; init; }
public required int WordSize { get; init; }
public required int Channels { get; init; }
public required int SampleRate { get; init; }
public required int FrameBytes { get; init; }
public required int FramesPerSuperframe { get; init; }
public required int FrameSamples { get; init; }
public Atrac9DecodeState? Atrac9 { get; init; }
}
private readonly record struct AudioControl(
ulong ParamAddress,
ulong BsiInfoAddress,
ulong AuInfoAddress,
ulong PcmItemAddress,
ulong AuAddress,
uint AuSize,
ulong PcmAddress,
uint PcmSize,
int WordSize,
byte[]? Atrac9Config,
uint AacMaxChannels,
uint AacSampleRateIndex);
// ---- Video decoder ----
@@ -65,34 +121,572 @@ public static class CodecExports
// ---- Audio decoder ----
[SysAbiExport(Nid = "VjhsmxpcezI", ExportName = "sceAudiodecInitLibrary",
Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceAudiodec")]
public static int AudiodecInitLibrary(CpuContext ctx)
{
var codecType = unchecked((uint)ctx[CpuRegister.Rdi]);
if (!IsValidAudioCodecType(codecType))
{
return SetReturn(ctx, AudiodecErrorInvalidType);
}
AudioCodecInitCounts.AddOrUpdate(codecType, 1, static (_, count) => count + 1);
return SetReturn(ctx, Ok);
}
[SysAbiExport(Nid = "h5jSB2QIDV0", ExportName = "sceAudiodecTermLibrary",
Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceAudiodec")]
public static int AudiodecTermLibrary(CpuContext ctx)
{
var codecType = unchecked((uint)ctx[CpuRegister.Rdi]);
if (!IsValidAudioCodecType(codecType))
{
return SetReturn(ctx, AudiodecErrorInvalidType);
}
AudioCodecInitCounts.AddOrUpdate(codecType, 0, static (_, count) => Math.Max(0, count - 1));
return SetReturn(ctx, Ok);
}
[SysAbiExport(Nid = "O3f1sLMWRvs", ExportName = "sceAudiodecCreateDecoder",
Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceAudiodec")]
public static int AudiodecCreateDecoder(CpuContext ctx)
{
var handle = (ulong)Interlocked.Increment(ref _nextHandle);
AudioDecoders[handle] = 1;
// sceAudiodec returns the handle directly (>= 0) or a negative error.
ctx[CpuRegister.Rax] = handle;
return unchecked((int)handle);
var controlAddress = ctx[CpuRegister.Rdi];
var codecType = unchecked((uint)ctx[CpuRegister.Rsi]);
if (!IsValidAudioCodecType(codecType))
{
return SetReturn(ctx, AudiodecErrorInvalidType);
}
var validation = TryReadAudioControl(ctx, controlAddress, codecType, decode: false, out var control);
if (validation != Ok)
{
return SetReturn(ctx, validation);
}
if (!AudioCodecInitCounts.TryGetValue(codecType, out var initCount) || initCount == 0)
{
return SetReturn(ctx, AudiodecErrorInvalidArg);
}
var decoder = CreateAudioDecoder(codecType, control);
if (decoder is null)
{
return SetReturn(ctx, AudiodecErrorInvalidArg);
}
int handle;
lock (AudioDecoderGate)
{
if (AudioDecoders.Count >= MaxAudioDecoders)
{
return SetReturn(ctx, AudiodecErrorInvalidArg);
}
do
{
_nextAudioHandle = _nextAudioHandle % MaxAudioDecoders + 1;
handle = _nextAudioHandle;
}
while (AudioDecoders.ContainsKey(handle));
AudioDecoders[handle] = decoder;
}
WriteAudioDecoderInfo(ctx, control.BsiInfoAddress, decoder, Ok);
return SetReturn(ctx, handle);
}
[SysAbiExport(Nid = "KHXHMDLkILw", ExportName = "sceAudiodecDecode",
Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceAudiodec")]
public static int AudiodecDecode(CpuContext ctx)
{
// No decoder present: report success with zero output samples so the
// caller treats the frame as silent rather than erroring.
return SetReturn(ctx, AudioDecoders.ContainsKey(ctx[CpuRegister.Rdi]) ? Ok : AudiodecErrorInvalidArg);
var handle = unchecked((int)ctx[CpuRegister.Rdi]);
if (!AudioDecoders.TryGetValue(handle, out var decoder))
{
return SetReturn(ctx, AudiodecErrorInvalidHandle);
}
var validation = TryReadAudioControl(
ctx,
ctx[CpuRegister.Rsi],
decoder.CodecType,
decode: true,
out var control);
if (validation != Ok)
{
return SetReturn(ctx, validation);
}
if (control.AuSize > MaxDecodeBufferBytes)
{
return SetReturn(ctx, AudiodecErrorInvalidAuSize);
}
if (control.PcmSize > MaxDecodeBufferBytes)
{
return SetReturn(ctx, AudiodecErrorInvalidPcmSize);
}
if (decoder.CodecType == AudiodecTypeAt9 && decoder.Atrac9 is not null)
{
DecodeAtrac9(ctx, decoder, control);
}
else
{
DecodeAudioSilence(ctx, decoder, control);
}
return SetReturn(ctx, Ok);
}
[SysAbiExport(Nid = "Tp+ZEy69mLk", ExportName = "sceAudiodecDeleteDecoder",
Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceAudiodec")]
public static int AudiodecDeleteDecoder(CpuContext ctx)
{
AudioDecoders.TryRemove(ctx[CpuRegister.Rdi], out _);
var handle = unchecked((int)ctx[CpuRegister.Rdi]);
return SetReturn(
ctx,
AudioDecoders.TryRemove(handle, out _)
? Ok
: AudiodecErrorInvalidHandle);
}
[SysAbiExport(Nid = "6Vf9WTLDoss", ExportName = "sceAudiodecClearContext",
Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceAudiodec")]
public static int AudiodecClearContext(CpuContext ctx)
{
var handle = unchecked((int)ctx[CpuRegister.Rdi]);
if (!AudioDecoders.TryGetValue(handle, out var decoder))
{
return SetReturn(ctx, AudiodecErrorInvalidHandle);
}
decoder.Atrac9?.Reset();
return SetReturn(ctx, Ok);
}
private static bool IsValidAudioCodecType(uint codecType) =>
codecType is AudiodecTypeAt9 or AudiodecTypeMp3 or AudiodecTypeAac;
private static int TryReadAudioControl(
CpuContext ctx,
ulong controlAddress,
uint codecType,
bool decode,
out AudioControl control)
{
control = default;
if (controlAddress == 0)
{
return AudiodecErrorInvalidCtrlPointer;
}
Span<byte> controlData = stackalloc byte[32];
if (!ctx.Memory.TryRead(controlAddress, controlData))
{
return AudiodecErrorInvalidCtrlPointer;
}
var paramAddress = BinaryPrimitives.ReadUInt64LittleEndian(controlData);
var bsiInfoAddress = BinaryPrimitives.ReadUInt64LittleEndian(controlData[8..]);
var auInfoAddress = BinaryPrimitives.ReadUInt64LittleEndian(controlData[16..]);
var pcmItemAddress = BinaryPrimitives.ReadUInt64LittleEndian(controlData[24..]);
if (paramAddress == 0)
{
return AudiodecErrorInvalidParamPointer;
}
if (bsiInfoAddress == 0)
{
return AudiodecErrorInvalidBsiInfoPointer;
}
if (auInfoAddress == 0)
{
return AudiodecErrorInvalidAuInfoPointer;
}
if (pcmItemAddress == 0)
{
return AudiodecErrorInvalidPcmItemPointer;
}
Span<byte> auInfo = stackalloc byte[24];
if (!ctx.Memory.TryRead(auInfoAddress, auInfo))
{
return AudiodecErrorInvalidAuInfoPointer;
}
if (BinaryPrimitives.ReadUInt32LittleEndian(auInfo) != auInfo.Length)
{
return AudiodecErrorInvalidAuInfoSize;
}
Span<byte> pcmItem = stackalloc byte[24];
if (!ctx.Memory.TryRead(pcmItemAddress, pcmItem))
{
return AudiodecErrorInvalidPcmItemPointer;
}
if (BinaryPrimitives.ReadUInt32LittleEndian(pcmItem) != pcmItem.Length)
{
return AudiodecErrorInvalidPcmItemSize;
}
var auAddress = BinaryPrimitives.ReadUInt64LittleEndian(auInfo[8..]);
var auSize = BinaryPrimitives.ReadUInt32LittleEndian(auInfo[16..]);
var pcmAddress = BinaryPrimitives.ReadUInt64LittleEndian(pcmItem[8..]);
var pcmSize = BinaryPrimitives.ReadUInt32LittleEndian(pcmItem[16..]);
if (decode && auAddress == 0)
{
return AudiodecErrorInvalidAuPointer;
}
if (decode && pcmAddress == 0)
{
return AudiodecErrorInvalidPcmPointer;
}
var parameterResult = TryReadAudioParameters(
ctx,
codecType,
paramAddress,
bsiInfoAddress,
out var wordSize,
out var atrac9Config,
out var aacMaxChannels,
out var aacSampleRateIndex);
if (parameterResult != Ok)
{
return parameterResult;
}
if (decode && auSize == 0)
{
return AudiodecErrorInvalidAuSize;
}
if (decode && pcmSize == 0)
{
return AudiodecErrorInvalidPcmSize;
}
control = new AudioControl(
paramAddress,
bsiInfoAddress,
auInfoAddress,
pcmItemAddress,
auAddress,
auSize,
pcmAddress,
pcmSize,
wordSize,
atrac9Config,
aacMaxChannels,
aacSampleRateIndex);
return Ok;
}
private static int TryReadAudioParameters(
CpuContext ctx,
uint codecType,
ulong paramAddress,
ulong bsiInfoAddress,
out int wordSize,
out byte[]? atrac9Config,
out uint aacMaxChannels,
out uint aacSampleRateIndex)
{
wordSize = 0;
atrac9Config = null;
aacMaxChannels = 0;
aacSampleRateIndex = 0;
var paramSize = codecType switch
{
AudiodecTypeAt9 => 12,
AudiodecTypeMp3 => 8,
AudiodecTypeAac => 24,
_ => 0,
};
var bsiSize = codecType switch
{
AudiodecTypeAt9 => 36,
AudiodecTypeMp3 => 24,
AudiodecTypeAac => 20,
_ => 0,
};
if (paramSize == 0 || bsiSize == 0)
{
return AudiodecErrorInvalidType;
}
Span<byte> param = stackalloc byte[paramSize];
if (!ctx.Memory.TryRead(paramAddress, param))
{
return AudiodecErrorInvalidParamPointer;
}
var suppliedParamSize = BinaryPrimitives.ReadUInt32LittleEndian(param);
if (codecType == AudiodecTypeAac
? suppliedParamSize < paramSize
: suppliedParamSize != paramSize)
{
return AudiodecErrorInvalidParamSize;
}
Span<byte> bsi = stackalloc byte[bsiSize];
if (!ctx.Memory.TryRead(bsiInfoAddress, bsi))
{
return AudiodecErrorInvalidBsiInfoPointer;
}
if (BinaryPrimitives.ReadUInt32LittleEndian(bsi) != bsiSize)
{
return AudiodecErrorInvalidBsiInfoSize;
}
wordSize = BinaryPrimitives.ReadInt32LittleEndian(param[4..]);
if (wordSize is < 0 or > 2)
{
return AudiodecErrorInvalidWordLength;
}
if (codecType == AudiodecTypeAt9)
{
atrac9Config = param[8..12].ToArray();
}
else if (codecType == AudiodecTypeAac)
{
aacSampleRateIndex = BinaryPrimitives.ReadUInt32LittleEndian(param[12..]);
aacMaxChannels = BinaryPrimitives.ReadUInt32LittleEndian(param[16..]);
}
return Ok;
}
private static AudioDecoderState? CreateAudioDecoder(uint codecType, AudioControl control)
{
if (codecType == AudiodecTypeAt9)
{
var atrac9 = new Atrac9DecodeState();
if (control.Atrac9Config is null || !atrac9.TryInitialize(control.Atrac9Config))
{
return null;
}
var config = atrac9.Config!;
return new AudioDecoderState
{
CodecType = codecType,
WordSize = control.WordSize,
Channels = config.ChannelCount,
SampleRate = config.SampleRate,
FrameBytes = config.SuperframeBytes,
FramesPerSuperframe = config.FramesPerSuperframe,
FrameSamples = config.FrameSamples,
Atrac9 = atrac9,
};
}
if (codecType == AudiodecTypeMp3)
{
return new AudioDecoderState
{
CodecType = codecType,
WordSize = control.WordSize,
Channels = 2,
SampleRate = 48_000,
FrameBytes = 1_441,
FramesPerSuperframe = 1,
FrameSamples = 1_152,
};
}
var channels = control.AacMaxChannels == 0
? 2
: unchecked((int)Math.Min(control.AacMaxChannels, 8));
return new AudioDecoderState
{
CodecType = codecType,
WordSize = control.WordSize,
Channels = channels,
SampleRate = GetAacSampleRate(control.AacSampleRateIndex),
FrameBytes = 4_608,
FramesPerSuperframe = 1,
FrameSamples = 2_048,
};
}
private static void DecodeAtrac9(CpuContext ctx, AudioDecoderState decoder, AudioControl control)
{
var input = ArrayPool<byte>.Shared.Rent(checked((int)control.AuSize));
var output = ArrayPool<byte>.Shared.Rent(checked((int)control.PcmSize));
try
{
if (!ctx.Memory.TryRead(control.AuAddress, input.AsSpan(0, checked((int)control.AuSize))))
{
WriteAudioDecoderInfo(ctx, control.BsiInfoAddress, decoder, AudiodecErrorInvalidAuPointer);
WriteAudioBufferSizes(ctx, control, 0, 0);
return;
}
var result = decoder.Atrac9!.Decode(
input.AsSpan(0, checked((int)control.AuSize)),
output.AsSpan(0, checked((int)control.PcmSize)),
GetAtrac9Encoding(decoder.WordSize),
decoder.Channels,
multipleFrames: false);
var outputWritten = result.OutputWritten;
if (outputWritten != 0 &&
!ctx.Memory.TryWrite(control.PcmAddress, output.AsSpan(0, outputWritten)))
{
outputWritten = 0;
}
WriteAudioBufferSizes(
ctx,
control,
unchecked((uint)result.InputConsumed),
unchecked((uint)outputWritten));
WriteAudioDecoderInfo(ctx, control.BsiInfoAddress, decoder, result.Status);
}
finally
{
ArrayPool<byte>.Shared.Return(input);
ArrayPool<byte>.Shared.Return(output);
}
}
private static void DecodeAudioSilence(CpuContext ctx, AudioDecoderState decoder, AudioControl control)
{
var wantedPcm = checked(
decoder.FrameSamples *
decoder.Channels *
GetAudioBytesPerSample(decoder.WordSize));
var outputSize = Math.Min(control.PcmSize, unchecked((uint)wantedPcm));
ClearGuestMemory(ctx, control.PcmAddress, outputSize);
WriteAudioBufferSizes(
ctx,
control,
Math.Min(control.AuSize, unchecked((uint)decoder.FrameBytes)),
outputSize);
WriteAudioDecoderInfo(ctx, control.BsiInfoAddress, decoder, Ok);
}
private static void WriteAudioBufferSizes(
CpuContext ctx,
AudioControl control,
uint inputConsumed,
uint outputWritten)
{
Span<byte> value = stackalloc byte[sizeof(uint)];
BinaryPrimitives.WriteUInt32LittleEndian(value, inputConsumed);
_ = ctx.Memory.TryWrite(control.AuInfoAddress + 16, value);
BinaryPrimitives.WriteUInt32LittleEndian(value, outputWritten);
_ = ctx.Memory.TryWrite(control.PcmItemAddress + 16, value);
}
private static void WriteAudioDecoderInfo(
CpuContext ctx,
ulong infoAddress,
AudioDecoderState decoder,
int result)
{
if (decoder.CodecType == AudiodecTypeAt9)
{
Span<byte> info = stackalloc byte[36];
info.Clear();
BinaryPrimitives.WriteUInt32LittleEndian(info, unchecked((uint)info.Length));
BinaryPrimitives.WriteUInt32LittleEndian(info[4..], unchecked((uint)decoder.Channels));
var superframeSamples = checked(decoder.FrameSamples * decoder.FramesPerSuperframe);
var bitrate = superframeSamples == 0
? 0u
: unchecked((uint)((ulong)decoder.FrameBytes * 8 * (uint)decoder.SampleRate /
(uint)superframeSamples));
BinaryPrimitives.WriteUInt32LittleEndian(info[8..], bitrate);
BinaryPrimitives.WriteUInt32LittleEndian(info[12..], unchecked((uint)decoder.SampleRate));
BinaryPrimitives.WriteUInt32LittleEndian(info[16..], unchecked((uint)decoder.FrameBytes));
BinaryPrimitives.WriteUInt32LittleEndian(info[20..], unchecked((uint)decoder.FramesPerSuperframe));
BinaryPrimitives.WriteUInt32LittleEndian(
info[24..],
unchecked((uint)(decoder.FrameBytes / Math.Max(decoder.FramesPerSuperframe, 1))));
BinaryPrimitives.WriteUInt32LittleEndian(info[28..], unchecked((uint)decoder.FrameSamples));
BinaryPrimitives.WriteInt32LittleEndian(info[32..], result);
_ = ctx.Memory.TryWrite(infoAddress, info);
return;
}
if (decoder.CodecType == AudiodecTypeMp3)
{
Span<byte> info = stackalloc byte[24];
info.Clear();
BinaryPrimitives.WriteUInt32LittleEndian(info, unchecked((uint)info.Length));
BinaryPrimitives.WriteInt32LittleEndian(info[20..], result);
_ = ctx.Memory.TryWrite(infoAddress, info);
return;
}
Span<byte> aacInfo = stackalloc byte[20];
aacInfo.Clear();
BinaryPrimitives.WriteUInt32LittleEndian(aacInfo, unchecked((uint)aacInfo.Length));
BinaryPrimitives.WriteUInt32LittleEndian(aacInfo[4..], unchecked((uint)decoder.SampleRate));
BinaryPrimitives.WriteUInt32LittleEndian(aacInfo[8..], unchecked((uint)decoder.Channels));
BinaryPrimitives.WriteInt32LittleEndian(aacInfo[16..], result);
_ = ctx.Memory.TryWrite(infoAddress, aacInfo);
}
private static Atrac9PcmEncoding GetAtrac9Encoding(int wordSize) =>
wordSize switch
{
0 => Atrac9PcmEncoding.Signed32,
1 => Atrac9PcmEncoding.Signed16,
2 => Atrac9PcmEncoding.Float,
_ => throw new ArgumentOutOfRangeException(nameof(wordSize)),
};
private static int GetAudioBytesPerSample(int wordSize) =>
wordSize == 1 ? sizeof(short) : sizeof(int);
private static int GetAacSampleRate(uint index)
{
ReadOnlySpan<int> rates =
[
96_000, 88_200, 64_000, 48_000, 44_100, 32_000,
24_000, 22_050, 16_000, 12_000, 11_025, 8_000,
];
return index < rates.Length ? rates[unchecked((int)index)] : 48_000;
}
private static void ClearGuestMemory(CpuContext ctx, ulong address, uint byteCount)
{
Span<byte> zero = stackalloc byte[256];
var cursor = address;
var remaining = byteCount;
while (remaining != 0)
{
var length = unchecked((int)Math.Min(remaining, (uint)zero.Length));
if (!ctx.Memory.TryWrite(cursor, zero[..length]))
{
return;
}
cursor += unchecked((uint)length);
remaining -= unchecked((uint)length);
}
}
internal static void ResetAudioDecodersForTests()
{
AudioDecoders.Clear();
AudioCodecInitCounts.Clear();
Interlocked.Exchange(ref _nextAudioHandle, 0);
}
private static bool TryWriteHandle(CpuContext ctx, ulong address, ulong handle) =>
ctx.TryWriteUInt64(address, handle);
@@ -0,0 +1,155 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Diagnostics;
using System.Threading;
using SharpEmu.Libs.Agc;
namespace SharpEmu.Libs.Diagnostics;
/// <summary>
/// Rate-limited progress probes armed when GTA's 'North Audio Update' thread
/// starts. Used to classify North Yankton freezes (flip vs present vs GPU wait)
/// without enabling full AGC/VideoOut trace.
/// </summary>
public static class LoadProgressDiagnostics
{
// Keep probes live long enough to cover a stuck Yankton session.
private const long ActiveWindowMs = 120_000;
private static long _armedTicks;
private static long _flipSubmitTraceCount;
private static long _orderedFlipEnqueueTraceCount;
private static long _presentTakenTraceCount;
private static long _presentNotTakenTraceCount;
private static long _gpuWaitSnapshotTraceCount;
public static void ArmIfNorthAudioThread(string? threadName)
{
if (string.IsNullOrEmpty(threadName) ||
threadName.IndexOf("North Audio", StringComparison.OrdinalIgnoreCase) < 0)
{
return;
}
if (Interlocked.CompareExchange(
ref _armedTicks,
Stopwatch.GetTimestamp(),
0) == 0)
{
Console.Error.WriteLine(
"[LOADER][TRACE] load_progress.armed reason=north_audio " +
$"window_ms={ActiveWindowMs}");
}
}
public static bool IsActive
{
get
{
var armed = Volatile.Read(ref _armedTicks);
if (armed == 0)
{
return false;
}
var elapsedMs = (Stopwatch.GetTimestamp() - armed) * 1000L /
Stopwatch.Frequency;
return elapsedMs <= ActiveWindowMs;
}
}
public static void TraceFlipSubmit(
int handle,
int bufferIndex,
int flipMode,
bool submitGpuImage,
bool guestImageSubmitted,
ulong guestImageAddress,
int flipEventCount)
{
if (!IsActive || !ShouldTrace(ref _flipSubmitTraceCount, out var count))
{
return;
}
Console.Error.WriteLine(
$"[LOADER][TRACE] load_progress.flip_submit count={count} " +
$"handle={handle} index={bufferIndex} mode={flipMode} " +
$"gpu_image={submitGpuImage} submitted={guestImageSubmitted} " +
$"addr=0x{guestImageAddress:X16} events={flipEventCount}");
}
public static void TraceOrderedFlipEnqueue(
int videoOutHandle,
int displayBufferIndex,
ulong address,
long version,
bool enqueued)
{
if (!IsActive ||
!ShouldTrace(ref _orderedFlipEnqueueTraceCount, out var count))
{
return;
}
Console.Error.WriteLine(
$"[LOADER][TRACE] load_progress.ordered_flip count={count} " +
$"handle={videoOutHandle} index={displayBufferIndex} " +
$"addr=0x{address:X16} version={version} enqueued={enqueued}");
}
public static void TracePresentTaken(
long presentedSequence,
ulong guestImageAddress,
long guestImageVersion)
{
if (!IsActive || !ShouldTrace(ref _presentTakenTraceCount, out var count))
{
return;
}
Console.Error.WriteLine(
$"[LOADER][TRACE] load_progress.present_taken count={count} " +
$"seq={presentedSequence} addr=0x{guestImageAddress:X16} " +
$"version={guestImageVersion}");
}
public static void TracePresentNotTaken(
long presentedSequence,
bool hasPendingPresentation)
{
if (!IsActive ||
!ShouldTrace(ref _presentNotTakenTraceCount, out var count))
{
return;
}
Console.Error.WriteLine(
$"[LOADER][TRACE] load_progress.present_not_taken count={count} " +
$"seq={presentedSequence} pending={hasPendingPresentation}");
}
public static void TraceGpuWaitSnapshot(object? memory = null)
{
if (!IsActive ||
!ShouldTrace(ref _gpuWaitSnapshotTraceCount, out var count))
{
return;
}
var snapshot = GpuWaitRegistry.SnapshotOutstanding(memory);
Console.Error.WriteLine(
$"[LOADER][TRACE] load_progress.gpu_waits count={count} " +
$"outstanding={snapshot.Outstanding} latched={snapshot.Latched} " +
$"oldest_ms={snapshot.OldestAgeMs} " +
$"sample_addr=0x{snapshot.SampleWaitAddress:X16} " +
$"sample_queue={snapshot.SampleQueueName ?? "-"}");
}
private static bool ShouldTrace(ref long counter, out long count)
{
count = Interlocked.Increment(ref counter);
return count <= 16 || (count & (count - 1)) == 0;
}
}
+2 -1
View File
@@ -89,7 +89,8 @@ internal sealed record GuestVertexBuffer(
uint OffsetBytes,
byte[] Data,
int Length,
bool Pooled);
bool Pooled,
bool PerInstance = false);
internal sealed record GuestIndexBuffer(
byte[] Data,
+26 -3
View File
@@ -54,6 +54,7 @@ internal interface IGuestGpuBackend
int scalarRegisterBufferIndex = -1,
uint pixelInputEnable = 0,
uint pixelInputAddress = 0,
IReadOnlyList<uint>? pixelInputCntl = null,
ulong storageBufferOffsetAlignment = 1);
bool TryCompileComputeShader(
@@ -108,7 +109,8 @@ internal interface IGuestGpuBackend
GuestIndexBuffer? indexBuffer = null,
IReadOnlyList<GuestVertexBuffer>? vertexBuffers = null,
GuestRenderState? renderState = null,
ulong shaderAddress = 0);
ulong shaderAddress = 0,
int baseVertex = 0);
void SubmitOffscreenTranslatedDraw(
IGuestCompiledShader pixelShader,
@@ -124,7 +126,8 @@ internal interface IGuestGpuBackend
IReadOnlyList<GuestVertexBuffer>? vertexBuffers = null,
GuestRenderState? renderState = null,
GuestDepthTarget? depthTarget = null,
ulong shaderAddress = 0);
ulong shaderAddress = 0,
int baseVertex = 0);
void SubmitStorageTranslatedDraw(
IGuestCompiledShader pixelShader,
@@ -234,7 +237,27 @@ internal interface IGuestGpuBackend
void SubmitGuestImageFill(ulong address, uint fillValue);
void SubmitGuestImageWrite(ulong address, byte[] pixels);
/// <summary>
/// Uploads guest-authored pixels into a live guest image. <paramref name="rowOffset"/>
/// is the first image row the buffer covers, so a caller that knows only part
/// of the surface changed can send that band instead of the whole thing; the
/// untouched rows on the host already hold the same bytes.
/// </summary>
void SubmitGuestImageWrite(ulong address, byte[] pixels, uint rowOffset = 0);
/// <summary>
/// Whether a non-zero <c>rowOffset</c> is honoured. Backends that cannot
/// upload a sub-range must report false so callers keep sending the whole
/// surface: a dropped band would leave the host copy stale, which is worse
/// than an oversized upload.
/// </summary>
bool SupportsPartialImageWrite => false;
/// <summary>
/// Asks the presenter to refresh CPU-dirty guest images on its render/present
/// drain. Must not enqueue retained plane copies on the producer path.
/// </summary>
void RequestCpuWrittenGuestImageSync(ulong scopeAddress = 0, ulong scopeByteCount = ulong.MaxValue);
bool TryGetGuestImageExtent(ulong address, out uint width, out uint height, out ulong byteCount);
@@ -218,6 +218,13 @@ internal static class MetalGuestFormats
{
var format = (dataFormat, numberType) switch
{
// Early G-buffer / scene targets (R16 + RG32). Keep in sync with
// VulkanVideoPresenter.TryDecodeRenderTargetFormat.
(2, 0) => MtlPixelFormat.R16Unorm,
(2, 1) => MtlPixelFormat.R16Snorm,
(2, 4) => MtlPixelFormat.R16Uint,
(2, 5) => MtlPixelFormat.R16Sint,
(2, 7) => MtlPixelFormat.R16Float,
(4, 4) => MtlPixelFormat.R32Uint,
(4, 5) => MtlPixelFormat.R32Sint,
(4, 7) => MtlPixelFormat.R32Float,
@@ -230,6 +237,8 @@ internal static class MetalGuestFormats
(10, 5) => MtlPixelFormat.Rgba8Sint,
(10, 9) => MtlPixelFormat.Rgba8UnormSrgb,
(10, _) => MtlPixelFormat.Rgba8Unorm,
(11, 4) => MtlPixelFormat.Rg32Uint,
(11, 5) => MtlPixelFormat.Rg32Sint,
(11, 7) => MtlPixelFormat.Rg32Float,
(12, 4) => MtlPixelFormat.Rgba16Uint,
(12, 5) => MtlPixelFormat.Rgba16Sint,
@@ -258,10 +267,12 @@ internal static class MetalGuestFormats
var outputKind = format switch
{
MtlPixelFormat.R8Uint or MtlPixelFormat.R32Uint or MtlPixelFormat.Rg16Uint or
MtlPixelFormat.Rgba8Uint or MtlPixelFormat.Rgba16Uint => Gen5PixelOutputKind.Uint,
MtlPixelFormat.R32Sint or MtlPixelFormat.Rg16Sint or MtlPixelFormat.Rgba8Sint or
MtlPixelFormat.Rgba16Sint => Gen5PixelOutputKind.Sint,
MtlPixelFormat.R8Uint or MtlPixelFormat.R16Uint or MtlPixelFormat.R32Uint or
MtlPixelFormat.Rg16Uint or MtlPixelFormat.Rg32Uint or MtlPixelFormat.Rgba8Uint or
MtlPixelFormat.Rgba16Uint => Gen5PixelOutputKind.Uint,
MtlPixelFormat.R16Sint or MtlPixelFormat.R32Sint or MtlPixelFormat.Rg16Sint or
MtlPixelFormat.Rg32Sint or MtlPixelFormat.Rgba8Sint or MtlPixelFormat.Rgba16Sint =>
Gen5PixelOutputKind.Sint,
_ => Gen5PixelOutputKind.Float,
};
result = new MetalRenderTargetFormat(format, outputKind);
@@ -70,6 +70,7 @@ internal sealed class MetalGuestGpuBackend : IGuestGpuBackend
int scalarRegisterBufferIndex = -1,
uint pixelInputEnable = 0,
uint pixelInputAddress = 0,
IReadOnlyList<uint>? pixelInputCntl = null,
ulong storageBufferOffsetAlignment = 1)
{
shader = null;
@@ -85,6 +86,7 @@ internal sealed class MetalGuestGpuBackend : IGuestGpuBackend
scalarRegisterBufferIndex,
pixelInputEnable,
pixelInputAddress,
pixelInputCntl,
storageBufferOffsetAlignment))
{
return false;
@@ -251,7 +253,8 @@ internal sealed class MetalGuestGpuBackend : IGuestGpuBackend
GuestIndexBuffer? indexBuffer = null,
IReadOnlyList<GuestVertexBuffer>? vertexBuffers = null,
GuestRenderState? renderState = null,
ulong shaderAddress = 0) =>
ulong shaderAddress = 0,
int baseVertex = 0) =>
MetalVideoPresenter.SubmitDepthOnlyTranslatedDraw(
Msl(pixelShader),
textures,
@@ -265,7 +268,8 @@ internal sealed class MetalGuestGpuBackend : IGuestGpuBackend
indexBuffer,
vertexBuffers,
renderState,
shaderAddress);
shaderAddress,
baseVertex);
public void SubmitOffscreenTranslatedDraw(
IGuestCompiledShader pixelShader,
@@ -281,7 +285,8 @@ internal sealed class MetalGuestGpuBackend : IGuestGpuBackend
IReadOnlyList<GuestVertexBuffer>? vertexBuffers = null,
GuestRenderState? renderState = null,
GuestDepthTarget? depthTarget = null,
ulong shaderAddress = 0) =>
ulong shaderAddress = 0,
int baseVertex = 0) =>
MetalVideoPresenter.SubmitOffscreenTranslatedDraw(
Msl(pixelShader),
textures,
@@ -296,7 +301,8 @@ internal sealed class MetalGuestGpuBackend : IGuestGpuBackend
vertexBuffers,
renderState,
depthTarget,
shaderAddress);
shaderAddress,
baseVertex);
public void SubmitStorageTranslatedDraw(
IGuestCompiledShader pixelShader,
@@ -392,9 +398,12 @@ internal sealed class MetalGuestGpuBackend : IGuestGpuBackend
public void SubmitGuestImageFill(ulong address, uint fillValue) =>
MetalVideoPresenter.SubmitGuestImageFill(address, fillValue);
public void SubmitGuestImageWrite(ulong address, byte[] pixels) =>
public void SubmitGuestImageWrite(ulong address, byte[] pixels, uint rowOffset = 0) =>
MetalVideoPresenter.SubmitGuestImageWrite(address, pixels);
public void RequestCpuWrittenGuestImageSync(ulong scopeAddress = 0, ulong scopeByteCount = ulong.MaxValue) =>
MetalVideoPresenter.RequestCpuWrittenGuestImageSync(scopeAddress, scopeByteCount);
public bool TryGetGuestImageExtent(ulong address, out uint width, out uint height, out ulong byteCount) =>
MetalVideoPresenter.TryGetGuestImageExtent(address, out width, out height, out byteCount);
@@ -1,182 +0,0 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.HLE.Host;
using SharpEmu.HLE.Host.Posix;
namespace SharpEmu.Libs.Gpu.Metal;
/// <summary>
/// Keyboard state sampled from the Metal presenter's window, feeding the POSIX
/// host input seam so pad emulation works like the Vulkan presenter's
/// HostWindowInput. Key events arrive on the AppKit main thread as macOS
/// virtual key codes; pad reads happen on guest threads, so state is guarded.
/// Window gamepads are not surfaced by AppKit — controller support would go
/// through GameController.framework and is out of scope here.
/// </summary>
internal static class MetalHostInput
{
private static readonly object Gate = new();
private static readonly HashSet<ushort> Pressed = new();
private static volatile bool _connected;
/// <summary>Registers this window's keyboard as the host input source.</summary>
public static void Attach()
{
_connected = true;
PosixHostInput.SetSource(new MetalWindowInputSource());
Console.Error.WriteLine("[LOADER][INFO] Window keyboard input attached for pad emulation.");
}
// Debug automation: SHARPEMU_METAL_AUTOKEY="12:0x24,15:0x24" presses the
// macOS key code at each elapsed-seconds mark for a few frames, letting
// headless test runs navigate menus without a human at the keyboard.
private static readonly List<(double At, ushort Key, bool[] State)> _autoKeys = ParseAutoKeys();
private static readonly System.Diagnostics.Stopwatch _autoKeyClock =
System.Diagnostics.Stopwatch.StartNew();
private static List<(double, ushort, bool[])> ParseAutoKeys()
{
var keys = new List<(double, ushort, bool[])>();
var spec = Environment.GetEnvironmentVariable("SHARPEMU_METAL_AUTOKEY");
if (string.IsNullOrWhiteSpace(spec))
{
return keys;
}
foreach (var entry in spec.Split(',', StringSplitOptions.RemoveEmptyEntries))
{
var parts = entry.Split(':');
if (parts.Length == 2 &&
double.TryParse(parts[0], out var at) &&
TryParseKeyCode(parts[1], out var key))
{
keys.Add((at, key, new bool[2]));
}
}
return keys;
}
private static bool TryParseKeyCode(string text, out ushort key)
{
return text.StartsWith("0x", StringComparison.OrdinalIgnoreCase)
? ushort.TryParse(text[2..], System.Globalization.NumberStyles.HexNumber, null, out key)
: ushort.TryParse(text, out key);
}
/// <summary>Called once per render frame; fires and releases scripted keys.</summary>
public static void PumpAutoKeys()
{
if (_autoKeys.Count == 0)
{
return;
}
var elapsed = _autoKeyClock.Elapsed.TotalSeconds;
foreach (var (at, key, state) in _autoKeys)
{
if (!state[0] && elapsed >= at)
{
state[0] = true;
KeyDown(key, isRepeat: false);
Console.Error.WriteLine($"[LOADER][INFO] Metal autokey press 0x{key:X} at {elapsed:F1}s");
}
else if (state[0] && !state[1] && elapsed >= at + 0.2)
{
state[1] = true;
KeyUp(key);
}
}
}
public static void KeyDown(ushort keyCode, bool isRepeat)
{
// kVK_F1: parity with the Vulkan window's perf-overlay toggle.
if (keyCode == 0x7A && !isRepeat)
{
VideoOut.PerfOverlay.Toggle();
}
lock (Gate)
{
Pressed.Add(keyCode);
}
}
public static void KeyUp(ushort keyCode)
{
lock (Gate)
{
Pressed.Remove(keyCode);
}
}
private static bool IsKeyCodeDown(ushort keyCode)
{
lock (Gate)
{
return Pressed.Contains(keyCode);
}
}
private sealed class MetalWindowInputSource : IPosixWindowInputSource
{
public bool HasKeyboardFocus => _connected;
public bool IsKeyDown(int virtualKey) =>
TryMapVirtualKey(virtualKey, out var keyCode) && IsKeyCodeDown(keyCode);
public int GetGamepadStates(Span<HostGamepadState> destination) => 0;
public string? DescribeConnectedGamepad() => null;
}
/// <summary>Windows virtual-key semantics (the seam's contract) to macOS
/// kVK virtual key codes, covering the keys pad emulation polls.</summary>
private static bool TryMapVirtualKey(int vk, out ushort keyCode)
{
keyCode = vk switch
{
0x08 => 0x33, // Backspace -> kVK_Delete
0x09 => 0x30, // Tab
0x0D => 0x24, // Enter -> kVK_Return
0x1B => 0x35, // Escape
0x20 => 0x31, // Space
0x25 => 0x7B, // Left
0x26 => 0x7E, // Up
0x27 => 0x7C, // Right
0x28 => 0x7D, // Down
// Letters: macOS ANSI key codes are layout-position based and
// non-contiguous, so map each polled letter explicitly.
0x41 => 0x00, // A
0x42 => 0x0B, // B
0x43 => 0x08, // C
0x44 => 0x02, // D
0x45 => 0x0E, // E
0x46 => 0x03, // F
0x47 => 0x05, // G
0x48 => 0x04, // H
0x49 => 0x22, // I
0x4A => 0x26, // J
0x4B => 0x28, // K
0x4C => 0x25, // L
0x4D => 0x2E, // M
0x4E => 0x2D, // N
0x4F => 0x1F, // O
0x50 => 0x23, // P
0x51 => 0x0C, // Q
0x52 => 0x0F, // R
0x53 => 0x01, // S
0x54 => 0x11, // T
0x55 => 0x20, // U
0x56 => 0x09, // V
0x57 => 0x0D, // W
0x58 => 0x07, // X
0x59 => 0x10, // Y
0x5A => 0x06, // Z
_ => ushort.MaxValue,
};
return keyCode != ushort.MaxValue;
}
}
+2 -89
View File
@@ -5,19 +5,6 @@ using System.Runtime.InteropServices;
namespace SharpEmu.Libs.Gpu.Metal;
// Core Graphics / Metal ABI structs passed by value through objc_msgSend. Struct
// *returns* are deliberately never used: on x86-64 (this process runs under Rosetta
// on Apple silicon) large struct returns switch to objc_msgSend_stret, and avoiding
// them entirely keeps one calling convention everywhere.
[StructLayout(LayoutKind.Sequential)]
internal struct CGRect
{
public double X;
public double Y;
public double Width;
public double Height;
}
[StructLayout(LayoutKind.Sequential)]
internal struct CGSize
{
@@ -93,31 +80,21 @@ internal struct MtlViewport
}
/// <summary>
/// Objective-C runtime access for the Metal presenter: AppKit, QuartzCore, and Metal
/// Objective-C runtime access for the Metal presenter: QuartzCore and Metal
/// through objc_msgSend, with one LibraryImport overload per distinct native
/// signature. Dependency-free by design — this plus the OS frameworks is the entire
/// Metal path, which is what keeps it NativeAOT-clean.
/// </summary>
internal static partial class MetalNative
{
private const string CoreFoundation =
"/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation";
[LibraryImport(CoreFoundation)]
public static partial nint CFRunLoopGetMain();
[LibraryImport(CoreFoundation)]
public static partial void CFRunLoopStop(nint runLoop);
private const string ObjCLibrary = "/usr/lib/libobjc.A.dylib";
private const string MetalFramework = "/System/Library/Frameworks/Metal.framework/Metal";
private const string AppKitFramework = "/System/Library/Frameworks/AppKit.framework/AppKit";
private const string QuartzCoreFramework = "/System/Library/Frameworks/QuartzCore.framework/QuartzCore";
private static bool _frameworksLoaded;
/// <summary>
/// Makes the AppKit and QuartzCore classes visible to objc_getClass; Metal is
/// Makes QuartzCore classes visible to objc_getClass; Metal is
/// pulled in by its own LibraryImport. Call once before any Class() lookup.
/// </summary>
public static void EnsureFrameworksLoaded()
@@ -127,7 +104,6 @@ internal static partial class MetalNative
return;
}
NativeLibrary.Load(AppKitFramework);
NativeLibrary.Load(QuartzCoreFramework);
_frameworksLoaded = true;
}
@@ -141,16 +117,6 @@ internal static partial class MetalNative
[LibraryImport(ObjCLibrary, StringMarshalling = StringMarshalling.Utf8)]
private static partial nint sel_registerName(string name);
[LibraryImport(ObjCLibrary, StringMarshalling = StringMarshalling.Utf8)]
public static partial nint objc_allocateClassPair(nint superclass, string name, nuint extraBytes);
[LibraryImport(ObjCLibrary)]
public static partial void objc_registerClassPair(nint cls);
[LibraryImport(ObjCLibrary, StringMarshalling = StringMarshalling.Utf8)]
[return: MarshalAs(UnmanagedType.I1)]
public static partial bool class_addMethod(nint cls, nint name, nint imp, string types);
[LibraryImport(ObjCLibrary)]
public static partial nint objc_autoreleasePoolPush();
@@ -169,14 +135,6 @@ internal static partial class MetalNative
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial nint Send(nint receiver, nint selector, nint argument);
/// <summary>objc_msgSend for a CGRect-returning selector (e.g. -bounds).
/// A 32-byte struct is returned via the x86-64 stret ABI — a hidden
/// pointer to caller storage passed ahead of self/_cmd — so this must not
/// be folded into the plain objc_msgSend overloads.</summary>
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend_stret")]
public static partial void SendStretRect(out CGRect result, nint receiver, nint selector);
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial nint Send(nint receiver, nint selector, nint argument, ref nint error);
@@ -209,17 +167,6 @@ internal static partial class MetalNative
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial void SendVoid(nint receiver, nint selector, nint argument0, nint argument1);
/// <summary>performSelectorOnMainThread:withObject:waitUntilDone: — the SEL
/// to perform is itself an argument, followed by the object and the wait
/// flag.</summary>
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial void SendVoidPerformSelector(
nint receiver,
nint selector,
nint performedSelector,
nint argument,
[MarshalAs(UnmanagedType.I1)] bool waitUntilDone);
/// <summary>setSwizzle: on MTLTextureDescriptor. Four one-byte
/// MTLTextureSwizzle values, passed packed like the framework expects.</summary>
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
@@ -237,9 +184,6 @@ internal static partial class MetalNative
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial void SendVoidSize(nint receiver, nint selector, CGSize size);
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial void SendVoidRect(nint receiver, nint selector, CGRect rect);
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial void SendVoidClearColor(nint receiver, nint selector, MtlClearColor color);
@@ -328,37 +272,6 @@ internal static partial class MetalNative
nuint indexBufferOffset,
nuint instanceCount);
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial nint SendTimer(
nint receiver,
nint selector,
double interval,
nint target,
nint timerSelector,
nint userInfo,
[MarshalAs(UnmanagedType.I1)] bool repeats);
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial nint SendInitFrame(nint receiver, nint selector, CGRect frame);
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial nint SendInitWindow(
nint receiver,
nint selector,
CGRect contentRect,
nuint styleMask,
nuint backing,
[MarshalAs(UnmanagedType.I1)] bool defer);
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial nint SendNextEvent(
nint receiver,
nint selector,
ulong eventMask,
nint untilDate,
nint inMode,
[MarshalAs(UnmanagedType.I1)] bool dequeue);
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial nint SendTextureDescriptor(
nint receiver,
@@ -1,6 +1,7 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Buffers.Binary;
using SharpEmu.Libs.Agc;
using SharpEmu.ShaderCompiler;
using SharpEmu.ShaderCompiler.Metal;
@@ -87,7 +88,8 @@ internal static partial class MetalVideoPresenter
uint InstanceCount,
uint PrimitiveType,
GuestIndexBuffer? IndexBuffer,
GuestRenderState RenderState);
GuestRenderState RenderState,
int BaseVertex = 0);
private sealed record OffscreenGuestDraw(
TranslatedGuestDraw Draw,
@@ -245,7 +247,8 @@ internal static partial class MetalVideoPresenter
IReadOnlyList<GuestVertexBuffer>? vertexBuffers,
GuestRenderState? renderState,
GuestDepthTarget? depthTarget,
ulong shaderAddress)
ulong shaderAddress,
int baseVertex = 0)
{
if (targets.Count == 0)
{
@@ -293,7 +296,8 @@ internal static partial class MetalVideoPresenter
instanceCount,
primitiveType,
indexBuffer,
effectiveRenderState),
effectiveRenderState,
baseVertex),
ToArray(targets),
depthTarget,
PublishTarget: true,
@@ -321,7 +325,8 @@ internal static partial class MetalVideoPresenter
GuestIndexBuffer? indexBuffer,
IReadOnlyList<GuestVertexBuffer>? vertexBuffers,
GuestRenderState? renderState,
ulong shaderAddress)
ulong shaderAddress,
int baseVertex = 0)
{
if (depthTarget.Address == 0 || depthTarget.Width == 0 || depthTarget.Height == 0)
{
@@ -348,7 +353,8 @@ internal static partial class MetalVideoPresenter
instanceCount,
primitiveType,
indexBuffer,
renderState ?? GuestRenderState.Default),
renderState ?? GuestRenderState.Default,
baseVertex),
[new GuestRenderTarget(Address: 0, depthTarget.Width, depthTarget.Height, Format: 10, NumberType: 0)],
depthTarget,
PublishTarget: false,
@@ -981,17 +987,38 @@ internal static partial class MetalVideoPresenter
private static void EncodeDrawCall(nint encoder, TranslatedGuestDraw draw)
{
var primitive = GetPrimitiveType(draw.PrimitiveType);
var vertexCount = draw.PrimitiveType == 0x11 && draw.IndexBuffer is null
? 4u
: draw.VertexCount;
var indexed = draw.IndexBuffer is not null;
var hasVertexBuffers = draw.VertexBuffers.Length > 0;
var primitive = GetPrimitiveType(
draw.PrimitiveType,
indexed,
draw.VertexCount,
hasVertexBuffers);
var vertexCount = AgcPrimitiveHelpers.GetRectListDrawVertexCount(
draw.PrimitiveType,
draw.VertexCount,
indexed,
hasVertexBuffers);
var baseVertex = (nuint)Math.Max(draw.BaseVertex, 0);
if (draw.IndexBuffer is { } indexBuffer)
{
var device = MetalNative.Send(encoder, MetalNative.Selector("device"));
var slice = AllocateUpload(
device, Math.Max(indexBuffer.Length, 1), out var buffer, out var offset);
indexBuffer.Data.AsSpan(0, Math.Min(indexBuffer.Length, indexBuffer.Data.Length))
.CopyTo(slice);
var source = indexBuffer.Data.AsSpan(
0,
Math.Min(indexBuffer.Length, indexBuffer.Data.Length));
// Metal drawIndexed without baseVertex: bake GE_INDX_OFFSET into
// the uploaded indices so glyph batches still hit the right verts.
if (draw.BaseVertex != 0)
{
BakeBaseVertexIntoIndices(source, slice, indexBuffer.Is32Bit, draw.BaseVertex);
}
else
{
source.CopyTo(slice);
}
MetalNative.SendDrawIndexedPrimitives(
encoder,
MetalNative.Selector("drawIndexedPrimitives:indexCount:indexType:indexBuffer:indexBufferOffset:instanceCount:"),
@@ -1012,12 +1039,46 @@ internal static partial class MetalVideoPresenter
encoder,
MetalNative.Selector("drawPrimitives:vertexStart:vertexCount:instanceCount:"),
primitive,
0,
baseVertex,
vertexCount,
Math.Max(draw.InstanceCount, 1));
}
}
private static void BakeBaseVertexIntoIndices(
ReadOnlySpan<byte> source,
Span<byte> destination,
bool is32Bit,
int baseVertex)
{
if (is32Bit)
{
var count = source.Length / sizeof(uint);
for (var index = 0; index < count; index++)
{
var value = BinaryPrimitives.ReadUInt32LittleEndian(
source.Slice(index * sizeof(uint), sizeof(uint)));
var adjusted = unchecked((uint)(value + baseVertex));
BinaryPrimitives.WriteUInt32LittleEndian(
destination.Slice(index * sizeof(uint), sizeof(uint)),
adjusted);
}
return;
}
var shortCount = source.Length / sizeof(ushort);
for (var index = 0; index < shortCount; index++)
{
var value = BinaryPrimitives.ReadUInt16LittleEndian(
source.Slice(index * sizeof(ushort), sizeof(ushort)));
var adjusted = unchecked((ushort)(value + baseVertex));
BinaryPrimitives.WriteUInt16LittleEndian(
destination.Slice(index * sizeof(ushort), sizeof(ushort)),
adjusted);
}
}
private static bool TryGetDrawPipeline(
nint device,
TranslatedGuestDraw draw,
@@ -1226,8 +1287,11 @@ internal static partial class MetalVideoPresenter
? vertexBuffer.Stride
: Math.Max(vertexBuffer.ComponentCount, 1) * 4;
MetalNative.Send(layout, MetalNative.Selector("setStride:"), (nint)stride);
// MTLVertexStepFunction.PerVertex = 1.
MetalNative.Send(layout, MetalNative.Selector("setStepFunction:"), 1);
// MTLVertexStepFunction: PerVertex = 1, PerInstance = 2.
MetalNative.Send(
layout,
MetalNative.Selector("setStepFunction:"),
vertexBuffer.PerInstance ? 2 : 1);
}
return descriptor;
@@ -2059,13 +2123,30 @@ internal static partial class MetalVideoPresenter
return 3;
case 6:
case 0x11:
return 4;
default:
return 3;
}
}
private static nuint GetPrimitiveType(
uint guestPrimitiveType,
bool indexed,
uint vertexCount,
bool hasVertexBuffers)
{
if (AgcPrimitiveHelpers.ShouldDrawRectListAsTriangleStrip(
guestPrimitiveType,
indexed,
vertexCount,
hasVertexBuffers))
{
return 4; // MTLPrimitiveTypeTriangleStrip
}
return GetPrimitiveType(guestPrimitiveType);
}
private static bool IsIntegerFormat(Gen5PixelOutputKind kind) =>
kind is Gen5PixelOutputKind.Uint or Gen5PixelOutputKind.Sint;

Some files were not shown because too many files have changed in this diff Show More