diff --git a/.github/workflows/workflow.yml b/.github/workflows/workflow.yml
index 4eb7b47..a98cb18 100644
--- a/.github/workflows/workflow.yml
+++ b/.github/workflows/workflow.yml
@@ -38,6 +38,7 @@ jobs:
artifact-name: ${{ steps.vars.outputs.artifact-name }}
release-name: ${{ steps.vars.outputs.release-name }}
release-tag: ${{ steps.vars.outputs.release-tag }}
+ safe-ref: ${{ steps.vars.outputs.safe-ref }}
short-sha: ${{ steps.vars.outputs.short-sha }}
steps:
- name: Compute workflow variables
@@ -53,6 +54,7 @@ jobs:
{
echo "short-sha=${short_sha}"
+ echo "safe-ref=${safe_ref}"
echo "archive-name=${archive_name}"
echo "artifact-name=${artifact_name}"
echo "release-tag=${release_tag}"
@@ -124,6 +126,65 @@ jobs:
path: ${{ env.RELEASE_DIR }}\${{ needs.init.outputs.archive-name }}
if-no-files-found: error
+ build-posix:
+ name: Build ${{ matrix.rid }}
+ needs:
+ - init
+ - reuse
+ runs-on: ${{ matrix.os }}
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - os: ubuntu-latest
+ rid: linux-x64
+ - os: macos-latest
+ rid: osx-x64
+ env:
+ DOTNET_NOLOGO: true
+ NUGET_PACKAGES: ${{ github.workspace }}/.nuget/packages
+ PUBLISH_DIR: ${{ github.workspace }}/artifacts/publish/${{ matrix.rid }}
+ RELEASE_DIR: ${{ github.workspace }}/artifacts/release
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v6
+
+ - name: Setup .NET SDK
+ uses: actions/setup-dotnet@v5
+ with:
+ dotnet-version: 10.0.103
+ cache: true
+ cache-dependency-path: |
+ Directory.Packages.props
+ src/**/packages.lock.json
+
+ - name: Restore solution
+ run: dotnet restore SharpEmu.slnx --locked-mode
+
+ - name: Build solution
+ run: dotnet build SharpEmu.slnx -c Release --no-restore
+
+ - name: Publish ${{ matrix.rid }} CLI
+ run: dotnet publish src/SharpEmu.CLI/SharpEmu.CLI.csproj -c Release -r ${{ matrix.rid }} --self-contained true --no-restore -p:PublishDir="$PUBLISH_DIR"
+
+ - name: Stage MoltenVK next to the build
+ if: matrix.rid == 'osx-x64'
+ run: scripts/fetch-macos-moltenvk.sh "$PUBLISH_DIR"
+
+ - name: Create release archive
+ run: |
+ mkdir -p "$RELEASE_DIR"
+ # tar keeps the executable bit, which zip would drop.
+ tar -czf "$RELEASE_DIR/sharpemu-${{ matrix.rid }}-${{ needs.init.outputs.short-sha }}.tar.gz" \
+ -C "$PUBLISH_DIR" .
+
+ - name: Upload build artifact
+ uses: actions/upload-artifact@v7
+ with:
+ name: sharpemu-${{ matrix.rid }}-${{ needs.init.outputs.short-sha }}
+ path: ${{ env.RELEASE_DIR }}/sharpemu-${{ matrix.rid }}-${{ needs.init.outputs.short-sha }}.tar.gz
+ if-no-files-found: error
+
release:
name: Publish GitHub Release
needs:
@@ -161,3 +222,46 @@ jobs:
--notes "${notes}" \
--target "${GITHUB_SHA}"
fi
+
+ release-posix:
+ name: Publish GitHub Release (${{ matrix.rid }})
+ needs:
+ - init
+ - build-posix
+ if: github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && github.ref == 'refs/heads/main')
+ runs-on: ubuntu-latest
+ permissions:
+ contents: write
+ strategy:
+ fail-fast: false
+ matrix:
+ rid: [linux-x64, osx-x64]
+ steps:
+ - name: Download build artifact
+ uses: actions/download-artifact@v8
+ with:
+ name: sharpemu-${{ matrix.rid }}-${{ needs.init.outputs.short-sha }}
+ path: release
+
+ - name: Create or update release
+ shell: bash
+ env:
+ ARCHIVE_NAME: sharpemu-${{ matrix.rid }}-${{ needs.init.outputs.short-sha }}.tar.gz
+ GH_REPO: ${{ github.repository }}
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ RELEASE_NAME: SharpEmu ${{ matrix.rid }} ${{ needs.init.outputs.short-sha }}
+ RELEASE_TAG: ${{ matrix.rid }}-${{ needs.init.outputs.safe-ref }}-${{ needs.init.outputs.short-sha }}
+ RID: ${{ matrix.rid }}
+ run: |
+ asset_path="release/${ARCHIVE_NAME}"
+ notes="Automated ${RID} build for commit ${GITHUB_SHA}."
+
+ if gh release view "${RELEASE_TAG}" >/dev/null 2>&1; then
+ gh release upload "${RELEASE_TAG}" "${asset_path}" --clobber
+ gh release edit "${RELEASE_TAG}" --title "${RELEASE_NAME}" --notes "${notes}"
+ else
+ gh release create "${RELEASE_TAG}" "${asset_path}" \
+ --title "${RELEASE_NAME}" \
+ --notes "${notes}" \
+ --target "${GITHUB_SHA}"
+ fi
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 4a05189..901e196 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -13,6 +13,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
+
@@ -22,4 +23,4 @@ SPDX-License-Identifier: GPL-2.0-or-later
-
\ No newline at end of file
+
diff --git a/README.md b/README.md
index 9ed2486..c08400a 100644
--- a/README.md
+++ b/README.md
@@ -23,10 +23,11 @@ SPDX-License-Identifier: GPL-2.0-or-later
Join our Discord for development updates, compatibility discussions, support, and community chat.
----
-
-> [!WARNING]
-> Currently the primary development target is Windows.
+---
+
+> [!NOTE]
+> SharpEmu supports Windows x64, Linux x64, and macOS x64. Apple Silicon Macs
+> can run the macOS x64 build through Rosetta 2.
> [!WARNING]
> SharpEmu is an experimental PS5 emulator developed from scratch in C#. The current focus is on accuracy and infrastructure setup rather than game-specific compatibility.
@@ -59,14 +60,33 @@ Current capabilities include:
Some games have reached like `sceVideoOut` and AGC stages.
-Currently the project primarily targets Windows. Cross-platform support (Linux and macOS) is planned, but development is currently focused on Windows to simplify early-stage debugging and iteration.
-
-## Using
-
-* Build or Publish project or download in release tab.
-* Open Powershell.
- * Run Emulator GUI.
- * Or command: `.\SharpEmu "eboot.bin" 2>&1 | Tee-Object -FilePath "log.txt"`
+SharpEmu supports Windows, Linux, and macOS hosts. Video output uses Vulkan on
+Windows and Linux, and MoltenVK on macOS. Platform support is still experimental,
+so compatibility and performance vary by game, operating system, and GPU driver.
+
+## Using
+
+Download the release archive for your operating system, extract it, and launch
+SharpEmu with the path to a legally obtained game's `eboot.bin`.
+
+Windows PowerShell:
+
+```powershell
+.\SharpEmu.exe "C:\path\to\game\eboot.bin" 2>&1 |
+ Tee-Object -FilePath "SharpEmu.log"
+```
+
+Linux and macOS:
+
+```bash
+chmod +x ./SharpEmu
+
+./SharpEmu "/path/to/game/eboot.bin" 2>&1 |
+ tee SharpEmu.log
+```
+
+A Vulkan-capable GPU and current graphics driver are required. The macOS
+release includes the MoltenVK Vulkan implementation.
## Games Tested
@@ -94,7 +114,7 @@ Currently the project primarily targets Windows. Cross-platform support (Linux a
## Build
-1. Install the **.NET SDK**.
+1. Install the .NET SDK version specified in [`global.json`](./global.json).
2. Clone the repository: `git clone https://github.com/par274/sharpemu.git`
3. Open the solution file (`SharpEmu.slnx`) in **VSCode**.
4. Build the project: `dotnet build` or `dotnet publish`
diff --git a/scripts/fetch-macos-moltenvk.sh b/scripts/fetch-macos-moltenvk.sh
new file mode 100755
index 0000000..c84067d
--- /dev/null
+++ b/scripts/fetch-macos-moltenvk.sh
@@ -0,0 +1,37 @@
+#!/usr/bin/env bash
+# Copyright (C) 2026 SharpEmu Emulator Project
+# SPDX-License-Identifier: GPL-2.0-or-later
+#
+# Downloads the official (universal x86_64+arm64) MoltenVK dylib and stages
+# it next to a SharpEmu build as libvulkan.1.dylib. The macOS build runs as
+# an x86-64 process under Rosetta 2, so Homebrew's arm64-only Vulkan
+# libraries cannot be used; the presenter looks for this app-local copy.
+#
+# Usage: scripts/fetch-macos-moltenvk.sh [output-dir]
+# (default output: artifacts/bin/Debug/net10.0/osx-x64)
+set -euo pipefail
+
+MVK_VERSION="${MVK_VERSION:-v1.4.0}"
+REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+OUT_DIR="${1:-$REPO_ROOT/artifacts/bin/Debug/net10.0/osx-x64}"
+
+if [[ ! -d "$OUT_DIR" ]]; then
+ echo "output directory does not exist: $OUT_DIR (build first?)" >&2
+ exit 2
+fi
+
+WORK_DIR="$(mktemp -d)"
+trap 'rm -rf "$WORK_DIR"' EXIT
+
+echo ">> Downloading MoltenVK $MVK_VERSION..."
+curl -sL -o "$WORK_DIR/mvk.tar" \
+ "https://github.com/KhronosGroup/MoltenVK/releases/download/$MVK_VERSION/MoltenVK-macos.tar"
+tar -xf "$WORK_DIR/mvk.tar" -C "$WORK_DIR" \
+ MoltenVK/MoltenVK/dynamic/dylib/macOS/libMoltenVK.dylib
+
+DYLIB="$WORK_DIR/MoltenVK/MoltenVK/dynamic/dylib/macOS/libMoltenVK.dylib"
+file "$DYLIB" | grep -q x86_64 || { echo "downloaded dylib lacks x86_64 slice" >&2; exit 3; }
+
+cp "$DYLIB" "$OUT_DIR/libMoltenVK.dylib"
+cp "$DYLIB" "$OUT_DIR/libvulkan.1.dylib"
+echo ">> Staged libMoltenVK.dylib + libvulkan.1.dylib in $OUT_DIR"
diff --git a/scripts/test-linux-docker.sh b/scripts/test-linux-docker.sh
new file mode 100755
index 0000000..e8f871d
--- /dev/null
+++ b/scripts/test-linux-docker.sh
@@ -0,0 +1,32 @@
+#!/usr/bin/env bash
+# Copyright (C) 2026 SharpEmu Emulator Project
+# SPDX-License-Identifier: GPL-2.0-or-later
+#
+# Smoke-tests the linux-x64 build inside an amd64 container. Useful from any
+# host (including Apple Silicon, where Docker runs the amd64 image under
+# emulation) to confirm the cross-platform layer keeps working on Linux.
+#
+# Usage: scripts/test-linux-docker.sh /path/to/eboot.bin
+set -euo pipefail
+
+GAME_PATH="${1:-}"
+if [[ -z "$GAME_PATH" || ! -f "$GAME_PATH" ]]; then
+ echo "usage: $0 " >&2
+ exit 2
+fi
+
+REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+GAME_DIR="$(cd "$(dirname "$GAME_PATH")" && pwd)"
+GAME_FILE="$(basename "$GAME_PATH")"
+PUBLISH_DIR="$REPO_ROOT/artifacts/publish/SharpEmu.CLI/Debug/net10.0/linux-x64"
+
+echo ">> Publishing linux-x64 self-contained build..."
+dotnet publish "$REPO_ROOT/src/SharpEmu.CLI" \
+ -c Debug -r linux-x64 --self-contained -p:PublishSingleFile=false
+
+echo ">> Running inside linux/amd64 container..."
+docker run --rm --platform linux/amd64 \
+ -v "$PUBLISH_DIR":/app:ro \
+ -v "$GAME_DIR":/game:ro \
+ mcr.microsoft.com/dotnet/runtime-deps:10.0 \
+ /app/SharpEmu --log-level=info "/game/$GAME_FILE"
diff --git a/src/SharpEmu.CLI/Program.cs b/src/SharpEmu.CLI/Program.cs
index d47ca78..26e56b3 100644
--- a/src/SharpEmu.CLI/Program.cs
+++ b/src/SharpEmu.CLI/Program.cs
@@ -75,6 +75,121 @@ internal static partial class Program
TryEnableConsoleFileMirror(earlyLogFilePath);
}
+ if (!CheckHostArchitecture())
+ {
+ return 5;
+ }
+
+ if (OperatingSystem.IsMacOS() || OperatingSystem.IsLinux())
+ {
+ if (OperatingSystem.IsMacOS())
+ {
+ 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.
+ var exitCode = 0;
+ HostMainThread.Enable();
+ var emulation = new Thread(() =>
+ {
+ try
+ {
+ exitCode = RunEmulator(args, isMitigatedChild);
+ }
+ finally
+ {
+ HostMainThread.Shutdown();
+ }
+ }, 32 * 1024 * 1024)
+ {
+ Name = "SharpEmu Emulation",
+ };
+ emulation.Start();
+ HostMainThread.Pump();
+ emulation.Join();
+ return exitCode;
+ }
+
+ return RunEmulator(args, isMitigatedChild);
+ }
+
+ ///
+ /// The supported host execution model, checked before any emulation
+ /// 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.
+ ///
+ private static bool CheckHostArchitecture()
+ {
+ if (RuntimeInformation.ProcessArchitecture == Architecture.X64)
+ {
+ return true;
+ }
+
+ Console.Error.WriteLine(
+ $"[LOADER][ERROR] Unsupported process architecture " +
+ $"{RuntimeInformation.ProcessArchitecture}: guest code executes " +
+ "natively, so SharpEmu must run as an x86-64 process.");
+ if (OperatingSystem.IsMacOS())
+ {
+ Console.Error.WriteLine(
+ "[LOADER][ERROR] On Apple Silicon, use the osx-x64 build under " +
+ "Rosetta 2 (install with: softwareupdate --install-rosetta).");
+ }
+
+ return false;
+ }
+
+ ///
+ /// Makes a Vulkan loader visible to GLFW's dlopen("libvulkan.1.dylib").
+ /// 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.
+ ///
+ private static void PreloadMacVulkanLoader()
+ {
+ var candidates = new[]
+ {
+ Path.Combine(AppContext.BaseDirectory, "libvulkan.1.dylib"),
+ Path.Combine(AppContext.BaseDirectory, "libMoltenVK.dylib"),
+ Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
+ ".sharpemu", "x64lib", "libvulkan.1.dylib"),
+ };
+ foreach (var candidate in candidates)
+ {
+ if (File.Exists(candidate) && NativeLibrary.TryLoad(candidate, out _))
+ {
+ Console.Error.WriteLine($"[LOADER][INFO] Vulkan loader preloaded: {candidate}");
+ return;
+ }
+ }
+
+ if (NativeLibrary.TryLoad("libvulkan.1.dylib", out _))
+ {
+ return;
+ }
+
+ Console.Error.WriteLine(
+ "[LOADER][WARN] No x86-64 Vulkan loader found; video output will be unavailable. " +
+ "Place a universal libMoltenVK.dylib (from the MoltenVK releases) next to SharpEmu " +
+ "as libvulkan.1.dylib.");
+ }
+
+ private static int RunEmulator(string[] args, bool isMitigatedChild)
+ {
Console.Error.WriteLine($"[DEBUG] SharpEmu starting with {args.Length} args");
if (!isMitigatedChild && TryRunMitigatedChild(args, out var childExitCode))
diff --git a/src/SharpEmu.CLI/SharpEmu.CLI.csproj b/src/SharpEmu.CLI/SharpEmu.CLI.csproj
index 3621327..20b9985 100644
--- a/src/SharpEmu.CLI/SharpEmu.CLI.csproj
+++ b/src/SharpEmu.CLI/SharpEmu.CLI.csproj
@@ -16,7 +16,9 @@ SPDX-License-Identifier: GPL-2.0-or-later
console window; CLI mode re-attaches to the parent terminal's console. -->
WinExe
SharpEmu
- win-x64;linux-x64;osx-arm64
+
+ win-x64;linux-x64;osx-x64;osx-arm64
true
true
true
@@ -29,6 +31,16 @@ SPDX-License-Identifier: GPL-2.0-or-later
true
+
+
+ false
+
+
false
none
@@ -63,7 +75,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
<_GlfwPublishFiles Include="@(ResolvedFileToPublish)"
- Condition="$([System.String]::Copy('%(ResolvedFileToPublish.Filename)').StartsWith('glfw'))" />
+ Condition="$([System.String]::Copy('%(ResolvedFileToPublish.Filename)').StartsWith('glfw')) Or $([System.String]::Copy('%(ResolvedFileToPublish.Filename)').StartsWith('libglfw'))" />
true
diff --git a/src/SharpEmu.CLI/packages.lock.json b/src/SharpEmu.CLI/packages.lock.json
index 95a7cc2..fb58102 100644
--- a/src/SharpEmu.CLI/packages.lock.json
+++ b/src/SharpEmu.CLI/packages.lock.json
@@ -135,6 +135,23 @@
"Ultz.Native.GLFW": "3.4.0"
}
},
+ "Silk.NET.Input.Common": {
+ "type": "Transitive",
+ "resolved": "2.23.0",
+ "contentHash": "QbJVV7kFBHEByayXCYdJtXXI9Sp4a+QAf0IdGV6uCWkFYcEmqBYW3aaNGvFOdSwTBDbHL5T/OtOCrGh4qYhk7A==",
+ "dependencies": {
+ "Silk.NET.Windowing.Common": "2.23.0"
+ }
+ },
+ "Silk.NET.Input.Glfw": {
+ "type": "Transitive",
+ "resolved": "2.23.0",
+ "contentHash": "KGHYqsv/IQRJtD6dloYh2tN4CkaM40vxM2kj0cGKBoCQiBDYHHhJiyDTyMPx0W7Fz5IgnhnG42ELmIAa0DH69A==",
+ "dependencies": {
+ "Silk.NET.Input.Common": "2.23.0",
+ "Silk.NET.Windowing.Glfw": "2.23.0"
+ }
+ },
"Silk.NET.Maths": {
"type": "Transitive",
"resolved": "2.23.0",
@@ -225,6 +242,7 @@
"type": "Project",
"dependencies": {
"SharpEmu.HLE": "[1.0.0, )",
+ "Silk.NET.Input": "[2.23.0, )",
"Silk.NET.Vulkan": "[2.23.0, )",
"Silk.NET.Vulkan.Extensions.EXT": "[2.23.0, )",
"Silk.NET.Vulkan.Extensions.KHR": "[2.23.0, )",
@@ -282,6 +300,16 @@
"resolved": "1.21.0",
"contentHash": "dv5+81Q1TBQvVMSOOOmRcjJmvWcX3BZPZsIq31+RLc5cNft0IHAyNlkdb7ZarOWG913PyBoFDsDXoCIlKmLclg=="
},
+ "Silk.NET.Input": {
+ "type": "CentralTransitive",
+ "requested": "[2.23.0, )",
+ "resolved": "2.23.0",
+ "contentHash": "Xzl+tVwAp2eEd8blmGQjmJrsZPnp3PWG0KJjiAQHaY2Zr/ELVWeAROKXmZdCAvexzmte2JVGEy/dxnMycbxlpg==",
+ "dependencies": {
+ "Silk.NET.Input.Common": "2.23.0",
+ "Silk.NET.Input.Glfw": "2.23.0"
+ }
+ },
"Silk.NET.Vulkan": {
"type": "CentralTransitive",
"requested": "[2.23.0, )",
@@ -434,6 +462,59 @@
"contentHash": "Iy22JopynbOJ32vA0lBhFEzGi65GQJBuJHYBYRBpydrDpNoTiHnjIXfA65Gu+8qsOr/ZEoIF8r9aHCgAXuO6DA=="
}
},
+ "net10.0/osx-x64": {
+ "Avalonia.Angle.Windows.Natives": {
+ "type": "Transitive",
+ "resolved": "2.1.25547.20250602",
+ "contentHash": "ZL0VLc4s9rvNNFt19Pxm5UNAkmKNylugAwJPX9ulXZ6JWs/l6XZihPWWTyezaoNOVyEPU8YbURtW7XMAtqXH5A=="
+ },
+ "Avalonia.Native": {
+ "type": "Transitive",
+ "resolved": "11.3.18",
+ "contentHash": "8g53DROFW6wVJAnTsE1Iu5bdZO5r0oqxbdbMQODs1QDYCK2IU/y/x/vQVKCYOvqxl4tXkzU9tExv8XqSGPWthQ==",
+ "dependencies": {
+ "Avalonia": "11.3.18"
+ }
+ },
+ "HarfBuzzSharp.NativeAssets.Linux": {
+ "type": "Transitive",
+ "resolved": "8.3.1.1",
+ "contentHash": "3EZ1mpIiKWRLL5hUYA82ZHteeDIVaEA/Z0rA/wU6tjx6crcAkJnBPwDXZugBSfo8+J3EznvRJf49uMsqYfKrHg=="
+ },
+ "HarfBuzzSharp.NativeAssets.macOS": {
+ "type": "Transitive",
+ "resolved": "8.3.1.1",
+ "contentHash": "jbtCsgftcaFLCA13tVKo5iWdElJScrulLTKJre36O4YQTIlwDtPPqhRZNk+Y0vv4D1gxbscasGRucUDfS44ofQ=="
+ },
+ "HarfBuzzSharp.NativeAssets.Win32": {
+ "type": "Transitive",
+ "resolved": "8.3.1.1",
+ "contentHash": "UsJtQsfAJoFDZrXc4hCUfRPMqccfKZ0iumJ/upcUjz/cmsTgVFGNEL5yaJWmkqsuFYdMWbj/En5/kS4PFl9hBA=="
+ },
+ "SkiaSharp.NativeAssets.Linux": {
+ "type": "Transitive",
+ "resolved": "2.88.9",
+ "contentHash": "cWSaJKVPWAaT/WIn9c8T5uT/l4ETwHxNJTkEOtNKjphNo8AW6TF9O32aRkxqw3l8GUdUo66Bu7EiqtFh/XG0Zg==",
+ "dependencies": {
+ "SkiaSharp": "2.88.9"
+ }
+ },
+ "SkiaSharp.NativeAssets.macOS": {
+ "type": "Transitive",
+ "resolved": "2.88.9",
+ "contentHash": "Nv5spmKc4505Ep7oUoJ5vp3KweFpeNqxpyGDWyeEPTX2uR6S6syXIm3gj75dM0YJz7NPvcix48mR5laqs8dPuA=="
+ },
+ "SkiaSharp.NativeAssets.Win32": {
+ "type": "Transitive",
+ "resolved": "2.88.9",
+ "contentHash": "wb2kYgU7iy84nQLYZwMeJXixvK++GoIuECjU4ECaUKNuflyRlJKyiRhN1MAHswvlvzuvkrjRWlK0Za6+kYQK7w=="
+ },
+ "Ultz.Native.GLFW": {
+ "type": "Transitive",
+ "resolved": "3.4.0",
+ "contentHash": "Iy22JopynbOJ32vA0lBhFEzGi65GQJBuJHYBYRBpydrDpNoTiHnjIXfA65Gu+8qsOr/ZEoIF8r9aHCgAXuO6DA=="
+ }
+ },
"net10.0/win-x64": {
"Avalonia.Angle.Windows.Natives": {
"type": "Transitive",
diff --git a/src/SharpEmu.Core/Cpu/CpuDispatcher.cs b/src/SharpEmu.Core/Cpu/CpuDispatcher.cs
index f09d49d..28e1cb1 100644
--- a/src/SharpEmu.Core/Cpu/CpuDispatcher.cs
+++ b/src/SharpEmu.Core/Cpu/CpuDispatcher.cs
@@ -22,15 +22,22 @@ public sealed class CpuDispatcher : ICpuDispatcher, IDisposable
ModuleInitializer,
}
- private const ulong StackBaseAddress = 0x7FFF_F000_0000UL;
+ // The top of the x86-64 user address space (0x7FFD..0x7FFF) is only
+ // freely mappable on Windows; on macOS/Linux it hosts the dyld shared
+ // cache / vdso and (under Rosetta 2) the translator runtime, so POSIX
+ // hosts use the equivalent layout one slot lower at 0x6FFx.
+ private static readonly ulong StackBaseAddress = OperatingSystem.IsWindows() ? 0x7FFF_F000_0000UL : 0x6FFF_F000_0000UL;
private const ulong StackSize = 0x0020_0000UL;
- private const ulong TlsBaseAddress = 0x7FFE_0000_0000UL;
+ private static readonly ulong TlsBaseAddress = OperatingSystem.IsWindows() ? 0x7FFE_0000_0000UL : 0x6FFE_0000_0000UL;
private const ulong TlsSize = 0x0001_0000UL;
- private const ulong TlsPrefixSize = 0x0000_1000UL;
- private const ulong BootstrapStubBaseAddress = 0x7FFD_F000_0000UL;
- private const ulong BootstrapPayloadBaseAddress = 0x7FFD_E000_0000UL;
- private const ulong DynlibFallbackStubBaseAddress = 0x7FFD_D000_0000UL;
- private const ulong ReturnToHostStubBaseAddress = 0x7FFD_C000_0000UL;
+ // The static TLS blocks live at negative offsets from the TCB (FreeBSD
+ // amd64 variant II); libc.prx alone reaches beyond -0x1700, so give the
+ // prefix a full 64KB on POSIX. Windows keeps its historical 4KB prefix.
+ private static readonly ulong TlsPrefixSize = OperatingSystem.IsWindows() ? 0x0000_1000UL : 0x0001_0000UL;
+ private static readonly ulong BootstrapStubBaseAddress = OperatingSystem.IsWindows() ? 0x7FFD_F000_0000UL : 0x6FFD_F000_0000UL;
+ private static readonly ulong BootstrapPayloadBaseAddress = OperatingSystem.IsWindows() ? 0x7FFD_E000_0000UL : 0x6FFD_E000_0000UL;
+ private static readonly ulong DynlibFallbackStubBaseAddress = OperatingSystem.IsWindows() ? 0x7FFD_D000_0000UL : 0x6FFD_D000_0000UL;
+ private static readonly ulong ReturnToHostStubBaseAddress = OperatingSystem.IsWindows() ? 0x7FFD_C000_0000UL : 0x6FFD_C000_0000UL;
private const ulong BootstrapRegionSize = 0x0000_1000UL;
private const ulong ReturnToHostStubStride = 0x0100_0000UL;
private const ulong BootstrapPayloadResultOffset = 0x28UL;
diff --git a/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.Exceptions.cs b/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.Exceptions.cs
index a16de8d..7c96cab 100644
--- a/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.Exceptions.cs
+++ b/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.Exceptions.cs
@@ -22,6 +22,12 @@ public sealed partial class DirectExecutionBackend
private unsafe void SetupExceptionHandler()
{
+ if (!OperatingSystem.IsWindows())
+ {
+ SetupPosixExceptionHandler();
+ return;
+ }
+
if (!string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_DISABLE_RAW_HANDLER"), "1", StringComparison.Ordinal))
{
_rawExceptionHandlerStub = _faultHandling.CreateHandlerThunk(RawVectoredHandlerPtrManaged, _hostRspSlotTlsIndex, _tlsGetValueAddress);
@@ -207,19 +213,16 @@ public sealed partial class DirectExecutionBackend
}
- try
+ Console.Error.WriteLine("[LOADER][INFO] Stack qwords (RSP..):");
+ for (int i = 0; i < 16; i++)
{
- Console.Error.WriteLine("[LOADER][INFO] Stack qwords (RSP..):");
- for (int i = 0; i < 16; i++)
+ ulong stackAddr = rsp + (ulong)(i * 8);
+ if (!TryReadHostQword(stackAddr, out ulong value))
{
- ulong stackAddr = rsp + (ulong)(i * 8);
- ulong value = (ulong)Marshal.ReadInt64((nint)stackAddr);
- Console.Error.WriteLine($"[LOADER][INFO] [rsp+0x{i * 8:X2}] @0x{stackAddr:X16} = 0x{value:X16}");
+ Console.Error.WriteLine("[LOADER][WARNING] Could not read stack qwords.");
+ break;
}
- }
- catch
- {
- Console.Error.WriteLine("[LOADER][WARNING] Could not read stack qwords.");
+ Console.Error.WriteLine($"[LOADER][INFO] [rsp+0x{i * 8:X2}] @0x{stackAddr:X16} = 0x{value:X16}");
}
try
@@ -232,8 +235,11 @@ public sealed partial class DirectExecutionBackend
{
break;
}
- ulong next = (ulong)Marshal.ReadInt64((nint)frame);
- ulong ret = (ulong)Marshal.ReadInt64((nint)(frame + 8));
+ if (!TryReadHostQword(frame, out ulong next) || !TryReadHostQword(frame + 8, out ulong ret))
+ {
+ Console.Error.WriteLine("[LOADER][WARNING] Could not walk RBP frame chain.");
+ break;
+ }
string extra = TryFormatNearestRuntimeSymbol(ret, out string retSym) ? $" [{retSym}]" : string.Empty;
Console.Error.WriteLine($"[LOADER][INFO] frame#{i}: rbp=0x{frame:X16} ret=0x{ret:X16}{extra} next=0x{next:X16}");
if (next <= frame)
@@ -256,10 +262,9 @@ public sealed partial class DirectExecutionBackend
Console.Error.WriteLine("[LOADER][ERROR] - Guest code called an unmapped import");
Console.Error.WriteLine("[LOADER][ERROR] - Guest code accessed unmapped memory");
Console.Error.WriteLine("[LOADER][ERROR] - Need to implement HLE for this NID");
- try
+ byte[] code = new byte[16];
+ if (TryReadHostBytes(rip, code))
{
- byte[] code = new byte[16];
- Marshal.Copy((nint)rip, code, 0, code.Length);
Console.Error.WriteLine("[LOADER][INFO] Code at RIP: " + BitConverter.ToString(code).Replace("-", " "));
if (code[0] == 100)
{
@@ -275,20 +280,18 @@ public sealed partial class DirectExecutionBackend
Console.Error.WriteLine($"[LOADER][INFO] RBP: 0x{rbp:X16} (mod 16 = {rbp % 16})");
Console.Error.WriteLine($"[LOADER][INFO] RSP: 0x{rsp:X16} (mod 16 = {rsp % 16})");
}
- if (rip > 16)
+ byte[] before = new byte[16];
+ if (rip > 16 && TryReadHostBytes(rip - 16, before))
{
- byte[] before = new byte[16];
- Marshal.Copy((nint)(rip - 16), before, 0, before.Length);
Console.Error.WriteLine("[LOADER][INFO] Code before RIP: " + BitConverter.ToString(before).Replace("-", " "));
}
- if (rip > 32)
+ byte[] window = new byte[64];
+ if (rip > 32 && TryReadHostBytes(rip - 32, window))
{
- byte[] window = new byte[64];
- Marshal.Copy((nint)(rip - 32), window, 0, window.Length);
Console.Error.WriteLine("[LOADER][INFO] Code window [RIP-0x20..]: " + BitConverter.ToString(window).Replace("-", " "));
}
}
- catch
+ else
{
Console.Error.WriteLine("[LOADER][ERROR] Could not read code at RIP");
}
@@ -823,6 +826,61 @@ public sealed partial class DirectExecutionBackend
}
}
+ private static bool TryReadHostQword(ulong address, out ulong value)
+ {
+ if (!OperatingSystem.IsWindows())
+ {
+ // A stray read inside the signal handler would raise a nested
+ // SIGSEGV and kill the process before diagnostics finish, so
+ // probe the region table instead of relying on try/catch.
+ return TryReadStackU64(address, out value);
+ }
+
+ value = 0;
+ try
+ {
+ value = (ulong)Marshal.ReadInt64((nint)address);
+ return true;
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
+ private unsafe bool TryReadHostBytes(ulong address, byte[] buffer)
+ {
+ if (address < 65536)
+ {
+ return false;
+ }
+
+ if (!OperatingSystem.IsWindows())
+ {
+ // See TryReadHostQword: probe every touched page before reading.
+ ulong end = address + (ulong)buffer.Length;
+ for (ulong page = address & 0xFFFFFFFFFFFFF000uL; page < end; page += 4096)
+ {
+ if (!_hostMemory.Query(page, out var mbi) ||
+ mbi.State != HostRegionState.Committed ||
+ !IsReadableProtection(mbi.RawProtection))
+ {
+ return false;
+ }
+ }
+ }
+
+ try
+ {
+ Marshal.Copy((nint)address, buffer, 0, buffer.Length);
+ return true;
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
private string FormatPointerWithNearestSymbol(ulong value)
{
string text = $"0x{value:X16}";
diff --git a/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.NativeWorker.cs b/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.NativeWorker.cs
index c5c50db..43e8414 100644
--- a/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.NativeWorker.cs
+++ b/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.NativeWorker.cs
@@ -7,6 +7,7 @@ using System.Runtime.InteropServices;
using System.Threading;
using SharpEmu.HLE;
using SharpEmu.HLE.Host;
+using SharpEmu.HLE.Host.Posix;
namespace SharpEmu.Core.Cpu.Native;
@@ -172,8 +173,20 @@ public sealed partial class DirectExecutionBackend
private static nint _exitThreadAddress;
private readonly DirectExecutionBackend _backend;
- private readonly AutoResetEvent _workAvailable = new(false);
- private readonly AutoResetEvent _workCompleted = new(false);
+ // Windows uses AutoResetEvent (its SafeWaitHandle is a real kernel
+ // event the emitted loop can wait on); POSIX uses worker-event
+ // semaphores shared the same way via PosixHostStubs.
+ private readonly AutoResetEvent? _workAvailable;
+ private readonly AutoResetEvent? _workCompleted;
+ private nint _workSemaphore;
+ private nint _doneSemaphore;
+
+ // RunPrologue/RunEpilogue compile to the host ABI (SysV on POSIX); the
+ // emitted loop calls them with Win64 registers, so POSIX routes the
+ // calls through register-shuffling thunks (shared by all workers).
+ private static nint _posixPrologueThunk;
+ private static nint _posixEpilogueThunk;
+ private static readonly object PosixThunkGate = new();
private GCHandle _selfHandle;
private void* _controlBlock;
private void* _loopStub;
@@ -212,6 +225,11 @@ public sealed partial class DirectExecutionBackend
private NativeGuestExecutor(DirectExecutionBackend backend)
{
_backend = backend;
+ if (OperatingSystem.IsWindows())
+ {
+ _workAvailable = new AutoResetEvent(false);
+ _workCompleted = new AutoResetEvent(false);
+ }
}
public static NativeGuestExecutor? TryCreate(DirectExecutionBackend backend)
@@ -258,8 +276,34 @@ public sealed partial class DirectExecutionBackend
var prologuePtr = (nint)(delegate* unmanaged)&RunPrologue;
var epiloguePtr = (nint)(delegate* unmanaged)&RunEpilogue;
var executorHandle = GCHandle.ToIntPtr(_selfHandle);
- var workHandle = _workAvailable.SafeWaitHandle.DangerousGetHandle();
- var doneHandle = _workCompleted.SafeWaitHandle.DangerousGetHandle();
+ nint workHandle;
+ nint doneHandle;
+ if (OperatingSystem.IsWindows())
+ {
+ workHandle = _workAvailable!.SafeWaitHandle.DangerousGetHandle();
+ doneHandle = _workCompleted!.SafeWaitHandle.DangerousGetHandle();
+ }
+ else
+ {
+ lock (PosixThunkGate)
+ {
+ if (_posixPrologueThunk == 0)
+ {
+ _posixPrologueThunk = PosixHostStubs.CreateWin64ToSysVThunk(prologuePtr);
+ _posixEpilogueThunk = PosixHostStubs.CreateWin64ToSysVThunk(epiloguePtr);
+ }
+ }
+ prologuePtr = _posixPrologueThunk;
+ epiloguePtr = _posixEpilogueThunk;
+ _workSemaphore = PosixHostStubs.CreateWorkerEvent();
+ _doneSemaphore = PosixHostStubs.CreateWorkerEvent();
+ if (_workSemaphore == 0 || _doneSemaphore == 0)
+ {
+ return false;
+ }
+ workHandle = _workSemaphore;
+ doneHandle = _doneSemaphore;
+ }
byte* code = (byte*)_loopStub;
int offset = 0;
@@ -377,8 +421,8 @@ public sealed partial class DirectExecutionBackend
_runYieldRequested = false;
_runYieldReason = null;
_runForcedExit = false;
- _workAvailable.Set();
- _workCompleted.WaitOne();
+ SignalWorkAvailable();
+ WaitWorkCompleted();
_runContext = null;
_runState = null;
yieldRequested = _runYieldRequested;
@@ -391,6 +435,28 @@ public sealed partial class DirectExecutionBackend
return _runNativeResult;
}
+ private void SignalWorkAvailable()
+ {
+ if (_workAvailable is not null)
+ {
+ _workAvailable.Set();
+ return;
+ }
+
+ _ = PosixHostStubs.SignalWorkerEvent(_workSemaphore);
+ }
+
+ private void WaitWorkCompleted()
+ {
+ if (_workCompleted is not null)
+ {
+ _workCompleted.WaitOne();
+ return;
+ }
+
+ _ = PosixHostStubs.WaitWorkerEvent(_doneSemaphore, -1);
+ }
+
[UnmanagedCallersOnly]
private static nint RunPrologue(nint executorHandle)
{
@@ -520,7 +586,7 @@ public sealed partial class DirectExecutionBackend
}
try
{
- _workAvailable.Set();
+ SignalWorkAvailable();
}
catch (ObjectDisposedException)
{
@@ -555,8 +621,18 @@ public sealed partial class DirectExecutionBackend
{
_selfHandle.Free();
}
- _workAvailable.Dispose();
- _workCompleted.Dispose();
+ _workAvailable?.Dispose();
+ _workCompleted?.Dispose();
+ if (_workSemaphore != 0)
+ {
+ PosixHostStubs.DestroyWorkerEvent(_workSemaphore);
+ _workSemaphore = 0;
+ }
+ if (_doneSemaphore != 0)
+ {
+ PosixHostStubs.DestroyWorkerEvent(_doneSemaphore);
+ _doneSemaphore = 0;
+ }
}
}
}
diff --git a/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.PosixSignals.cs b/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.PosixSignals.cs
new file mode 100644
index 0000000..d9dbf82
--- /dev/null
+++ b/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.PosixSignals.cs
@@ -0,0 +1,374 @@
+// Copyright (C) 2026 SharpEmu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+using System;
+using System.Runtime.InteropServices;
+using System.Threading;
+using SharpEmu.Core.Cpu.Native.Windows;
+
+namespace SharpEmu.Core.Cpu.Native;
+
+public sealed unsafe partial class DirectExecutionBackend
+{
+ // POSIX bridge for the Windows vectored-exception-handler logic. A
+ // sigaction(SIGSEGV/SIGBUS/SIGILL) handler rebuilds the EXCEPTION_POINTERS
+ // view the shared handlers expect (Win64 CONTEXT register offsets) from
+ // the signal's mcontext, runs the same recovery chain the VEH path uses
+ // (unresolved-import trap sentinels, demand-paging of lazily-committed
+ // guest pages, fault diagnostics), and writes register changes back into
+ // the mcontext so sigreturn resumes the repaired guest. Unrecovered
+ // faults are forwarded to the previously installed handler so the .NET
+ // runtime keeps turning its own faults into managed exceptions.
+
+ private const int PosixSigIll = 4;
+ private const int PosixSigSegv = 11;
+ private static readonly int PosixSigBus = OperatingSystem.IsMacOS() ? 10 : 7;
+
+ // struct sigaction: the handler pointer leads on both platforms; Darwin
+ // packs { handler(8), mask(4), flags(4) }, Linux glibc/musl packs
+ // { handler(8), mask(128), flags(4), restorer(8) }.
+ private static readonly int PosixSigactionSize = OperatingSystem.IsMacOS() ? 16 : 152;
+ private static readonly int PosixSigactionFlagsOffset = OperatingSystem.IsMacOS() ? 12 : 136;
+
+ private static readonly int PosixSaSigInfo = OperatingSystem.IsMacOS() ? 0x0040 : 0x0004;
+ private static readonly int PosixSaNoDefer = OperatingSystem.IsMacOS() ? 0x0010 : 0x40000000;
+
+ // siginfo_t.si_addr: Darwin { signo, errno, code, pid, uid, status, addr },
+ // Linux { signo, errno, code, pad32, addr }.
+ private static readonly int PosixSigInfoAddressOffset = OperatingSystem.IsMacOS() ? 24 : 16;
+
+ // Darwin ucontext_t stores a pointer to __darwin_mcontext64 at +48; the
+ // general registers live in its __ss thread state after the 16-byte
+ // exception state. Linux glibc embeds mcontext_t inline at +40 with the
+ // registers in gregs[23]. Rosetta 2 delivers the regular x86-64 layout
+ // to translated processes.
+ private const int DarwinUcontextMcontextOffset = 48;
+ private const int DarwinMcontextErrOffset = 4;
+ private const int DarwinMcontextFaultAddressOffset = 8;
+ private const int LinuxUcontextGregsOffset = 40;
+ private const int LinuxGregsErrOffset = 19 * 8;
+
+ // Byte offsets of the general registers relative to GetPosixRegisterBase,
+ // ordered to match the contiguous Win64 CONTEXT block CTX_RAX..CTX_RIP
+ // (rax, rcx, rdx, rbx, rsp, rbp, rsi, rdi, r8..r15, rip). Verified
+ // against the x86-64 platform headers.
+ private static readonly int[] PosixRegisterOffsets = OperatingSystem.IsMacOS()
+ ? new[] { 16, 32, 40, 24, 72, 64, 56, 48, 80, 88, 96, 104, 112, 120, 128, 136, 144 }
+ : new[] { 104, 112, 96, 88, 120, 80, 72, 64, 0, 8, 16, 24, 32, 40, 48, 56, 128 };
+
+ private static DirectExecutionBackend? _posixSignalBackend;
+ private static bool _posixSignalHandlersInstalled;
+ private static bool _posixRawRecoveryEnabled;
+ private static bool _posixSignalWarmup;
+ private static readonly nint[] _posixPreviousActions = new nint[32];
+ private static int _posixSignalTraceCount;
+
+ [ThreadStatic]
+ private static int _posixSignalHandlerDepth;
+
+ private void SetupPosixExceptionHandler()
+ {
+ if (string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_DISABLE_POSIX_SIGNALS"), "1", StringComparison.Ordinal))
+ {
+ Console.Error.WriteLine("[LOADER][WARN] POSIX signal exception bridge disabled by SHARPEMU_DISABLE_POSIX_SIGNALS=1; guest faults will not be recovered.");
+ return;
+ }
+
+ _posixSignalBackend = this;
+ if (_posixSignalHandlersInstalled)
+ {
+ return;
+ }
+
+ _posixRawRecoveryEnabled = !string.Equals(
+ Environment.GetEnvironmentVariable("SHARPEMU_DISABLE_RAW_HANDLER"), "1", StringComparison.Ordinal);
+ if (!_posixRawRecoveryEnabled)
+ {
+ Console.Error.WriteLine("[LOADER][INFO] Raw sentinel recovery disabled by SHARPEMU_DISABLE_RAW_HANDLER=1");
+ }
+
+ WarmUpPosixSignalPath();
+
+ if (!InstallPosixSignalHandler(PosixSigSegv) ||
+ !InstallPosixSignalHandler(PosixSigBus) ||
+ !InstallPosixSignalHandler(PosixSigIll))
+ {
+ throw new InvalidOperationException("Failed to install POSIX fault signal handlers");
+ }
+
+ _posixSignalHandlersInstalled = true;
+ Console.Error.WriteLine("[LOADER][INFO] POSIX signal exception bridge installed (SIGSEGV/SIGBUS/SIGILL)");
+ }
+
+ ///
+ /// Runs the signal-recovery path once with fabricated inputs before the
+ /// handlers are installed. The first entry into the handler must not
+ /// require JIT compilation (a fault can interrupt arbitrary runtime
+ /// states), and under Rosetta 2 the signal trampoline cannot enter x86
+ /// code that has never been executed (and therefore never translated): a
+ /// cold handler is silently never invoked and the faulting instruction
+ /// retries forever.
+ ///
+ private void WarmUpPosixSignalPath()
+ {
+ byte* fakeUcontext = stackalloc byte[512];
+ new Span(fakeUcontext, 512).Clear();
+ byte* fakeMcontext = stackalloc byte[512];
+ new Span(fakeMcontext, 512).Clear();
+ if (OperatingSystem.IsMacOS())
+ {
+ *(byte**)(fakeUcontext + DarwinUcontextMcontextOffset) = fakeMcontext;
+ }
+
+ _posixSignalWarmup = true;
+ try
+ {
+ ((delegate* unmanaged)&HandlePosixSignal)(PosixSigSegv, 0, (nint)fakeUcontext);
+
+ // Warm the branches the fabricated fault above skips without
+ // spamming diagnostics: the benign-exception path through
+ // VectoredHandler, the lazy-commit probe (fault address 0 bails
+ // out immediately), and the chain helper (signal 0 has no saved
+ // action and sigaction(0, ...) fails with EINVAL).
+ EXCEPTION_RECORD record = default;
+ record.ExceptionCode = DBG_PRINTEXCEPTION_C;
+ byte* contextRecord = stackalloc byte[Win64ContextOffsets.Size];
+ new Span(contextRecord, Win64ContextOffsets.Size).Clear();
+ EXCEPTION_POINTERS pointers;
+ pointers.ExceptionRecord = &record;
+ pointers.ContextRecord = contextRecord;
+ _ = VectoredHandler(&pointers);
+
+ record.ExceptionCode = 3221225477u;
+ record.NumberParameters = 2;
+ // 0x70000 is never guest-owned, so this walks the vmem region
+ // scan and the PRT range check, then bails out silently.
+ record.ExceptionInformation[1] = 0x70000;
+ _ = TryHandleLazyCommittedPage(&record, 0, 0);
+ ChainPreviousPosixAction(0, 0, 0);
+ }
+ finally
+ {
+ _posixSignalWarmup = false;
+ }
+ }
+
+ private static bool InstallPosixSignalHandler(int signal)
+ {
+ byte* action = stackalloc byte[PosixSigactionSize];
+ new Span(action, PosixSigactionSize).Clear();
+ *(nint*)action = (nint)(delegate* unmanaged)&HandlePosixSignal;
+ // No SA_ONSTACK: the runtime's alternate stacks are far too small for
+ // the recovery/diagnostic path (JIT compilation of cold handler code
+ // can run inside the signal frame). Guest faults deliver onto the 2MB
+ // guest stack, host faults onto the regular thread stack — the same
+ // stacks Windows dispatches exceptions on.
+ *(int*)(action + PosixSigactionFlagsOffset) = PosixSaSigInfo | PosixSaNoDefer;
+
+ var previous = (byte*)NativeMemory.AllocZeroed((nuint)PosixSigactionSize);
+ if (sigaction(signal, action, previous) != 0)
+ {
+ NativeMemory.Free(previous);
+ Console.Error.WriteLine($"[LOADER][ERROR] sigaction({signal}) failed: errno={Marshal.GetLastPInvokeError()}");
+ return false;
+ }
+
+ _posixPreviousActions[signal] = (nint)previous;
+ return true;
+ }
+
+ [UnmanagedCallersOnly]
+ private static void HandlePosixSignal(int signal, nint siginfo, nint ucontext)
+ {
+ if (_posixSignalHandlerDepth > 0)
+ {
+ // A fault inside our own fault handler (diagnostics touched an
+ // unmapped address): restore the default action and return so the
+ // re-executed instruction terminates the process.
+ RestoreDefaultPosixAction(signal);
+ return;
+ }
+
+ _posixSignalHandlerDepth++;
+ try
+ {
+ if (TryHandlePosixFault(signal, siginfo, ucontext))
+ {
+ return;
+ }
+ }
+ catch
+ {
+ // A managed exception must never unwind out of a signal frame.
+ }
+ finally
+ {
+ _posixSignalHandlerDepth--;
+ }
+
+ ChainPreviousPosixAction(signal, siginfo, ucontext);
+ }
+
+ private static bool TryHandlePosixFault(int signal, nint siginfo, nint ucontext)
+ {
+ byte* registers = GetPosixRegisterBase(ucontext);
+ if (registers == null)
+ {
+ return false;
+ }
+
+ byte* contextRecord = stackalloc byte[Win64ContextOffsets.Size];
+ new Span(contextRecord, Win64ContextOffsets.Size).Clear();
+ int[] offsets = PosixRegisterOffsets;
+ for (int i = 0; i < offsets.Length; i++)
+ {
+ WriteCtxU64(contextRecord, CTX_RAX + i * 8, *(ulong*)(registers + offsets[i]));
+ }
+
+ EXCEPTION_RECORD record = default;
+ record.ExceptionAddress = (void*)ReadCtxU64(contextRecord, CTX_RIP);
+ if (signal == PosixSigIll)
+ {
+ record.ExceptionCode = 3221225501u;
+ }
+ else
+ {
+ ulong faultAddress = GetPosixFaultAddress(siginfo, registers);
+ record.ExceptionCode = 3221225477u;
+ record.NumberParameters = 2;
+ record.ExceptionInformation[0] = GetPosixAccessType(registers, faultAddress, ReadCtxU64(contextRecord, CTX_RIP));
+ record.ExceptionInformation[1] = faultAddress;
+ }
+
+ EXCEPTION_POINTERS pointers;
+ pointers.ExceptionRecord = &record;
+ pointers.ContextRecord = contextRecord;
+
+ int traceIndex = _posixSignalWarmup ? 0 : Interlocked.Increment(ref _posixSignalTraceCount);
+ bool traceSignal = traceIndex > 0 && (traceIndex <= 16 || traceIndex % 1024 == 0 ||
+ string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_LOG_POSIX_SIGNALS"), "1", StringComparison.Ordinal));
+ if (traceSignal)
+ {
+ Console.Error.WriteLine(
+ $"[LOADER][TRACE] posix-signal#{traceIndex}: sig={signal} rip=0x{ReadCtxU64(contextRecord, CTX_RIP):X16} " +
+ $"fault=0x{record.ExceptionInformation[1]:X16} access={record.ExceptionInformation[0]} rsp=0x{ReadCtxU64(contextRecord, CTX_RSP):X16}");
+ Console.Error.Flush();
+ }
+
+ // Sentinel recovery runs first: on Windows both vectored handlers see
+ // every fault anyway, and recovering here avoids dumping the full
+ // VectoredHandler diagnostics for each recoverable trap.
+ int disposition = 0;
+ if (_posixRawRecoveryEnabled)
+ {
+ disposition = TryRecoverUnresolvedSentinel(&pointers);
+ }
+ if (disposition != -1 && !_posixSignalWarmup && _posixSignalBackend is { } backend)
+ {
+ disposition = backend.VectoredHandler(&pointers);
+ }
+ if (traceSignal)
+ {
+ Console.Error.WriteLine(
+ $"[LOADER][TRACE] posix-signal#{traceIndex}: recovered={disposition == -1} new_rip=0x{ReadCtxU64(contextRecord, CTX_RIP):X16}");
+ Console.Error.Flush();
+ }
+ if (disposition != -1 && !_posixSignalWarmup)
+ {
+ return false;
+ }
+
+ for (int i = 0; i < offsets.Length; i++)
+ {
+ *(ulong*)(registers + offsets[i]) = ReadCtxU64(contextRecord, CTX_RAX + i * 8);
+ }
+ return true;
+ }
+
+ private static byte* GetPosixRegisterBase(nint ucontext)
+ {
+ if (ucontext == 0)
+ {
+ return null;
+ }
+
+ if (OperatingSystem.IsMacOS())
+ {
+ return *(byte**)((byte*)ucontext + DarwinUcontextMcontextOffset);
+ }
+
+ return (byte*)ucontext + LinuxUcontextGregsOffset;
+ }
+
+ private static ulong GetPosixFaultAddress(nint siginfo, byte* registers)
+ {
+ ulong address = siginfo != 0 ? *(ulong*)((byte*)siginfo + PosixSigInfoAddressOffset) : 0;
+ if (address == 0 && OperatingSystem.IsMacOS())
+ {
+ address = *(ulong*)(registers + DarwinMcontextFaultAddressOffset);
+ }
+
+ return address;
+ }
+
+ private static ulong GetPosixAccessType(byte* registers, ulong faultAddress, ulong rip)
+ {
+ // x86 page-fault error code: bit 1 = write access, bit 4 = instruction
+ // fetch. Fall back to comparing the fault address against RIP when
+ // the error code is not populated (e.g. under Rosetta 2 translation).
+ ulong error = OperatingSystem.IsMacOS()
+ ? *(uint*)(registers + DarwinMcontextErrOffset)
+ : *(ulong*)(registers + LinuxGregsErrOffset);
+ if ((error & 0x10) != 0)
+ {
+ return 8;
+ }
+ if ((error & 0x2) != 0)
+ {
+ return 1;
+ }
+
+ return faultAddress != 0 && faultAddress == rip ? 8u : 0u;
+ }
+
+ private static void RestoreDefaultPosixAction(int signal)
+ {
+ byte* action = stackalloc byte[PosixSigactionSize];
+ new Span(action, PosixSigactionSize).Clear();
+ _ = sigaction(signal, action, null);
+ }
+
+ private static void ChainPreviousPosixAction(int signal, nint siginfo, nint ucontext)
+ {
+ byte* previous = (uint)signal < (uint)_posixPreviousActions.Length
+ ? (byte*)_posixPreviousActions[signal]
+ : null;
+ nint handler = previous != null ? *(nint*)previous : 0;
+ if (handler == 0)
+ {
+ // SIG_DFL (or nothing saved): reinstate the default action and
+ // return, so re-executing the faulting instruction terminates the
+ // process with the original fault context intact.
+ RestoreDefaultPosixAction(signal);
+ return;
+ }
+ if (handler == 1)
+ {
+ // SIG_IGN
+ return;
+ }
+
+ int flags = *(int*)(previous + PosixSigactionFlagsOffset);
+ if ((flags & PosixSaSigInfo) != 0)
+ {
+ ((delegate* unmanaged)handler)(signal, siginfo, ucontext);
+ }
+ else
+ {
+ ((delegate* unmanaged)handler)(signal);
+ }
+ }
+
+ [DllImport("libc", SetLastError = true)]
+ private static extern int sigaction(int signum, void* act, void* oldact);
+}
diff --git a/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.cs b/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.cs
index eb51bd6..d8d27a1 100644
--- a/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.cs
+++ b/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.cs
@@ -14,6 +14,7 @@ using SharpEmu.Core.Loader;
using SharpEmu.Core.Memory;
using SharpEmu.HLE;
using SharpEmu.HLE.Host;
+using SharpEmu.HLE.Host.Posix;
using SharpEmu.Logging;
namespace SharpEmu.Core.Cpu.Native;
@@ -138,15 +139,25 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
private const ulong GuestImageScanEnd = 36507222016uL;
- private const ulong GuestThreadStackBaseAddress = 0x7FFF_E000_0000UL;
+ // The 0x7FFx window is Windows-specific; dyld and Rosetta reserve that
+ // range on macOS, so POSIX guest threads use the lower 0x6FFx window.
+ // The POSIX stack base sits a further 1GB down: the import-stub region
+ // descends from 0x7000_0000_0000 on the same 16MB grid and reaches
+ // 0x6FFF_C000_0000 at its 64-module limit, which would otherwise consume
+ // the top stack slots (on Windows the two bands are ~15TB apart).
+ private static readonly ulong GuestThreadStackBaseAddress =
+ OperatingSystem.IsWindows() ? 0x7FFF_E000_0000UL : 0x6FFF_A000_0000UL;
- private const ulong GuestThreadTlsBaseAddress = 0x7FFE_0000_0000UL;
+ private static readonly ulong GuestThreadTlsBaseAddress =
+ OperatingSystem.IsWindows() ? 0x7FFE_0000_0000UL : 0x6FFE_0000_0000UL;
private const ulong GuestThreadStackSize = 0x0020_0000UL;
private const ulong GuestThreadTlsSize = 0x0001_0000UL;
- private const ulong GuestThreadTlsPrefixSize = 0x0000_1000UL;
+ // Matches CpuDispatcher.TlsPrefixSize: static TLS blocks sit below the
+ // TCB, and libc.prx already reaches beyond -0x1700 on POSIX.
+ private static readonly ulong GuestThreadTlsPrefixSize = OperatingSystem.IsWindows() ? 0x0000_1000UL : 0x0001_0000UL;
private const ulong GuestThreadRegionStride = 0x0100_0000UL;
@@ -546,6 +557,38 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
private readonly object _guestThreadGate = new object();
+ // Diagnostic owner tracking for _guestThreadGate; written only while the
+ // gate is held, read lock-free by the stall watchdog's periodic snapshot.
+ private volatile string? _gateOwnerSite;
+ private int _gateOwnerManagedThreadId;
+ private long _gateAcquireTimestamp;
+
+ private GateHolder LockGate(string site)
+ {
+ Monitor.Enter(_guestThreadGate);
+ _gateOwnerSite = site;
+ Volatile.Write(ref _gateOwnerManagedThreadId, Environment.CurrentManagedThreadId);
+ Volatile.Write(ref _gateAcquireTimestamp, Stopwatch.GetTimestamp());
+ return new GateHolder(this);
+ }
+
+ private readonly struct GateHolder : IDisposable
+ {
+ private readonly DirectExecutionBackend _owner;
+
+ public GateHolder(DirectExecutionBackend owner)
+ {
+ _owner = owner;
+ }
+
+ public void Dispose()
+ {
+ _owner._gateOwnerSite = null;
+ Volatile.Write(ref _owner._gateOwnerManagedThreadId, 0);
+ Monitor.Exit(_owner._guestThreadGate);
+ }
+ }
+
private readonly Queue _readyGuestThreads = new Queue();
// Once set, guest worker threads are unwound to the host at their next import
@@ -610,8 +653,17 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
private static readonly RawExceptionHandlerDelegate RawVectoredHandlerDelegateInstance = RawVectoredHandlerManaged;
private static readonly RawExceptionHandlerDelegate RawUnhandledFilterDelegateInstance = RawUnhandledFilterManaged;
- private static readonly nint ImportGatewayPtr =
- Marshal.GetFunctionPointerForDelegate(ImportGatewayDelegateInstance);
+ private static readonly nint ImportGatewayPtr = ResolveImportGatewayPtr();
+
+ // Emitted import trampolines use the Win64 ABI. Managed callbacks use the
+ // host ABI, so POSIX needs a small Win64-to-SysV register-shuffling thunk.
+ private static nint ResolveImportGatewayPtr()
+ {
+ var managedPtr = Marshal.GetFunctionPointerForDelegate(ImportGatewayDelegateInstance);
+ return OperatingSystem.IsWindows()
+ ? managedPtr
+ : PosixHostStubs.CreateWin64ToSysVThunk(managedPtr);
+ }
private static readonly nint RawVectoredHandlerPtrManaged =
Marshal.GetFunctionPointerForDelegate(RawVectoredHandlerDelegateInstance);
@@ -865,7 +917,9 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
_hostThreading = _hostPlatform.Threading;
_hostSymbols = _hostPlatform.Symbols;
_hostMemory = _hostPlatform.Memory;
- _faultHandling = faultHandling ?? new WindowsFaultHandling(_hostMemory);
+ _faultHandling = faultHandling ?? (OperatingSystem.IsWindows()
+ ? new WindowsFaultHandling(_hostMemory)
+ : NullHostFaultHandling.Instance);
_selfHandle = GCHandle.Alloc(this);
_selfHandlePtr = GCHandle.ToIntPtr(_selfHandle);
_guestTlsBaseTlsIndex = _hostThreading.AllocateTlsSlot();
@@ -1976,26 +2030,54 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
{
throw new OutOfMemoryException("Failed to allocate TLS handler");
}
+ // The handler runs in place of a patched guest `mov reg, fs:[0]`,
+ // which preserves every register and the flags. TlsGetValue (and the
+ // Win64 ABI in general) clobbers rcx/rdx/r8-r11 and the arithmetic
+ // flags, so save them all: guest code legitimately keeps live values
+ // and comparison results across TLS reads, and losing them corrupted
+ // deterministic computations (e.g. procedural texture generation).
byte* tlsHandlerAddress = (byte*)_tlsHandlerAddress;
int num = 0;
- tlsHandlerAddress[num++] = 72;
- tlsHandlerAddress[num++] = 131;
- tlsHandlerAddress[num++] = 236;
- tlsHandlerAddress[num++] = 40;
- tlsHandlerAddress[num++] = 185;
+ tlsHandlerAddress[num++] = 0x9C; // pushfq
+ tlsHandlerAddress[num++] = 0x51; // push rcx
+ tlsHandlerAddress[num++] = 0x52; // push rdx
+ tlsHandlerAddress[num++] = 0x41; // push r8
+ tlsHandlerAddress[num++] = 0x50;
+ tlsHandlerAddress[num++] = 0x41; // push r9
+ tlsHandlerAddress[num++] = 0x51;
+ tlsHandlerAddress[num++] = 0x41; // push r10
+ tlsHandlerAddress[num++] = 0x52;
+ tlsHandlerAddress[num++] = 0x41; // push r11
+ tlsHandlerAddress[num++] = 0x53;
+ tlsHandlerAddress[num++] = 0x48; // sub rsp, 0x20
+ tlsHandlerAddress[num++] = 0x83;
+ tlsHandlerAddress[num++] = 0xEC;
+ tlsHandlerAddress[num++] = 0x20;
+ tlsHandlerAddress[num++] = 0xB9; // mov ecx, index
*(uint*)(tlsHandlerAddress + num) = _guestTlsBaseTlsIndex;
num += 4;
- tlsHandlerAddress[num++] = 72;
- tlsHandlerAddress[num++] = 184;
+ tlsHandlerAddress[num++] = 0x48; // mov rax, TlsGetValue
+ tlsHandlerAddress[num++] = 0xB8;
*(long*)(tlsHandlerAddress + num) = _tlsGetValueAddress;
num += 8;
- tlsHandlerAddress[num++] = byte.MaxValue;
- tlsHandlerAddress[num++] = 208;
- tlsHandlerAddress[num++] = 72;
- tlsHandlerAddress[num++] = 131;
- tlsHandlerAddress[num++] = 196;
- tlsHandlerAddress[num++] = 40;
- tlsHandlerAddress[num++] = 195;
+ tlsHandlerAddress[num++] = 0xFF; // call rax
+ tlsHandlerAddress[num++] = 0xD0;
+ tlsHandlerAddress[num++] = 0x48; // add rsp, 0x20
+ tlsHandlerAddress[num++] = 0x83;
+ tlsHandlerAddress[num++] = 0xC4;
+ tlsHandlerAddress[num++] = 0x20;
+ tlsHandlerAddress[num++] = 0x41; // pop r11
+ tlsHandlerAddress[num++] = 0x5B;
+ tlsHandlerAddress[num++] = 0x41; // pop r10
+ tlsHandlerAddress[num++] = 0x5A;
+ tlsHandlerAddress[num++] = 0x41; // pop r9
+ tlsHandlerAddress[num++] = 0x59;
+ tlsHandlerAddress[num++] = 0x41; // pop r8
+ tlsHandlerAddress[num++] = 0x58;
+ tlsHandlerAddress[num++] = 0x5A; // pop rdx
+ tlsHandlerAddress[num++] = 0x59; // pop rcx
+ tlsHandlerAddress[num++] = 0x9D; // popfq
+ tlsHandlerAddress[num++] = 0xC3; // ret
_tlsPatchStubOffset = (num + 15) & ~15;
uint num2 = default(uint);
if (!_hostMemory.Protect((ulong)(void*)_tlsHandlerAddress, TlsHandlerRegionSize, HostPageProtection.ReadExecute, out num2))
@@ -2613,7 +2695,7 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
{
return false;
}
- lock (_guestThreadGate)
+ using (LockGate("TryStartThread"))
{
_guestThreads[request.ThreadHandle] = thread;
_readyGuestThreads.Enqueue(thread);
@@ -2649,10 +2731,15 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
return false;
}
+ // Joins regularly park here for minutes (a game main thread joining a
+ // streamer); polling at a fixed 1ms burns half a host core for the
+ // whole wait, so back off toward a 10ms cadence once the join is
+ // clearly long-lived.
+ var joinPollMilliseconds = 1;
while (!ActiveForcedGuestExit)
{
Thread? hostThread;
- lock (_guestThreadGate)
+ using (LockGate("TryJoinThread"))
{
if (!_guestThreads.TryGetValue(threadHandle, out var thread))
{
@@ -2689,16 +2776,21 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
try
{
- hostThread.Join(1);
+ hostThread.Join(joinPollMilliseconds);
}
catch (ThreadStateException)
{
- Thread.Sleep(1);
+ Thread.Sleep(joinPollMilliseconds);
}
}
else
{
- Thread.Sleep(1);
+ Thread.Sleep(joinPollMilliseconds);
+ }
+
+ if (joinPollMilliseconds < 10)
+ {
+ joinPollMilliseconds++;
}
}
@@ -2728,7 +2820,7 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
for (int i = 0; i < 8; i++)
{
GuestThreadState? thread = null;
- lock (_guestThreadGate)
+ using (LockGate("Pump.dequeue"))
{
while (_readyGuestThreads.Count > 0)
{
@@ -2759,7 +2851,7 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
Name = $"SharpEmu-{thread.Name}",
Priority = MapGuestThreadPriority(thread.Priority),
};
- lock (_guestThreadGate)
+ using (LockGate("Pump.bind_host"))
{
thread.HostThread = hostThread;
}
@@ -2780,7 +2872,7 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
}
var wakeCount = 0;
- lock (_guestThreadGate)
+ using (LockGate("WakeBlockedThreads"))
{
foreach (var thread in _guestThreads.Values)
{
@@ -2829,7 +2921,7 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
public IReadOnlyList SnapshotThreads()
{
- lock (_guestThreadGate)
+ using (LockGate("SnapshotThreads"))
{
var snapshots = new GuestThreadSnapshot[_guestThreads.Count];
var index = 0;
@@ -2861,7 +2953,7 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
return;
}
- lock (_guestThreadGate)
+ using (LockGate("RegisterBlockedContinuation"))
{
if (!_guestThreads.TryGetValue(guestThreadHandle, out var thread))
{
@@ -2880,7 +2972,7 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
{
var now = Stopwatch.GetTimestamp();
var wakeCount = 0;
- lock (_guestThreadGate)
+ using (LockGate("WakeExpiredBlockedGuestThreads"))
{
foreach (var thread in _guestThreads.Values)
{
@@ -2971,7 +3063,7 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
private GuestThreadState[] SnapshotGuestThreads()
{
- lock (_guestThreadGate)
+ using (LockGate("SnapshotGuestThreads"))
{
return _guestThreads.Values.ToArray();
}
@@ -3161,7 +3253,7 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
if (currentGuestThreadHandle != 0)
{
GuestContinuationRunner? runner;
- lock (_guestThreadGate)
+ using (LockGate("TryCallGuestContinuation"))
{
if (_guestThreads.TryGetValue(currentGuestThreadHandle, out var guestThread))
{
@@ -3253,7 +3345,7 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
{
_guestTeardownRequested = true;
Thread[] hostThreads;
- lock (_guestThreadGate)
+ using (LockGate("RequestGuestThreadTeardown"))
{
_readyGuestThreads.Clear();
Interlocked.Exchange(ref _readyGuestThreadCount, 0);
@@ -3283,7 +3375,7 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
private void ClearGuestThreads()
{
GuestContinuationRunner[] runners;
- lock (_guestThreadGate)
+ using (LockGate("ClearGuestThreads"))
{
runners = _guestThreads.Values
.Select(static thread => thread.ContinuationRunner)
@@ -3572,7 +3664,7 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
GuestCpuContinuation continuation = default;
IGuestThreadBlockWaiter? blockWaiter = null;
var resumeContinuation = false;
- lock (_guestThreadGate)
+ using (LockGate("RunGuestThread.block"))
{
if (thread.HasBlockedContinuation)
{
@@ -3602,7 +3694,7 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
var exitReason = resumeContinuation
? ExecuteBlockedGuestThreadContinuation(thread.Context, continuation, thread.Name, out var blockReason)
: ExecuteGuestThreadEntry(thread.Context, thread.EntryPoint, thread.Name, out blockReason);
- lock (_guestThreadGate)
+ using (LockGate("RunGuestThread.exit"))
{
switch (exitReason)
{
@@ -4355,6 +4447,18 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
return 20;
}
+ // SHARPEMU_PERIODIC_SNAPSHOT_SECONDS=N: dump the stall snapshot every N
+ // seconds regardless of progress, for diagnosing soft stalls where imports
+ // keep flowing but the game stops advancing.
+ private static int GetPeriodicSnapshotSeconds()
+ {
+ if (int.TryParse(Environment.GetEnvironmentVariable("SHARPEMU_PERIODIC_SNAPSHOT_SECONDS"), out var result))
+ {
+ return Math.Max(0, result);
+ }
+ return 0;
+ }
+
private void StartStallWatchdog()
{
int stallWatchdogSeconds = GetStallWatchdogSeconds();
@@ -4384,6 +4488,8 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
dispatcherThread.Start();
long num = (long)((double)stallWatchdogSeconds * Stopwatch.Frequency);
+ var periodicSnapshotTicks = (long)((double)GetPeriodicSnapshotSeconds() * Stopwatch.Frequency);
+ var lastPeriodicSnapshot = Stopwatch.GetTimestamp();
_stallWatchdogThread = new Thread(new ThreadStart(delegate
{
while (!_stallWatchdogStop)
@@ -4393,6 +4499,58 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
{
break;
}
+ if (periodicSnapshotTicks > 0 &&
+ Stopwatch.GetTimestamp() - lastPeriodicSnapshot >= periodicSnapshotTicks)
+ {
+ lastPeriodicSnapshot = Stopwatch.GetTimestamp();
+ var gateOwnerSite = _gateOwnerSite;
+ var gateOwnerTid = Volatile.Read(ref _gateOwnerManagedThreadId);
+ var gateHeldMs = gateOwnerSite is null
+ ? 0.0
+ : Stopwatch.GetElapsedTime(Volatile.Read(ref _gateAcquireTimestamp)).TotalMilliseconds;
+ var snapshotText = new System.Text.StringBuilder();
+ snapshotText.AppendLine(
+ $"[LOADER][DIAG] Periodic snapshot: gate_owner={gateOwnerSite ?? "none"} " +
+ $"gate_tid={gateOwnerTid} gate_held_ms={gateHeldMs:0}");
+ // Never touch the gate here: the periodic snapshot must keep
+ // reporting even (especially) when the gate is wedged.
+ // Dump guest threads without the lock; tolerate torn reads.
+ try
+ {
+ foreach (var thread in _guestThreads.Values)
+ {
+ snapshotText.AppendLine(
+ $"[LOADER][DIAG] gateless guest-thread: handle=0x{thread.ThreadHandle:X16} name='{thread.Name}' " +
+ $"state={thread.State} imports={Interlocked.Read(ref thread.ImportCount)} " +
+ $"nid={Volatile.Read(ref thread.LastImportNid) ?? "none"} ret=0x{Volatile.Read(ref thread.LastReturnRip):X16} " +
+ $"block={thread.BlockReason ?? "none"} wake={thread.BlockWakeKey ?? "none"}");
+ }
+ }
+ catch (Exception snapshotError)
+ {
+ snapshotText.AppendLine($"[LOADER][DIAG] gateless snapshot failed: {snapshotError.Message}");
+ }
+
+ // Console can be wedged by whatever is being diagnosed, so
+ // write to a side file when one is configured and fall back
+ // to stderr otherwise.
+ var snapshotPath = Environment.GetEnvironmentVariable("SHARPEMU_PERIODIC_SNAPSHOT_FILE");
+ if (!string.IsNullOrWhiteSpace(snapshotPath))
+ {
+ try
+ {
+ System.IO.File.AppendAllText(snapshotPath, snapshotText.ToString());
+ }
+ catch
+ {
+ }
+ }
+ else
+ {
+ Console.Error.Write(snapshotText.ToString());
+ Console.Error.Flush();
+ }
+ }
long num2 = Stopwatch.GetTimestamp() - Volatile.Read(ref _lastProgressTimestamp);
if (num2 < num)
{
@@ -4441,7 +4599,7 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
private bool HasReadyGuestThread()
{
WakeExpiredBlockedGuestThreads();
- lock (_guestThreadGate)
+ using (LockGate("HasReadyGuestThread"))
{
foreach (var thread in _guestThreads.Values)
{
@@ -4630,6 +4788,14 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
// Native guest workers park idle once every guest thread has unwound; stop
// them before any executable stub or TLS index they reference is freed.
DisposeNativeGuestExecutors();
+
+ if (ReferenceEquals(_posixSignalBackend, this))
+ {
+ // The signal handlers stay installed (they chain to the previous
+ // action when no backend is active), but must stop dispatching
+ // into a disposed backend.
+ _posixSignalBackend = null;
+ }
ClearImportHandlerTrampolines();
_importEntries = Array.Empty();
_runtimeSymbolsByName.Clear();
diff --git a/src/SharpEmu.Core/Cpu/Native/NullHostFaultHandling.cs b/src/SharpEmu.Core/Cpu/Native/NullHostFaultHandling.cs
new file mode 100644
index 0000000..011cb67
--- /dev/null
+++ b/src/SharpEmu.Core/Cpu/Native/NullHostFaultHandling.cs
@@ -0,0 +1,49 @@
+// Copyright (C) 2026 SharpEmu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+using SharpEmu.HLE.Host;
+
+namespace SharpEmu.Core.Cpu.Native;
+
+///
+/// Placeholder for hosts whose fault bridge is installed directly by the
+/// execution backend. POSIX uses its sigaction bridge and never calls these
+/// Windows-shaped registration methods.
+///
+internal sealed class NullHostFaultHandling : IHostFaultHandling
+{
+ public static NullHostFaultHandling Instance { get; } = new();
+
+ private NullHostFaultHandling()
+ {
+ }
+
+ public nint CreateHandlerThunk(nint managedCallback, uint hostRspSwitchTlsSlot, nint tlsGetValueAddress)
+ {
+ _ = managedCallback;
+ _ = hostRspSwitchTlsSlot;
+ _ = tlsGetValueAddress;
+ return 0;
+ }
+
+ public void FreeThunk(nint thunk)
+ {
+ _ = thunk;
+ }
+
+ public nint AddFirstChanceHandler(nint thunk)
+ {
+ _ = thunk;
+ return 0;
+ }
+
+ public void RemoveHandler(nint handle)
+ {
+ _ = handle;
+ }
+
+ public void SetUnhandledFilter(nint thunk)
+ {
+ _ = thunk;
+ }
+}
diff --git a/src/SharpEmu.Core/Cpu/Native/Windows/Win64ContextOffsets.cs b/src/SharpEmu.Core/Cpu/Native/Windows/Win64ContextOffsets.cs
index 10e22e8..79db3a9 100644
--- a/src/SharpEmu.Core/Cpu/Native/Windows/Win64ContextOffsets.cs
+++ b/src/SharpEmu.Core/Cpu/Native/Windows/Win64ContextOffsets.cs
@@ -11,6 +11,7 @@ namespace SharpEmu.Core.Cpu.Native.Windows;
///
internal static class Win64ContextOffsets
{
+ public const int Size = 0x4D0;
public const int Mxcsr = 52;
public const int Rax = 120;
public const int Rcx = 128;
diff --git a/src/SharpEmu.Core/Memory/PhysicalVirtualMemory.cs b/src/SharpEmu.Core/Memory/PhysicalVirtualMemory.cs
index 8ac6ca8..7add040 100644
--- a/src/SharpEmu.Core/Memory/PhysicalVirtualMemory.cs
+++ b/src/SharpEmu.Core/Memory/PhysicalVirtualMemory.cs
@@ -249,6 +249,71 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
var requestedCursor = AlignUp(desiredAddress, effectiveAlignment);
var cursor = GetAllocationSearchCursor(desiredAddress, requestedCursor, effectiveAlignment, executable);
+ // Under Rosetta 2 the kernel can ignore placement hints for whole
+ // windows, so page-stepped exact probes are pathological on macOS.
+ // Linux must keep using the exact-address search below: PS5 resource
+ // descriptors cannot represent ordinary 0x7F... host mappings. Linux
+ // HostMemory uses MAP_FIXED_NOREPLACE, making those low-address probes
+ // safe without clobbering existing host mappings.
+ if (OperatingSystem.IsMacOS())
+ {
+ // Prefer the requested low address. Besides matching the guest
+ // address model, this keeps the allocation representable by every
+ // PS5 GPU descriptor (the strictest ones carry 40 address bits).
+ try
+ {
+ var exactAddress = AllocateAt(
+ cursor,
+ alignedSize,
+ executable,
+ allowAlternative: false);
+ if (exactAddress == cursor)
+ {
+ actualAddress = exactAddress;
+ UpdateAllocationSearchCursor(
+ desiredAddress,
+ effectiveAlignment,
+ executable,
+ exactAddress + alignedSize);
+ return true;
+ }
+ }
+ catch
+ {
+ }
+
+ // Over-allocate by the alignment so a kernel-chosen placement
+ // always contains an aligned start; the unused head/tail stays
+ // part of the tracked region and is simply never handed out.
+ var reserveSize = effectiveAlignment > PageSize
+ ? alignedSize + effectiveAlignment
+ : alignedSize;
+ try
+ {
+ var posixAddress = AllocateAt(cursor, reserveSize, executable, allowAlternative: true);
+ if (posixAddress != 0)
+ {
+ var alignedBase = AlignUp(posixAddress, effectiveAlignment);
+ const ulong gpuAddressLimit = 1UL << 40;
+ if (alignedBase < gpuAddressLimit &&
+ alignedSize <= gpuAddressLimit - alignedBase &&
+ alignedBase + alignedSize <= posixAddress + reserveSize)
+ {
+ actualAddress = alignedBase;
+ UpdateAllocationSearchCursor(desiredAddress, effectiveAlignment, executable, alignedBase + alignedSize);
+ return true;
+ }
+
+ ReleaseUntrackedAllocation(posixAddress);
+ }
+ }
+ catch
+ {
+ }
+
+ return false;
+ }
+
for (var attempt = 0; attempt < 0x10000; attempt++)
{
if (cursor == 0 || ulong.MaxValue - cursor < alignedSize)
@@ -283,6 +348,28 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
return false;
}
+ private void ReleaseUntrackedAllocation(ulong address)
+ {
+ _gate.EnterWriteLock();
+ try
+ {
+ for (var i = 0; i < _regions.Count; i++)
+ {
+ if (_regions[i].VirtualAddress == address)
+ {
+ _regions.RemoveAt(i);
+ break;
+ }
+ }
+ }
+ finally
+ {
+ _gate.ExitWriteLock();
+ }
+
+ _hostMemory.Free(address);
+ }
+
public bool TryAllocateGuestMemory(ulong size, ulong alignment, out ulong address)
{
address = 0;
diff --git a/src/SharpEmu.Core/packages.lock.json b/src/SharpEmu.Core/packages.lock.json
index 42a3d8e..fe059ca 100644
--- a/src/SharpEmu.Core/packages.lock.json
+++ b/src/SharpEmu.Core/packages.lock.json
@@ -36,6 +36,23 @@
"Ultz.Native.GLFW": "3.4.0"
}
},
+ "Silk.NET.Input.Common": {
+ "type": "Transitive",
+ "resolved": "2.23.0",
+ "contentHash": "QbJVV7kFBHEByayXCYdJtXXI9Sp4a+QAf0IdGV6uCWkFYcEmqBYW3aaNGvFOdSwTBDbHL5T/OtOCrGh4qYhk7A==",
+ "dependencies": {
+ "Silk.NET.Windowing.Common": "2.23.0"
+ }
+ },
+ "Silk.NET.Input.Glfw": {
+ "type": "Transitive",
+ "resolved": "2.23.0",
+ "contentHash": "KGHYqsv/IQRJtD6dloYh2tN4CkaM40vxM2kj0cGKBoCQiBDYHHhJiyDTyMPx0W7Fz5IgnhnG42ELmIAa0DH69A==",
+ "dependencies": {
+ "Silk.NET.Input.Common": "2.23.0",
+ "Silk.NET.Windowing.Glfw": "2.23.0"
+ }
+ },
"Silk.NET.Maths": {
"type": "Transitive",
"resolved": "2.23.0",
@@ -74,6 +91,7 @@
"type": "Project",
"dependencies": {
"SharpEmu.HLE": "[1.0.0, )",
+ "Silk.NET.Input": "[2.23.0, )",
"Silk.NET.Vulkan": "[2.23.0, )",
"Silk.NET.Vulkan.Extensions.EXT": "[2.23.0, )",
"Silk.NET.Vulkan.Extensions.KHR": "[2.23.0, )",
@@ -83,6 +101,16 @@
"sharpemu.logging": {
"type": "Project"
},
+ "Silk.NET.Input": {
+ "type": "CentralTransitive",
+ "requested": "[2.23.0, )",
+ "resolved": "2.23.0",
+ "contentHash": "Xzl+tVwAp2eEd8blmGQjmJrsZPnp3PWG0KJjiAQHaY2Zr/ELVWeAROKXmZdCAvexzmte2JVGEy/dxnMycbxlpg==",
+ "dependencies": {
+ "Silk.NET.Input.Common": "2.23.0",
+ "Silk.NET.Input.Glfw": "2.23.0"
+ }
+ },
"Silk.NET.Vulkan": {
"type": "CentralTransitive",
"requested": "[2.23.0, )",
diff --git a/src/SharpEmu.HLE/Host/HostPlatform.cs b/src/SharpEmu.HLE/Host/HostPlatform.cs
index 15a60f5..30503bb 100644
--- a/src/SharpEmu.HLE/Host/HostPlatform.cs
+++ b/src/SharpEmu.HLE/Host/HostPlatform.cs
@@ -2,6 +2,7 @@
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Runtime.InteropServices;
+using SharpEmu.HLE.Host.Posix;
using SharpEmu.HLE.Host.Windows;
namespace SharpEmu.HLE.Host;
@@ -28,7 +29,14 @@ public static class HostPlatform
return new WindowsHostPlatform();
}
+ if ((OperatingSystem.IsLinux() || OperatingSystem.IsMacOS()) &&
+ RuntimeInformation.ProcessArchitecture == Architecture.X64)
+ {
+ return new PosixHostPlatform();
+ }
+
throw new PlatformNotSupportedException(
- "SharpEmu native guest execution requires a host platform backend and none exists for this OS/architecture yet (currently Windows x64 only).");
+ "SharpEmu native guest execution requires an x86-64 process on Windows, Linux, or macOS. " +
+ "On Apple Silicon, use the osx-x64 build under Rosetta 2.");
}
}
diff --git a/src/SharpEmu.HLE/Host/Posix/PosixAlsaAudioStream.cs b/src/SharpEmu.HLE/Host/Posix/PosixAlsaAudioStream.cs
new file mode 100644
index 0000000..afcae68
--- /dev/null
+++ b/src/SharpEmu.HLE/Host/Posix/PosixAlsaAudioStream.cs
@@ -0,0 +1,173 @@
+// Copyright (C) 2026 SharpEmu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+using System.Runtime.InteropServices;
+
+namespace SharpEmu.HLE.Host.Posix;
+
+///
+/// ALSA-based playback for Linux. The PCM device is opened in blocking mode
+/// with a device buffer sized to match the 32KB queue the other backends
+/// keep, so snd_pcm_writei itself provides the backpressure pacing. The
+/// "default" device routes through PulseAudio/PipeWire on desktops and to
+/// the hardware on bare ALSA setups; SHARPEMU_ALSA_DEVICE overrides it.
+///
+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;
+ private const int ErrorPipe = -32; // -EPIPE, underrun
+ private const int ErrorStreamPipe = -86; // -ESTRPIPE, suspended
+
+ private readonly object _gate = new();
+ private nint _pcm;
+ private bool _disposed;
+
+ public PosixAlsaAudioStream(uint sampleRate)
+ {
+ if (!OperatingSystem.IsLinux())
+ {
+ throw new PlatformNotSupportedException("ALSA audio is only available on Linux.");
+ }
+
+ var device = Environment.GetEnvironmentVariable("SHARPEMU_ALSA_DEVICE");
+ if (string.IsNullOrWhiteSpace(device))
+ {
+ device = "default";
+ }
+
+ var status = snd_pcm_open(out _pcm, device, StreamPlayback, 0);
+ if (status != 0)
+ {
+ throw new InvalidOperationException(
+ $"snd_pcm_open(\"{device}\") failed: {DescribeError(status)}.");
+ }
+
+ status = snd_pcm_set_params(
+ _pcm,
+ FormatS16LittleEndian,
+ AccessReadWriteInterleaved,
+ 2,
+ sampleRate,
+ 1,
+ DeviceLatencyMicroseconds);
+ if (status != 0)
+ {
+ _ = snd_pcm_close(_pcm);
+ _pcm = 0;
+ throw new InvalidOperationException(
+ $"snd_pcm_set_params({sampleRate} Hz) failed: {DescribeError(status)}.");
+ }
+ }
+
+ public bool Submit(ReadOnlySpan stereoPcm16)
+ {
+ lock (_gate)
+ {
+ if (_disposed)
+ {
+ return false;
+ }
+
+ return WritePcm(stereoPcm16, (uint)(stereoPcm16.Length / 4));
+ }
+ }
+
+ public void Dispose()
+ {
+ lock (_gate)
+ {
+ if (_disposed)
+ {
+ return;
+ }
+
+ _disposed = true;
+ if (_pcm != 0)
+ {
+ _ = snd_pcm_drop(_pcm);
+ _ = snd_pcm_close(_pcm);
+ _pcm = 0;
+ }
+ }
+ }
+
+ private bool WritePcm(ReadOnlySpan pcm, uint frames)
+ {
+ var recovered = false;
+ fixed (byte* data = pcm)
+ {
+ var offset = 0L;
+ while (offset < frames)
+ {
+ var written = snd_pcm_writei(
+ _pcm,
+ data + (offset * 4),
+ (nuint)(frames - offset));
+ if (written >= 0)
+ {
+ offset += written;
+ continue;
+ }
+
+ // One recovery attempt per submit covers underruns (-EPIPE)
+ // and suspend/resume (-ESTRPIPE); anything else, or a second
+ // failure, drops the buffer rather than stalling the guest.
+ if (recovered ||
+ (written != ErrorPipe && written != ErrorStreamPipe) ||
+ snd_pcm_recover(_pcm, (int)written, 1) != 0)
+ {
+ return false;
+ }
+
+ recovered = true;
+ }
+ }
+
+ return true;
+ }
+
+ private static string DescribeError(long status)
+ {
+ var message = Marshal.PtrToStringUTF8(snd_strerror((int)status));
+ return $"{message ?? "unknown error"} ({status})";
+ }
+
+ private const string Alsa = "libasound.so.2";
+
+ [DllImport(Alsa)]
+ private static extern int snd_pcm_open(
+ out nint pcm,
+ [MarshalAs(UnmanagedType.LPUTF8Str)] string name,
+ int stream,
+ int mode);
+
+ [DllImport(Alsa)]
+ private static extern int snd_pcm_set_params(
+ nint pcm,
+ int format,
+ int access,
+ uint channels,
+ uint rate,
+ int softResample,
+ uint latencyUs);
+
+ [DllImport(Alsa)]
+ private static extern long snd_pcm_writei(nint pcm, byte* buffer, nuint frames);
+
+ [DllImport(Alsa)]
+ private static extern int snd_pcm_recover(nint pcm, int error, int silent);
+
+ [DllImport(Alsa)]
+ private static extern int snd_pcm_drop(nint pcm);
+
+ [DllImport(Alsa)]
+ private static extern int snd_pcm_close(nint pcm);
+
+ [DllImport(Alsa)]
+ private static extern nint snd_strerror(int error);
+}
diff --git a/src/SharpEmu.HLE/Host/Posix/PosixCoreAudioStream.cs b/src/SharpEmu.HLE/Host/Posix/PosixCoreAudioStream.cs
new file mode 100644
index 0000000..f5ff329
--- /dev/null
+++ b/src/SharpEmu.HLE/Host/Posix/PosixCoreAudioStream.cs
@@ -0,0 +1,261 @@
+// Copyright (C) 2026 SharpEmu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+using System.Runtime.InteropServices;
+
+namespace SharpEmu.HLE.Host.Posix;
+
+///
+/// AudioQueue-based playback for macOS. Buffers are enqueued as stereo PCM16
+/// and returned by the queue's internal thread through the output callback;
+/// Submit applies the same 32KB backpressure the WinMM backend uses so guest
+/// pacing works identically.
+///
+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 object _gate = new();
+ private readonly AutoResetEvent _completion = new(false);
+ private readonly Queue _freeBuffers = new();
+ private GCHandle _selfHandle;
+ private nint _queue;
+ private int _queuedPcmBytes;
+ private bool _started;
+ private bool _disposed;
+
+ public PosixCoreAudioStream(uint sampleRate)
+ {
+ if (!OperatingSystem.IsMacOS())
+ {
+ throw new PlatformNotSupportedException("CoreAudio is only available on macOS.");
+ }
+
+ var format = new AudioStreamBasicDescription
+ {
+ SampleRate = sampleRate,
+ FormatId = FormatLinearPcm,
+ FormatFlags = FlagIsSignedInteger | FlagIsPacked,
+ BytesPerPacket = 4,
+ FramesPerPacket = 1,
+ BytesPerFrame = 4,
+ ChannelsPerFrame = 2,
+ BitsPerChannel = 16,
+ };
+
+ _selfHandle = GCHandle.Alloc(this);
+ var status = AudioQueueNewOutput(
+ &format,
+ &OutputCallback,
+ GCHandle.ToIntPtr(_selfHandle),
+ 0,
+ 0,
+ 0,
+ out _queue);
+ if (status != 0)
+ {
+ _selfHandle.Free();
+ throw new InvalidOperationException($"AudioQueueNewOutput failed with OSStatus {status}.");
+ }
+ }
+
+ public bool Submit(ReadOnlySpan stereoPcm16)
+ {
+ lock (_gate)
+ {
+ if (_disposed || _queue == 0)
+ {
+ return false;
+ }
+
+ var outputLength = stereoPcm16.Length;
+ while (_queuedPcmBytes != 0 &&
+ _queuedPcmBytes + outputLength > MaximumQueuedPcmBytes)
+ {
+ Monitor.Exit(_gate);
+ try
+ {
+ // Dispose can free the event while this thread waits
+ // outside the gate; treat that like a timed-out wait.
+ if (!_completion.WaitOne(TimeSpan.FromSeconds(1)))
+ {
+ return false;
+ }
+ }
+ catch (ObjectDisposedException)
+ {
+ return false;
+ }
+ finally
+ {
+ Monitor.Enter(_gate);
+ }
+
+ if (_disposed)
+ {
+ return false;
+ }
+ }
+
+ if (!TryTakeBuffer(outputLength, out var buffer))
+ {
+ return false;
+ }
+
+ var audioData = ((AudioQueueBuffer*)buffer)->AudioData;
+ stereoPcm16.CopyTo(new Span(audioData, outputLength));
+
+ ((AudioQueueBuffer*)buffer)->AudioDataByteSize = (uint)outputLength;
+ if (AudioQueueEnqueueBuffer(_queue, buffer, 0, 0) != 0)
+ {
+ _freeBuffers.Enqueue(buffer);
+ return false;
+ }
+
+ _queuedPcmBytes += outputLength;
+ if (!_started)
+ {
+ if (AudioQueueStart(_queue, 0) != 0)
+ {
+ // A queue that never starts never drains, so later
+ // submits would block on backpressure until their
+ // timeout. Tear the queue down and fail fast instead.
+ _ = AudioQueueDispose(_queue, true);
+ _queue = 0;
+ _queuedPcmBytes = 0;
+ _freeBuffers.Clear();
+ return false;
+ }
+
+ _started = true;
+ }
+
+ return true;
+ }
+ }
+
+ public void Dispose()
+ {
+ lock (_gate)
+ {
+ if (_disposed)
+ {
+ return;
+ }
+
+ _disposed = true;
+ if (_queue != 0)
+ {
+ // Synchronous dispose stops the queue, frees its buffers, and
+ // guarantees no further callbacks reference this instance.
+ _ = AudioQueueDispose(_queue, true);
+ _queue = 0;
+ }
+
+ _freeBuffers.Clear();
+ // Wake any submitter waiting on backpressure before the event
+ // goes away; a late waiter observes ObjectDisposedException and
+ // bails out in Submit.
+ _completion.Set();
+ _completion.Dispose();
+ if (_selfHandle.IsAllocated)
+ {
+ _selfHandle.Free();
+ }
+ }
+ }
+
+ private bool TryTakeBuffer(int length, out nint buffer)
+ {
+ while (_freeBuffers.TryDequeue(out buffer))
+ {
+ if (((AudioQueueBuffer*)buffer)->AudioDataBytesCapacity >= (uint)length)
+ {
+ return true;
+ }
+
+ _ = AudioQueueFreeBuffer(_queue, buffer);
+ }
+
+ return AudioQueueAllocateBuffer(_queue, (uint)length, out buffer) == 0;
+ }
+
+ [UnmanagedCallersOnly]
+ private static void OutputCallback(nint userData, nint queue, nint buffer)
+ {
+ if (GCHandle.FromIntPtr(userData).Target is not PosixCoreAudioStream port)
+ {
+ return;
+ }
+
+ lock (port._gate)
+ {
+ if (port._disposed)
+ {
+ return;
+ }
+
+ port._queuedPcmBytes -= checked((int)((AudioQueueBuffer*)buffer)->AudioDataByteSize);
+ port._freeBuffers.Enqueue(buffer);
+ }
+
+ port._completion.Set();
+ }
+
+ [StructLayout(LayoutKind.Sequential)]
+ private struct AudioStreamBasicDescription
+ {
+ public double SampleRate;
+ public uint FormatId;
+ public uint FormatFlags;
+ public uint BytesPerPacket;
+ public uint FramesPerPacket;
+ public uint BytesPerFrame;
+ public uint ChannelsPerFrame;
+ public uint BitsPerChannel;
+ public uint Reserved;
+ }
+
+ [StructLayout(LayoutKind.Sequential)]
+ private struct AudioQueueBuffer
+ {
+ public uint AudioDataBytesCapacity;
+ public void* AudioData;
+ public uint AudioDataByteSize;
+ public nint UserData;
+ public uint PacketDescriptionCapacity;
+ public nint PacketDescriptions;
+ public uint PacketDescriptionCount;
+ }
+
+ private const string AudioToolbox =
+ "/System/Library/Frameworks/AudioToolbox.framework/AudioToolbox";
+
+ [DllImport(AudioToolbox)]
+ private static extern int AudioQueueNewOutput(
+ AudioStreamBasicDescription* format,
+ delegate* unmanaged callback,
+ nint userData,
+ nint callbackRunLoop,
+ nint runLoopMode,
+ uint flags,
+ out nint queue);
+
+ [DllImport(AudioToolbox)]
+ private static extern int AudioQueueAllocateBuffer(nint queue, uint bufferByteSize, out nint buffer);
+
+ [DllImport(AudioToolbox)]
+ private static extern int AudioQueueFreeBuffer(nint queue, nint buffer);
+
+ [DllImport(AudioToolbox)]
+ private static extern int AudioQueueEnqueueBuffer(nint queue, nint buffer, uint packetDescriptionCount, nint packetDescriptions);
+
+ [DllImport(AudioToolbox)]
+ private static extern int AudioQueueStart(nint queue, nint startTime);
+
+ [DllImport(AudioToolbox)]
+ private static extern int AudioQueueDispose(nint queue, [MarshalAs(UnmanagedType.I1)] bool immediate);
+}
diff --git a/src/SharpEmu.HLE/Host/Posix/PosixHostAudio.cs b/src/SharpEmu.HLE/Host/Posix/PosixHostAudio.cs
new file mode 100644
index 0000000..133479e
--- /dev/null
+++ b/src/SharpEmu.HLE/Host/Posix/PosixHostAudio.cs
@@ -0,0 +1,21 @@
+// Copyright (C) 2026 SharpEmu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+namespace SharpEmu.HLE.Host.Posix;
+
+///
+/// POSIX audio output: CoreAudio (AudioQueue) on macOS, ALSA on Linux. Both
+/// streams accept the seam's interleaved stereo PCM16 and pace the guest via
+/// device-queue backpressure.
+///
+internal sealed class PosixHostAudio : IHostAudioOutput
+{
+ public string BackendName => OperatingSystem.IsMacOS() ? "coreaudio" : "alsa";
+
+ public IHostAudioStream OpenStereoPcm16Stream(uint sampleRate)
+ {
+ return OperatingSystem.IsMacOS()
+ ? new PosixCoreAudioStream(sampleRate)
+ : new PosixAlsaAudioStream(sampleRate);
+ }
+}
diff --git a/src/SharpEmu.HLE/Host/Posix/PosixHostInput.cs b/src/SharpEmu.HLE/Host/Posix/PosixHostInput.cs
new file mode 100644
index 0000000..303d6c8
--- /dev/null
+++ b/src/SharpEmu.HLE/Host/Posix/PosixHostInput.cs
@@ -0,0 +1,79 @@
+// Copyright (C) 2026 SharpEmu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+namespace SharpEmu.HLE.Host.Posix;
+
+///
+/// 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
+/// 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.
+///
+public interface IPosixWindowInputSource
+{
+ /// True while the window's keyboard is delivering events.
+ bool HasKeyboardFocus { get; }
+
+ /// Windows virtual-key semantics; the source translates.
+ bool IsKeyDown(int virtualKey);
+
+ /// Same contract as .
+ int GetGamepadStates(Span 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;
+
+ /// Called by the presenter's window layer when input is ready.
+ 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 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 ?? false;
+ }
+
+ public bool IsKeyDown(int virtualKey)
+ {
+ return _source?.IsKeyDown(virtualKey) ?? false;
+ }
+}
diff --git a/src/SharpEmu.HLE/Host/Posix/PosixHostMemory.cs b/src/SharpEmu.HLE/Host/Posix/PosixHostMemory.cs
new file mode 100644
index 0000000..a463d8b
--- /dev/null
+++ b/src/SharpEmu.HLE/Host/Posix/PosixHostMemory.cs
@@ -0,0 +1,508 @@
+// Copyright (C) 2026 SharpEmu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+using System.Runtime.InteropServices;
+
+namespace SharpEmu.HLE.Host.Posix;
+
+///
+/// POSIX virtual memory backend implemented over mmap/mprotect/munmap with a
+/// shadow region table that answers VirtualQuery-style questions and tracks
+/// page protections.
+/// POSIX anonymous mappings are demand-paged by the kernel, so Win32
+/// "reserve-only" regions are mapped as committed memory directly and
+/// commit requests become protection changes.
+///
+internal sealed unsafe class PosixHostMemory : IHostMemory
+{
+ private const uint MEM_COMMIT = 0x1000;
+ private const uint MEM_RESERVE = 0x2000;
+ private const uint MEM_RELEASE = 0x8000;
+ private const uint MEM_FREE_STATE = 0x10000;
+ private const uint MEM_PRIVATE = 0x20000;
+
+ private const uint PAGE_NOACCESS = 0x01;
+ private const uint PAGE_READONLY = 0x02;
+ private const uint PAGE_READWRITE = 0x04;
+ private const uint PAGE_EXECUTE = 0x10;
+ private const uint PAGE_EXECUTE_READ = 0x20;
+ private const uint PAGE_EXECUTE_READWRITE = 0x40;
+
+ private const ulong PageSize = 0x1000;
+
+ private struct BasicInfo
+ {
+ public ulong BaseAddress;
+ public ulong AllocationBase;
+ public uint AllocationProtect;
+ public ulong RegionSize;
+ public uint State;
+ public uint Protect;
+ public uint Type;
+ }
+
+ public ulong Allocate(ulong desiredAddress, ulong size, HostPageProtection protection)
+ {
+ return (ulong)Posix.Alloc(
+ (void*)desiredAddress,
+ (nuint)size,
+ MEM_COMMIT | MEM_RESERVE,
+ ToNativeProtection(protection));
+ }
+
+ public ulong Reserve(ulong desiredAddress, ulong size, HostPageProtection protection)
+ {
+ return (ulong)Posix.Alloc(
+ (void*)desiredAddress,
+ (nuint)size,
+ MEM_RESERVE,
+ ToNativeProtection(protection));
+ }
+
+ public bool Commit(ulong address, ulong size, HostPageProtection protection)
+ {
+ return Posix.Alloc(
+ (void*)address,
+ (nuint)size,
+ MEM_COMMIT,
+ ToNativeProtection(protection)) != null;
+ }
+
+ public bool Free(ulong address)
+ {
+ return Posix.Free((void*)address, 0, MEM_RELEASE);
+ }
+
+ public bool Protect(
+ ulong address,
+ ulong size,
+ HostPageProtection protection,
+ out uint rawOldProtection)
+ {
+ return Posix.Protect(
+ (void*)address,
+ (nuint)size,
+ ToNativeProtection(protection),
+ out rawOldProtection);
+ }
+
+ public bool ProtectRaw(
+ ulong address,
+ ulong size,
+ uint rawProtection,
+ out uint rawOldProtection)
+ {
+ return Posix.Protect(
+ (void*)address,
+ (nuint)size,
+ rawProtection,
+ out rawOldProtection);
+ }
+
+ public bool Query(ulong address, out HostRegionInfo info)
+ {
+ if (Posix.Query((void*)address, out var nativeInfo) == 0)
+ {
+ info = default;
+ return false;
+ }
+
+ info = new HostRegionInfo(
+ nativeInfo.BaseAddress,
+ nativeInfo.AllocationBase,
+ nativeInfo.RegionSize,
+ nativeInfo.State switch
+ {
+ MEM_COMMIT => HostRegionState.Committed,
+ MEM_RESERVE => HostRegionState.Reserved,
+ _ => HostRegionState.Free,
+ },
+ nativeInfo.State,
+ ToHostProtection(nativeInfo.Protect),
+ nativeInfo.Protect,
+ nativeInfo.AllocationProtect);
+ return true;
+ }
+
+ public void FlushInstructionCache(ulong address, ulong size)
+ {
+ _ = address;
+ _ = size;
+ // The supported POSIX process is x86-64 (including Rosetta 2), whose
+ // instruction cache is coherent. A future arm64 backend must call the
+ // platform instruction-cache invalidation API here.
+ }
+
+ private static uint ToNativeProtection(HostPageProtection protection) => protection switch
+ {
+ HostPageProtection.NoAccess => PAGE_NOACCESS,
+ HostPageProtection.ReadOnly => PAGE_READONLY,
+ HostPageProtection.ReadWrite => PAGE_READWRITE,
+ HostPageProtection.Execute => PAGE_EXECUTE,
+ HostPageProtection.ReadExecute => PAGE_EXECUTE_READ,
+ HostPageProtection.ReadWriteExecute => PAGE_EXECUTE_READWRITE,
+ HostPageProtection.ExecuteWriteCopy => PAGE_EXECUTE_READWRITE,
+ _ => throw new ArgumentOutOfRangeException(nameof(protection), protection, null),
+ };
+
+ private static HostPageProtection ToHostProtection(uint protection) => protection switch
+ {
+ PAGE_READONLY => HostPageProtection.ReadOnly,
+ PAGE_READWRITE => HostPageProtection.ReadWrite,
+ PAGE_EXECUTE => HostPageProtection.Execute,
+ PAGE_EXECUTE_READ => HostPageProtection.ReadExecute,
+ PAGE_EXECUTE_READWRITE => HostPageProtection.ReadWriteExecute,
+ _ => HostPageProtection.NoAccess,
+ };
+
+ private static class Posix
+ {
+ private const int PROT_NONE = 0x0;
+ private const int PROT_READ = 0x1;
+ private const int PROT_WRITE = 0x2;
+ private const int PROT_EXEC = 0x4;
+
+ private const int MAP_PRIVATE = 0x02;
+ private const int MAP_FIXED = 0x10;
+ private static readonly int MAP_ANON = OperatingSystem.IsMacOS() ? 0x1000 : 0x20;
+ private static readonly int MAP_NORESERVE = OperatingSystem.IsMacOS() ? 0 : 0x4000;
+
+ // Linux-only: fail instead of clobbering an existing mapping.
+ private const int MAP_FIXED_NOREPLACE = 0x100000;
+
+ private static readonly nint MAP_FAILED = -1;
+
+ private static readonly object Gate = new();
+ private static readonly SortedList Regions = new();
+
+ private sealed class Region
+ {
+ public ulong Base;
+ public ulong Size;
+ public uint DefaultProtect;
+ public Dictionary? PageProtects;
+
+ public ulong End => Base + Size;
+
+ public uint ProtectAt(ulong pageAddress)
+ {
+ if (PageProtects is not null && PageProtects.TryGetValue(pageAddress, out var overriden))
+ {
+ return overriden;
+ }
+
+ return DefaultProtect;
+ }
+ }
+
+ public static void* Alloc(void* address, nuint size, uint allocationType, uint protect)
+ {
+ if (size == 0)
+ {
+ return null;
+ }
+
+ var alignedSize = AlignUp((ulong)size, PageSize);
+
+ lock (Gate)
+ {
+ if (allocationType == MEM_COMMIT && address != null &&
+ TryFindRegionLocked((ulong)address, out var existing))
+ {
+ // Note: MEM_RESERVE requests that overlap an existing
+ // region must fail like Win32 does; only a pure commit
+ // may target pages inside a tracked mapping.
+ // Commit inside an existing mapping: the pages are already
+ // backed (demand paged), so only apply the protection.
+ var start = AlignDown((ulong)address, PageSize);
+ var end = AlignUp((ulong)address + alignedSize, PageSize);
+ if (end <= start || end > existing.End)
+ {
+ // Win32 fails a commit that runs past its reservation
+ // instead of committing a prefix; committing partially
+ // here would let callers believe the whole range is
+ // usable.
+ return null;
+ }
+
+ if (mprotect((nint)start, (nuint)(end - start), ToPosixProtect(protect)) != 0)
+ {
+ return null;
+ }
+
+ SetProtectRangeLocked(existing, start, end - start, protect);
+ return address;
+ }
+
+ if ((allocationType & MEM_RESERVE) == 0)
+ {
+ // MEM_COMMIT alone outside any known region is invalid here.
+ return null;
+ }
+
+ var posixProtect = ToPosixProtect(protect);
+ var flags = MAP_PRIVATE | MAP_ANON;
+ if ((allocationType & MEM_COMMIT) == 0)
+ {
+ // Reserve-only: keep the requested protection so the region
+ // is usable without a separate commit step, but tell the
+ // kernel not to account swap for it where supported.
+ flags |= MAP_NORESERVE;
+ }
+
+ nint result;
+ if (address != null)
+ {
+ // Win32 maps at exactly the requested address or fails
+ // without touching existing mappings. Fail up front on
+ // any overlap we track, then place the mapping: Linux
+ // gets MAP_FIXED_NOREPLACE (fails cleanly on host
+ // mappings too). Darwin lacks NOREPLACE and plain
+ // MAP_FIXED would silently clobber untracked host
+ // memory (dyld, the runtime's JIT heap, Rosetta), so
+ // pass the address as a hint instead -- the kernel
+ // honors it when the range is free and relocates the
+ // mapping otherwise, which we treat as failure.
+ if (OverlapsTrackedRegionLocked((ulong)address, alignedSize))
+ {
+ Trace($"exact overlap: addr=0x{(ulong)address:X16} size=0x{alignedSize:X}");
+ return null;
+ }
+
+ var exactFlags = OperatingSystem.IsMacOS() ? flags : flags | MAP_FIXED_NOREPLACE;
+ result = mmap((nint)address, (nuint)alignedSize, posixProtect, exactFlags, -1, 0);
+ if (result == MAP_FAILED || (ulong)result != (ulong)address)
+ {
+ Trace($"exact mmap failed: addr=0x{(ulong)address:X16} got=0x{(ulong)result:X16} size=0x{alignedSize:X} errno={Marshal.GetLastPInvokeError()}");
+ if (result != MAP_FAILED)
+ {
+ munmap(result, (nuint)alignedSize);
+ }
+
+ return null;
+ }
+ }
+ else
+ {
+ result = mmap(0, (nuint)alignedSize, posixProtect, flags, -1, 0);
+ if (result == MAP_FAILED)
+ {
+ Trace($"mmap failed: size=0x{alignedSize:X} errno={Marshal.GetLastPInvokeError()}");
+ return null;
+ }
+ }
+
+ Regions[(ulong)result] = new Region
+ {
+ Base = (ulong)result,
+ Size = alignedSize,
+ DefaultProtect = protect
+ };
+
+ return (void*)result;
+ }
+ }
+
+ public static bool Free(void* address, nuint size, uint freeType)
+ {
+ _ = size;
+ _ = freeType;
+
+ lock (Gate)
+ {
+ if (!Regions.TryGetValue((ulong)address, out var region))
+ {
+ return false;
+ }
+
+ Regions.Remove((ulong)address);
+ return munmap((nint)address, (nuint)region.Size) == 0;
+ }
+ }
+
+ public static bool Protect(void* address, nuint size, uint newProtect, out uint oldProtect)
+ {
+ oldProtect = PAGE_NOACCESS;
+ if (size == 0)
+ {
+ return false;
+ }
+
+ var start = AlignDown((ulong)address, PageSize);
+ var end = AlignUp((ulong)address + size, PageSize);
+
+ lock (Gate)
+ {
+ if (!TryFindRegionLocked(start, out var region) || end > region.End)
+ {
+ return false;
+ }
+
+ oldProtect = region.ProtectAt(start);
+ if (mprotect((nint)start, (nuint)(end - start), ToPosixProtect(newProtect)) != 0)
+ {
+ return false;
+ }
+
+ SetProtectRangeLocked(region, start, end - start, newProtect);
+ return true;
+ }
+ }
+
+ public static nuint Query(void* address, out BasicInfo info)
+ {
+ info = default;
+ var pageAddress = AlignDown((ulong)address, PageSize);
+
+ lock (Gate)
+ {
+ if (TryFindRegionLocked(pageAddress, out var region))
+ {
+ // Win32 VirtualQuery reports a run of pages sharing the
+ // same protection, so stop the run where it changes.
+ var protect = region.ProtectAt(pageAddress);
+ var runEnd = pageAddress + PageSize;
+ while (runEnd < region.End && region.ProtectAt(runEnd) == protect)
+ {
+ runEnd += PageSize;
+ }
+
+ info.BaseAddress = pageAddress;
+ info.AllocationBase = region.Base;
+ info.AllocationProtect = region.DefaultProtect;
+ info.RegionSize = runEnd - pageAddress;
+ info.State = MEM_COMMIT;
+ info.Protect = protect;
+ info.Type = MEM_PRIVATE;
+ return (nuint)sizeof(BasicInfo);
+ }
+
+ // Untracked host memory (runtime heaps, stacks, libraries) is
+ // reported as a free block reaching to the next tracked region
+ // so scanning callers keep advancing.
+ var nextBase = ulong.MaxValue;
+ foreach (var regionBase in Regions.Keys)
+ {
+ if (regionBase > pageAddress)
+ {
+ nextBase = regionBase;
+ break;
+ }
+ }
+
+ info.BaseAddress = pageAddress;
+ info.AllocationBase = 0;
+ info.AllocationProtect = PAGE_NOACCESS;
+ info.RegionSize = (nextBase == ulong.MaxValue ? pageAddress + PageSize : nextBase) - pageAddress;
+ info.State = MEM_FREE_STATE;
+ info.Protect = PAGE_NOACCESS;
+ info.Type = 0;
+ return (nuint)sizeof(BasicInfo);
+ }
+ }
+
+ private static bool OverlapsTrackedRegionLocked(ulong start, ulong size)
+ {
+ var end = start + size;
+ foreach (var region in Regions.Values)
+ {
+ if (region.Base < end && start < region.End)
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private static bool TryFindRegionLocked(ulong address, out Region region)
+ {
+ region = null!;
+ var keys = Regions.Keys;
+ var low = 0;
+ var high = keys.Count - 1;
+ Region? candidate = null;
+ while (low <= high)
+ {
+ var middle = low + ((high - low) >> 1);
+ var entry = Regions.Values[middle];
+ if (entry.Base <= address)
+ {
+ candidate = entry;
+ low = middle + 1;
+ }
+ else
+ {
+ high = middle - 1;
+ }
+ }
+
+ if (candidate is null || address >= candidate.End)
+ {
+ return false;
+ }
+
+ region = candidate;
+ return true;
+ }
+
+ private static void SetProtectRangeLocked(Region region, ulong start, ulong size, uint protect)
+ {
+ if (start == region.Base && size >= region.Size)
+ {
+ region.DefaultProtect = protect;
+ region.PageProtects = null;
+ return;
+ }
+
+ region.PageProtects ??= new Dictionary();
+ var end = start + size;
+ for (var pageAddress = start; pageAddress < end; pageAddress += PageSize)
+ {
+ if (protect == region.DefaultProtect)
+ {
+ region.PageProtects.Remove(pageAddress);
+ }
+ else
+ {
+ region.PageProtects[pageAddress] = protect;
+ }
+ }
+ }
+
+ private static int ToPosixProtect(uint win32Protect)
+ {
+ return win32Protect switch
+ {
+ PAGE_NOACCESS => PROT_NONE,
+ PAGE_READONLY => PROT_READ,
+ PAGE_READWRITE => PROT_READ | PROT_WRITE,
+ PAGE_EXECUTE => PROT_READ | PROT_EXEC,
+ PAGE_EXECUTE_READ => PROT_READ | PROT_EXEC,
+ PAGE_EXECUTE_READWRITE => PROT_READ | PROT_WRITE | PROT_EXEC,
+ _ => PROT_READ | PROT_WRITE
+ };
+ }
+
+ private static void Trace(string message)
+ {
+ if (string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_LOG_VMEM"), "1", StringComparison.Ordinal))
+ {
+ Console.Error.WriteLine($"[HOSTMEM] {message}");
+ }
+ }
+
+ private static ulong AlignDown(ulong value, ulong alignment) => value & ~(alignment - 1);
+
+ private static ulong AlignUp(ulong value, ulong alignment) => checked((value + alignment - 1) & ~(alignment - 1));
+
+ [DllImport("libc", SetLastError = true)]
+ private static extern nint mmap(nint addr, nuint length, int prot, int flags, int fd, long offset);
+
+ [DllImport("libc", SetLastError = true)]
+ private static extern int munmap(nint addr, nuint length);
+
+ [DllImport("libc", SetLastError = true)]
+ private static extern int mprotect(nint addr, nuint length, int prot);
+ }
+}
diff --git a/src/SharpEmu.HLE/Host/Posix/PosixHostPlatform.cs b/src/SharpEmu.HLE/Host/Posix/PosixHostPlatform.cs
new file mode 100644
index 0000000..fc452d3
--- /dev/null
+++ b/src/SharpEmu.HLE/Host/Posix/PosixHostPlatform.cs
@@ -0,0 +1,17 @@
+// Copyright (C) 2026 SharpEmu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+namespace SharpEmu.HLE.Host.Posix;
+
+internal sealed class PosixHostPlatform : IHostPlatform
+{
+ public IHostMemory Memory { get; } = new PosixHostMemory();
+
+ public IHostThreading Threading { get; } = new PosixHostThreading();
+
+ public IHostSymbolResolver Symbols { get; } = new PosixHostSymbolResolver();
+
+ public IHostAudioOutput Audio { get; } = new PosixHostAudio();
+
+ public IHostInput Input { get; } = new PosixHostInput();
+}
diff --git a/src/SharpEmu.HLE/Host/Posix/PosixHostStubs.cs b/src/SharpEmu.HLE/Host/Posix/PosixHostStubs.cs
new file mode 100644
index 0000000..22ae8ec
--- /dev/null
+++ b/src/SharpEmu.HLE/Host/Posix/PosixHostStubs.cs
@@ -0,0 +1,657 @@
+// Copyright (C) 2026 SharpEmu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+using System.Runtime.InteropServices;
+
+namespace SharpEmu.HLE.Host.Posix;
+
+///
+/// POSIX replacements for the kernel32 helpers the native backend embeds in
+/// emitted x86-64 code. Every stub exposed here follows the Win64 calling
+/// convention the emitted call sites were written for (first argument in
+/// ECX, result in RAX, Win64 non-volatile registers preserved), so the
+/// emission code stays identical across platforms.
+///
+internal static unsafe class PosixHostStubs
+{
+ private static readonly object Gate = new();
+ private static bool _initialized;
+ private static nint _tlsGetValueStub;
+ private static nint _queryPerformanceCounterStub;
+ private static nint _switchToThreadStub;
+ private static nint _sleepStub;
+ private static nint _waitForSingleObjectStub;
+ private static nint _setEventStub;
+ private static nint _exitThreadStub;
+
+ public static nint TlsGetValueStubAddress
+ {
+ get { EnsureInitialized(); return _tlsGetValueStub; }
+ }
+
+ public static nint QueryPerformanceCounterStubAddress
+ {
+ get { EnsureInitialized(); return _queryPerformanceCounterStub; }
+ }
+
+ public static nint SwitchToThreadStubAddress
+ {
+ get { EnsureInitialized(); return _switchToThreadStub; }
+ }
+
+ public static nint SleepStubAddress
+ {
+ get { EnsureInitialized(); return _sleepStub; }
+ }
+
+ ///
+ /// Win64-convention replacements for the kernel32 event/thread helpers the
+ /// native guest worker loop embeds. The "handle" they take is a worker
+ /// event created by : a dispatch semaphore
+ /// on macOS, an unnamed POSIX semaphore on Linux. The wait stub always
+ /// waits forever (the worker loop passes INFINITE) and retries EINTR.
+ ///
+ public static nint WaitForSingleObjectStubAddress
+ {
+ get { EnsureInitialized(); return _waitForSingleObjectStub; }
+ }
+
+ public static nint SetEventStubAddress
+ {
+ get { EnsureInitialized(); return _setEventStub; }
+ }
+
+ public static nint ExitThreadStubAddress
+ {
+ get { EnsureInitialized(); return _exitThreadStub; }
+ }
+
+ ///
+ /// Creates a binary-semaphore worker event signalable/waitable both from
+ /// managed code and from emitted native code (via the stub addresses
+ /// above). Returns 0 on failure.
+ ///
+ public static nint CreateWorkerEvent()
+ {
+ if (OperatingSystem.IsMacOS())
+ {
+ return dispatch_semaphore_create(0);
+ }
+
+ var semaphore = Marshal.AllocHGlobal(64);
+ if (sem_init(semaphore, 0, 0) != 0)
+ {
+ Marshal.FreeHGlobal(semaphore);
+ return 0;
+ }
+
+ return semaphore;
+ }
+
+ public static bool SignalWorkerEvent(nint handle)
+ {
+ if (OperatingSystem.IsMacOS())
+ {
+ _ = dispatch_semaphore_signal(handle);
+ return true;
+ }
+
+ return sem_post(handle) == 0;
+ }
+
+ /// Waits for a worker event; a negative timeout waits forever.
+ public static bool WaitWorkerEvent(nint handle, int timeoutMilliseconds)
+ {
+ if (OperatingSystem.IsMacOS())
+ {
+ if (timeoutMilliseconds < 0)
+ {
+ return dispatch_semaphore_wait(handle, ulong.MaxValue) == 0;
+ }
+
+ var deadline = dispatch_time(0, timeoutMilliseconds * 1_000_000L);
+ return dispatch_semaphore_wait(handle, deadline) == 0;
+ }
+
+ if (timeoutMilliseconds < 0)
+ {
+ while (sem_wait(handle) != 0)
+ {
+ // EINTR: retry.
+ }
+
+ return true;
+ }
+
+ var deadlineTicks = Environment.TickCount64 + timeoutMilliseconds;
+ while (sem_trywait(handle) != 0)
+ {
+ if (Environment.TickCount64 >= deadlineTicks)
+ {
+ return false;
+ }
+
+ System.Threading.Thread.Sleep(1);
+ }
+
+ return true;
+ }
+
+ public static void DestroyWorkerEvent(nint handle)
+ {
+ if (handle == 0)
+ {
+ return;
+ }
+
+ if (OperatingSystem.IsMacOS())
+ {
+ dispatch_release(handle);
+ return;
+ }
+
+ _ = sem_destroy(handle);
+ Marshal.FreeHGlobal(handle);
+ }
+
+ ///
+ /// Starts a raw pthread at a native entry point (pthread entries take their
+ /// argument in RDI; the worker loop stub ignores it). Returns an opaque
+ /// handle for /,
+ /// or 0 on failure.
+ ///
+ public static nint CreateWorkerThread(nint entry, nint parameter, nuint stackReserveBytes, out uint threadId)
+ {
+ threadId = 0;
+ byte* attr = stackalloc byte[512];
+ if (pthread_attr_init(attr) != 0)
+ {
+ return 0;
+ }
+
+ try
+ {
+ if (stackReserveBytes != 0)
+ {
+ _ = pthread_attr_setstacksize(attr, nuint.Max(stackReserveBytes, 512 * 1024));
+ }
+
+ nint thread;
+ if (pthread_create(&thread, attr, entry, parameter) != 0)
+ {
+ return 0;
+ }
+
+ if (OperatingSystem.IsMacOS())
+ {
+ ulong numericId;
+ if (pthread_threadid_np(thread, &numericId) == 0)
+ {
+ threadId = unchecked((uint)numericId);
+ }
+ }
+ else
+ {
+ threadId = unchecked((uint)thread);
+ }
+
+ var holder = (nint*)Marshal.AllocHGlobal(sizeof(nint) * 2);
+ holder[0] = thread;
+ holder[1] = 0; // joined flag
+ return (nint)holder;
+ }
+ finally
+ {
+ _ = pthread_attr_destroy(attr);
+ }
+ }
+
+ ///
+ /// Waits for a worker thread to exit. Liveness is probed with
+ /// pthread_kill(thread, 0) (ESRCH once the thread has terminated) because
+ /// neither platform offers a portable timed join; the exited thread is then
+ /// joined so its resources are reclaimed.
+ ///
+ public static bool WaitForWorkerThreadExit(nint threadHandle, uint timeoutMilliseconds)
+ {
+ var holder = (nint*)threadHandle;
+ if (holder == null)
+ {
+ return false;
+ }
+
+ if (holder[1] != 0)
+ {
+ return true;
+ }
+
+ var thread = holder[0];
+ var deadline = Environment.TickCount64 + timeoutMilliseconds;
+ while (pthread_kill(thread, 0) == 0)
+ {
+ if (Environment.TickCount64 >= deadline)
+ {
+ return false;
+ }
+
+ System.Threading.Thread.Sleep(1);
+ }
+
+ _ = pthread_join(thread, null);
+ holder[1] = 1;
+ return true;
+ }
+
+ public static void CloseWorkerThreadHandle(nint threadHandle)
+ {
+ var holder = (nint*)threadHandle;
+ if (holder == null)
+ {
+ return;
+ }
+
+ if (holder[1] == 0)
+ {
+ // Never observed exiting: detach so the thread does not leak a
+ // zombie join target when it eventually terminates.
+ _ = pthread_detach(holder[0]);
+ }
+
+ Marshal.FreeHGlobal(threadHandle);
+ }
+
+ /// Allocates a pthread TLS key, mirroring kernel32!TlsAlloc.
+ public static uint TlsAlloc()
+ {
+ if (OperatingSystem.IsMacOS())
+ {
+ nuint key;
+ return pthread_key_create_mac(&key, 0) == 0 ? (uint)key : uint.MaxValue;
+ }
+
+ uint key32;
+ return pthread_key_create_linux(&key32, 0) == 0 ? key32 : uint.MaxValue;
+ }
+
+ public static bool TlsFree(uint key)
+ {
+ return OperatingSystem.IsMacOS()
+ ? pthread_key_delete_mac((nuint)key) == 0
+ : pthread_key_delete_linux(key) == 0;
+ }
+
+ public static bool TlsSetValue(uint key, nint value)
+ {
+ return OperatingSystem.IsMacOS()
+ ? pthread_setspecific_mac((nuint)key, value) == 0
+ : pthread_setspecific_linux(key, value) == 0;
+ }
+
+ public static nint TlsGetValue(uint key)
+ {
+ return OperatingSystem.IsMacOS()
+ ? pthread_getspecific_mac((nuint)key)
+ : pthread_getspecific_linux(key);
+ }
+
+ /// Stable numeric id of the calling thread (kernel32!GetCurrentThreadId).
+ public static uint GetCurrentThreadId()
+ {
+ if (OperatingSystem.IsMacOS())
+ {
+ ulong tid;
+ return pthread_threadid_np(0, &tid) == 0 ? unchecked((uint)tid) : 0u;
+ }
+
+ return unchecked((uint)gettid());
+ }
+
+ ///
+ /// Wraps a managed callback (compiled for the SysV ABI on POSIX .NET) in a
+ /// thunk that accepts up to four integer arguments in the Win64 ABI the
+ /// emitted x86-64 call sites use. Win64 passes args in rcx/rdx/r8/r9 and
+ /// treats rdi/rsi as non-volatile; SysV expects rdi/rsi/rdx/rcx and
+ /// clobbers them, so the thunk saves rdi/rsi, shuffles the registers, keeps
+ /// the stack 16-byte aligned for the call, and forwards the rax result.
+ ///
+ public static nint CreateWin64ToSysVThunk(nint sysvTarget)
+ {
+ var memory = HostPlatform.Current.Memory;
+ var page = (byte*)memory.Allocate(
+ 0,
+ 4096,
+ HostPageProtection.ReadWriteExecute);
+ if (page == null)
+ {
+ throw new OutOfMemoryException("Failed to allocate Win64->SysV thunk page");
+ }
+
+ var offset = 0;
+ Emit(page, ref offset, 0x57); // push rdi
+ Emit(page, ref offset, 0x56); // push rsi
+ Emit(page, ref offset, 0x48, 0x89, 0xCF); // mov rdi, rcx
+ Emit(page, ref offset, 0x48, 0x89, 0xD6); // mov rsi, rdx
+ Emit(page, ref offset, 0x4C, 0x89, 0xC2); // mov rdx, r8
+ Emit(page, ref offset, 0x4C, 0x89, 0xC9); // mov rcx, r9
+ Emit(page, ref offset, 0x48, 0x83, 0xEC, 0x08); // sub rsp, 8 (realign to 16)
+ EmitMovRaxImm64(page, ref offset, sysvTarget); // mov rax, target
+ Emit(page, ref offset, 0xFF, 0xD0); // call rax
+ Emit(page, ref offset, 0x48, 0x83, 0xC4, 0x08); // add rsp, 8
+ Emit(page, ref offset, 0x5E); // pop rsi
+ Emit(page, ref offset, 0x5F); // pop rdi
+ Emit(page, ref offset, 0xC3); // ret
+
+ if (!memory.Protect((ulong)page, 4096, HostPageProtection.ReadExecute, out _))
+ {
+ throw new InvalidOperationException("Failed to protect Win64->SysV thunk page");
+ }
+
+ memory.FlushInstructionCache((ulong)page, (ulong)offset);
+ return (nint)page;
+ }
+
+ private static void EnsureInitialized()
+ {
+ if (_initialized)
+ {
+ return;
+ }
+
+ lock (Gate)
+ {
+ if (_initialized)
+ {
+ return;
+ }
+
+ BuildStubs();
+ _initialized = true;
+ }
+ }
+
+ private static void BuildStubs()
+ {
+ var memory = HostPlatform.Current.Memory;
+ var page = (byte*)memory.Allocate(
+ 0,
+ 4096,
+ HostPageProtection.ReadWriteExecute);
+ if (page == null)
+ {
+ throw new OutOfMemoryException("Failed to allocate POSIX host helper stub page");
+ }
+
+ var offset = 0;
+ _tlsGetValueStub = EmitTlsGetValue(page, ref offset);
+ _queryPerformanceCounterStub = EmitQueryPerformanceCounter(page, ref offset);
+ _switchToThreadStub = EmitSwitchToThread(page, ref offset);
+ _sleepStub = EmitSleep(page, ref offset);
+ _waitForSingleObjectStub = EmitWaitForSingleObject(page, ref offset);
+ _setEventStub = EmitSetEvent(page, ref offset);
+ _exitThreadStub = EmitExitThread(page, ref offset);
+
+ if (!memory.Protect((ulong)page, 4096, HostPageProtection.ReadExecute, out _))
+ {
+ throw new InvalidOperationException("Failed to protect POSIX host helper stub page");
+ }
+
+ memory.FlushInstructionCache((ulong)page, (ulong)offset);
+ }
+
+ private static nint EmitTlsGetValue(byte* page, ref int offset)
+ {
+ var start = (nint)(page + offset);
+ if (OperatingSystem.IsMacOS())
+ {
+ // On macOS x86-64 pthread keys index the gs-based thread specific
+ // data array directly, so TlsGetValue(index in ecx) collapses to a
+ // single load that clobbers nothing but RAX.
+ Emit(page, ref offset, 0x89, 0xC8); // mov eax, ecx
+ Emit(page, ref offset, 0x65, 0x48, 0x8B, 0x04, 0xC5, 0, 0, 0, 0); // mov rax, gs:[rax*8]
+ Emit(page, ref offset, 0xC3); // ret
+ return start;
+ }
+
+ // Linux: call pthread_getspecific, preserving the registers that are
+ // volatile in SysV but non-volatile in Win64 (rsi, rdi).
+ var pthreadGetSpecific = ResolveLibcExport("pthread_getspecific");
+ Emit(page, ref offset, 0x56); // push rsi
+ Emit(page, ref offset, 0x57); // push rdi
+ Emit(page, ref offset, 0x48, 0x83, 0xEC, 0x08); // sub rsp, 8
+ Emit(page, ref offset, 0x89, 0xCF); // mov edi, ecx
+ EmitMovRaxImm64(page, ref offset, pthreadGetSpecific); // mov rax, imm64
+ Emit(page, ref offset, 0xFF, 0xD0); // call rax
+ Emit(page, ref offset, 0x48, 0x83, 0xC4, 0x08); // add rsp, 8
+ Emit(page, ref offset, 0x5F); // pop rdi
+ Emit(page, ref offset, 0x5E); // pop rsi
+ Emit(page, ref offset, 0xC3); // ret
+ return start;
+ }
+
+ private static nint EmitQueryPerformanceCounter(byte* page, ref int offset)
+ {
+ // BOOL QueryPerformanceCounter(LARGE_INTEGER* out in rcx): the emitted
+ // consumers only need a monotonically increasing counter, which rdtsc
+ // provides without leaving Win64-safe registers.
+ var start = (nint)(page + offset);
+ Emit(page, ref offset, 0x0F, 0x31); // rdtsc
+ Emit(page, ref offset, 0x48, 0xC1, 0xE2, 0x20); // shl rdx, 32
+ Emit(page, ref offset, 0x48, 0x09, 0xD0); // or rax, rdx
+ Emit(page, ref offset, 0x48, 0x89, 0x01); // mov [rcx], rax
+ Emit(page, ref offset, 0xB8, 0x01, 0x00, 0x00, 0x00); // mov eax, 1
+ Emit(page, ref offset, 0xC3); // ret
+ return start;
+ }
+
+ private static nint EmitSwitchToThread(byte* page, ref int offset)
+ {
+ var schedYield = ResolveLibcExport("sched_yield");
+ var start = (nint)(page + offset);
+ Emit(page, ref offset, 0x56); // push rsi
+ Emit(page, ref offset, 0x57); // push rdi
+ Emit(page, ref offset, 0x48, 0x83, 0xEC, 0x08); // sub rsp, 8
+ EmitMovRaxImm64(page, ref offset, schedYield); // mov rax, imm64
+ Emit(page, ref offset, 0xFF, 0xD0); // call rax
+ Emit(page, ref offset, 0x48, 0x83, 0xC4, 0x08); // add rsp, 8
+ Emit(page, ref offset, 0x5F); // pop rdi
+ Emit(page, ref offset, 0x5E); // pop rsi
+ Emit(page, ref offset, 0xB8, 0x01, 0x00, 0x00, 0x00); // mov eax, 1
+ Emit(page, ref offset, 0xC3); // ret
+ return start;
+ }
+
+ private static nint EmitSleep(byte* page, ref int offset)
+ {
+ // void Sleep(DWORD milliseconds in ecx) -> usleep(microseconds in edi).
+ var usleep = ResolveLibcExport("usleep");
+ var start = (nint)(page + offset);
+ Emit(page, ref offset, 0x56); // push rsi
+ Emit(page, ref offset, 0x57); // push rdi
+ Emit(page, ref offset, 0x48, 0x83, 0xEC, 0x08); // sub rsp, 8
+ Emit(page, ref offset, 0x89, 0xCF); // mov edi, ecx
+ Emit(page, ref offset, 0x81, 0xFF, 0xFF, 0x0F, 0x00, 0x00); // cmp edi, 0xFFF
+ Emit(page, ref offset, 0x76, 0x05); // jbe +5
+ Emit(page, ref offset, 0xBF, 0xFF, 0x0F, 0x00, 0x00); // mov edi, 0xFFF (cap at ~4s)
+ Emit(page, ref offset, 0x69, 0xFF, 0xE8, 0x03, 0x00, 0x00); // imul edi, edi, 1000
+ EmitMovRaxImm64(page, ref offset, usleep); // mov rax, imm64
+ Emit(page, ref offset, 0xFF, 0xD0); // call rax
+ Emit(page, ref offset, 0x48, 0x83, 0xC4, 0x08); // add rsp, 8
+ Emit(page, ref offset, 0x5F); // pop rdi
+ Emit(page, ref offset, 0x5E); // pop rsi
+ Emit(page, ref offset, 0xC3); // ret
+ return start;
+ }
+
+ private static nint EmitWaitForSingleObject(byte* page, ref int offset)
+ {
+ // DWORD WaitForSingleObject(worker event in rcx, timeout in edx): the
+ // worker loop only ever waits forever, so the timeout is ignored.
+ // macOS waits on a dispatch semaphore (needs DISPATCH_TIME_FOREVER in
+ // rsi), Linux on a sem_t; both retry until the wait succeeds (EINTR).
+ var wait = ResolveLibcExport(
+ OperatingSystem.IsMacOS() ? "dispatch_semaphore_wait" : "sem_wait");
+ var start = (nint)(page + offset);
+ Emit(page, ref offset, 0x56); // push rsi
+ Emit(page, ref offset, 0x57); // push rdi
+ Emit(page, ref offset, 0x53); // push rbx
+ Emit(page, ref offset, 0x48, 0x89, 0xCB); // mov rbx, rcx
+ var retry = offset;
+ Emit(page, ref offset, 0x48, 0x89, 0xDF); // mov rdi, rbx
+ if (OperatingSystem.IsMacOS())
+ {
+ Emit(page, ref offset, 0x48, 0xC7, 0xC6, 0xFF, 0xFF, 0xFF, 0xFF); // mov rsi, DISPATCH_TIME_FOREVER
+ }
+ EmitMovRaxImm64(page, ref offset, wait); // mov rax, imm64
+ Emit(page, ref offset, 0xFF, 0xD0); // call rax
+ Emit(page, ref offset, 0x85, 0xC0); // test eax, eax
+ Emit(page, ref offset, 0x75, unchecked((byte)(retry - (offset + 2)))); // jnz retry
+ Emit(page, ref offset, 0x31, 0xC0); // xor eax, eax (WAIT_OBJECT_0)
+ Emit(page, ref offset, 0x5B); // pop rbx
+ Emit(page, ref offset, 0x5F); // pop rdi
+ Emit(page, ref offset, 0x5E); // pop rsi
+ Emit(page, ref offset, 0xC3); // ret
+ return start;
+ }
+
+ private static nint EmitSetEvent(byte* page, ref int offset)
+ {
+ // BOOL SetEvent(worker event in rcx) -> dispatch_semaphore_signal /
+ // sem_post.
+ var signal = ResolveLibcExport(
+ OperatingSystem.IsMacOS() ? "dispatch_semaphore_signal" : "sem_post");
+ var start = (nint)(page + offset);
+ Emit(page, ref offset, 0x56); // push rsi
+ Emit(page, ref offset, 0x57); // push rdi
+ Emit(page, ref offset, 0x48, 0x83, 0xEC, 0x08); // sub rsp, 8
+ Emit(page, ref offset, 0x48, 0x89, 0xCF); // mov rdi, rcx
+ EmitMovRaxImm64(page, ref offset, signal); // mov rax, imm64
+ Emit(page, ref offset, 0xFF, 0xD0); // call rax
+ Emit(page, ref offset, 0x48, 0x83, 0xC4, 0x08); // add rsp, 8
+ Emit(page, ref offset, 0x5F); // pop rdi
+ Emit(page, ref offset, 0x5E); // pop rsi
+ Emit(page, ref offset, 0xB8, 0x01, 0x00, 0x00, 0x00); // mov eax, 1
+ Emit(page, ref offset, 0xC3); // ret
+ return start;
+ }
+
+ private static nint EmitExitThread(byte* page, ref int offset)
+ {
+ // void ExitThread(code in ecx) -> pthread_exit(NULL); never returns,
+ // so no registers need preserving. pthread_exit runs the thread's TSD
+ // destructors, which detaches the CLR if the thread lazily attached.
+ var pthreadExit = ResolveLibcExport("pthread_exit");
+ var start = (nint)(page + offset);
+ Emit(page, ref offset, 0x48, 0x83, 0xEC, 0x08); // sub rsp, 8
+ Emit(page, ref offset, 0x31, 0xFF); // xor edi, edi
+ EmitMovRaxImm64(page, ref offset, pthreadExit); // mov rax, imm64
+ Emit(page, ref offset, 0xFF, 0xD0); // call rax
+ Emit(page, ref offset, 0xCC); // int3 (never returns)
+ return start;
+ }
+
+ private static nint ResolveLibcExport(string name)
+ {
+ var libc = NativeLibrary.Load(OperatingSystem.IsMacOS() ? "libSystem.dylib" : "libc.so.6");
+ return NativeLibrary.GetExport(libc, name);
+ }
+
+ private static void Emit(byte* page, ref int offset, params byte[] bytes)
+ {
+ foreach (var value in bytes)
+ {
+ page[offset++] = value;
+ }
+ }
+
+ private static void EmitMovRaxImm64(byte* page, ref int offset, nint value)
+ {
+ Emit(page, ref offset, 0x48, 0xB8);
+ *(long*)(page + offset) = value;
+ offset += sizeof(long);
+ }
+
+ [DllImport("libc", EntryPoint = "pthread_key_create", SetLastError = true)]
+ private static extern int pthread_key_create_mac(nuint* key, nint destructor);
+
+ [DllImport("libc", EntryPoint = "pthread_key_create", SetLastError = true)]
+ private static extern int pthread_key_create_linux(uint* key, nint destructor);
+
+ [DllImport("libc", EntryPoint = "pthread_key_delete")]
+ private static extern int pthread_key_delete_mac(nuint key);
+
+ [DllImport("libc", EntryPoint = "pthread_key_delete")]
+ private static extern int pthread_key_delete_linux(uint key);
+
+ [DllImport("libc", EntryPoint = "pthread_setspecific")]
+ private static extern int pthread_setspecific_mac(nuint key, nint value);
+
+ [DllImport("libc", EntryPoint = "pthread_setspecific")]
+ private static extern int pthread_setspecific_linux(uint key, nint value);
+
+ [DllImport("libc", EntryPoint = "pthread_getspecific")]
+ private static extern nint pthread_getspecific_mac(nuint key);
+
+ [DllImport("libc", EntryPoint = "pthread_getspecific")]
+ private static extern nint pthread_getspecific_linux(uint key);
+
+ [DllImport("libc")]
+ private static extern int pthread_threadid_np(nint thread, ulong* threadId);
+
+ [DllImport("libc")]
+ private static extern int gettid();
+
+ [DllImport("libc")]
+ private static extern int pthread_attr_init(byte* attr);
+
+ [DllImport("libc")]
+ private static extern int pthread_attr_destroy(byte* attr);
+
+ [DllImport("libc")]
+ private static extern int pthread_attr_setstacksize(byte* attr, nuint stackSize);
+
+ [DllImport("libc")]
+ private static extern int pthread_create(nint* thread, byte* attr, nint startRoutine, nint arg);
+
+ [DllImport("libc")]
+ private static extern int pthread_join(nint thread, nint* returnValue);
+
+ [DllImport("libc")]
+ private static extern int pthread_detach(nint thread);
+
+ [DllImport("libc")]
+ private static extern int pthread_kill(nint thread, int signal);
+
+ // macOS: dispatch semaphores back the worker events (unnamed sem_init is
+ // unsupported on Darwin). libSystem reexports libdispatch, so "libc"
+ // resolves these like the pthread imports above.
+ [DllImport("libc")]
+ private static extern nint dispatch_semaphore_create(long value);
+
+ [DllImport("libc")]
+ private static extern nint dispatch_semaphore_signal(nint semaphore);
+
+ [DllImport("libc")]
+ private static extern nint dispatch_semaphore_wait(nint semaphore, ulong timeout);
+
+ [DllImport("libc")]
+ private static extern ulong dispatch_time(ulong when, long deltaNanoseconds);
+
+ [DllImport("libc")]
+ private static extern void dispatch_release(nint handle);
+
+ // Linux: unnamed POSIX semaphores.
+ [DllImport("libc")]
+ private static extern int sem_init(nint semaphore, int shared, uint value);
+
+ [DllImport("libc")]
+ private static extern int sem_post(nint semaphore);
+
+ [DllImport("libc")]
+ private static extern int sem_wait(nint semaphore);
+
+ [DllImport("libc")]
+ private static extern int sem_trywait(nint semaphore);
+
+ [DllImport("libc")]
+ private static extern int sem_destroy(nint semaphore);
+}
diff --git a/src/SharpEmu.HLE/Host/Posix/PosixHostSymbolResolver.cs b/src/SharpEmu.HLE/Host/Posix/PosixHostSymbolResolver.cs
new file mode 100644
index 0000000..0a0056e
--- /dev/null
+++ b/src/SharpEmu.HLE/Host/Posix/PosixHostSymbolResolver.cs
@@ -0,0 +1,19 @@
+// Copyright (C) 2026 SharpEmu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+namespace SharpEmu.HLE.Host.Posix;
+
+internal sealed class PosixHostSymbolResolver : IHostSymbolResolver
+{
+ public nint GetAddress(HostRuntimeFunction function) => function switch
+ {
+ HostRuntimeFunction.TlsGetValue => PosixHostStubs.TlsGetValueStubAddress,
+ HostRuntimeFunction.QueryPerformanceCounter => PosixHostStubs.QueryPerformanceCounterStubAddress,
+ HostRuntimeFunction.SwitchToThread => PosixHostStubs.SwitchToThreadStubAddress,
+ HostRuntimeFunction.Sleep => PosixHostStubs.SleepStubAddress,
+ HostRuntimeFunction.WaitForSingleObject => PosixHostStubs.WaitForSingleObjectStubAddress,
+ HostRuntimeFunction.SetEvent => PosixHostStubs.SetEventStubAddress,
+ HostRuntimeFunction.ExitThread => PosixHostStubs.ExitThreadStubAddress,
+ _ => throw new ArgumentOutOfRangeException(nameof(function), function, null),
+ };
+}
diff --git a/src/SharpEmu.HLE/Host/Posix/PosixHostThreading.cs b/src/SharpEmu.HLE/Host/Posix/PosixHostThreading.cs
new file mode 100644
index 0000000..7bb11fb
--- /dev/null
+++ b/src/SharpEmu.HLE/Host/Posix/PosixHostThreading.cs
@@ -0,0 +1,57 @@
+// Copyright (C) 2026 SharpEmu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+namespace SharpEmu.HLE.Host.Posix;
+
+internal sealed class PosixHostThreading : IHostThreading
+{
+ public uint AllocateTlsSlot() => PosixHostStubs.TlsAlloc();
+
+ public bool FreeTlsSlot(uint slot) => PosixHostStubs.TlsFree(slot);
+
+ public bool SetTlsValue(uint slot, nint value) => PosixHostStubs.TlsSetValue(slot, value);
+
+ public nint GetTlsValue(uint slot) => PosixHostStubs.TlsGetValue(slot);
+
+ public uint CurrentThreadId => PosixHostStubs.GetCurrentThreadId();
+
+ public void RequestTimerResolution()
+ {
+ // POSIX sleep primitives are already high-resolution; there is no
+ // timeBeginPeriod equivalent to request.
+ }
+
+ // Thread affinity is advisory on POSIX hosts (macOS offers no
+ // pthread-level affinity API); callers treat false as "not applied".
+ public bool TrySetCurrentThreadAffinity(nuint affinityMask)
+ {
+ _ = affinityMask;
+ return false;
+ }
+
+ public nint CreateNativeThread(
+ nint entry,
+ nint parameter,
+ nuint stackReserveBytes,
+ out uint threadId)
+ {
+ return PosixHostStubs.CreateWorkerThread(entry, parameter, stackReserveBytes, out threadId);
+ }
+
+ public bool WaitForThreadExit(nint threadHandle, uint timeoutMilliseconds)
+ {
+ return PosixHostStubs.WaitForWorkerThreadExit(threadHandle, timeoutMilliseconds);
+ }
+
+ public void CloseThreadHandle(nint threadHandle)
+ {
+ PosixHostStubs.CloseWorkerThreadHandle(threadHandle);
+ }
+
+ public bool TryCaptureThreadRegisters(uint threadId, out HostCapturedRegisters registers)
+ {
+ _ = threadId;
+ registers = default;
+ return false;
+ }
+}
diff --git a/src/SharpEmu.HLE/HostMainThread.cs b/src/SharpEmu.HLE/HostMainThread.cs
new file mode 100644
index 0000000..78f27ca
--- /dev/null
+++ b/src/SharpEmu.HLE/HostMainThread.cs
@@ -0,0 +1,79 @@
+// Copyright (C) 2026 SharpEmu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+using System.Collections.Concurrent;
+
+namespace SharpEmu.HLE;
+
+///
+/// Runs work on the real process main thread. GLFW windowing must live on
+/// that thread on macOS (AppKit) and Linux (X11's single event queue), so the
+/// CLI moves emulation onto a worker thread, parks the main thread in
+/// , and the video presenter posts its window loop here. On
+/// Windows stays false and the window keeps its own
+/// thread.
+///
+public static class HostMainThread
+{
+ private static readonly BlockingCollection _work = new();
+ private static Action? _shutdownRequestHandler;
+
+ public static bool IsAvailable { get; private set; }
+
+ ///
+ /// Registers a callback invoked by so a
+ /// long-running posted work item (the presenter's window loop) can be
+ /// asked to return to the pump.
+ ///
+ public static void SetShutdownRequestHandler(Action handler) =>
+ _shutdownRequestHandler = handler;
+
+ /// Marks the pump as present. Call before guest code can run.
+ public static void Enable() => IsAvailable = true;
+
+ public static void Post(Action work)
+ {
+ try
+ {
+ _work.Add(work);
+ }
+ catch (InvalidOperationException)
+ {
+ // Shutdown already requested; the process is exiting.
+ }
+ }
+
+ ///
+ /// Services posted work on the calling (main) thread until
+ /// is called and the queue drains.
+ ///
+ public static void Pump()
+ {
+ foreach (var work in _work.GetConsumingEnumerable())
+ {
+ try
+ {
+ work();
+ }
+ catch (Exception exception)
+ {
+ Console.Error.WriteLine($"[LOADER][ERROR] Main-thread work failed: {exception}");
+ }
+ }
+ }
+
+ public static void Shutdown()
+ {
+ IsAvailable = false;
+ try
+ {
+ _shutdownRequestHandler?.Invoke();
+ }
+ catch (Exception exception)
+ {
+ Console.Error.WriteLine($"[LOADER][WARN] Main-thread shutdown handler failed: {exception.Message}");
+ }
+
+ _work.CompleteAdding();
+ }
+}
diff --git a/src/SharpEmu.HLE/SharpEmu.HLE.csproj b/src/SharpEmu.HLE/SharpEmu.HLE.csproj
index 6b026db..b6845be 100644
--- a/src/SharpEmu.HLE/SharpEmu.HLE.csproj
+++ b/src/SharpEmu.HLE/SharpEmu.HLE.csproj
@@ -12,6 +12,10 @@ SPDX-License-Identifier: GPL-2.0-or-later
$(NoWarn);1591
+
+
+
+
diff --git a/src/SharpEmu.Libs/Agc/AgcExports.cs b/src/SharpEmu.Libs/Agc/AgcExports.cs
index 109995e..7e10146 100644
--- a/src/SharpEmu.Libs/Agc/AgcExports.cs
+++ b/src/SharpEmu.Libs/Agc/AgcExports.cs
@@ -5,6 +5,7 @@ using SharpEmu.HLE;
using SharpEmu.Libs.Kernel;
using SharpEmu.Libs.VideoOut;
using System.Buffers.Binary;
+using System.Collections.Concurrent;
using System.Runtime.CompilerServices;
namespace SharpEmu.Libs.Agc;
@@ -3379,6 +3380,7 @@ public static class AgcExports
$"agc.rt_writer seq={drawSequence} target=0x{target.Address:X16} " +
$"fmt={target.Format} tile={target.TileMode} " +
$"size={target.Width}x{target.Height} vertices={vertexCount} " +
+ $"prim=0x{primitiveType:X} indexed={indexed} " +
$"es=0x{(hasExportShader ? exportShaderAddress : 0):X16} " +
$"ps=0x{(hasPixelShader ? pixelShaderAddress : 0):X16}");
}
@@ -3415,6 +3417,8 @@ public static class AgcExports
CreateVulkanGuestMemoryBuffers(translatedDraw.GlobalMemoryBindings);
var vertexBuffers =
CreateVulkanGuestVertexBuffers(translatedDraw.VertexInputs);
+ TraceRectListVertices(translatedDraw, vertexBuffers);
+ TraceGrassDrawVertices(translatedDraw, textures, vertexBuffers);
VulkanVideoPresenter.SubmitOffscreenTranslatedDraw(
translatedDraw.PixelSpirv,
textures,
@@ -4381,6 +4385,7 @@ public static class AgcExports
descriptor.Width > 8192 ||
descriptor.Height > 8192)
{
+ TraceTextureFallback(descriptor, "invalid-descriptor");
texture = CreateFallbackGuestDrawTexture(isStorage, descriptor.Format, descriptor.NumberType);
return true;
}
@@ -4398,6 +4403,9 @@ public static class AgcExports
sourceByteCount > MaxPresentedTextureBytes ||
sourceByteCount > int.MaxValue)
{
+ TraceTextureFallback(
+ descriptor,
+ $"invalid-byte-count:{sourceByteCount}");
texture = CreateFallbackGuestDrawTexture(isStorage, descriptor.Format, descriptor.NumberType);
return true;
}
@@ -4433,7 +4441,10 @@ public static class AgcExports
if (descriptor.Address != 0)
{
var storageSource = new byte[(int)sourceByteCount];
- if (ctx.Memory.TryRead(descriptor.Address, storageSource) &&
+ if ((ctx.Memory.TryRead(descriptor.Address, storageSource) ||
+ KernelMemoryCompatExports.TryReadTrackedLibcHeapGpuAlias(
+ descriptor.Address,
+ storageSource)) &&
storageSource.AsSpan().IndexOfAnyExcept((byte)0) >= 0)
{
initialPixels = storageSource;
@@ -4459,8 +4470,14 @@ public static class AgcExports
}
var source = new byte[(int)sourceByteCount];
- if (!ctx.Memory.TryRead(descriptor.Address, source))
+ if (!ctx.Memory.TryRead(descriptor.Address, source) &&
+ !KernelMemoryCompatExports.TryReadTrackedLibcHeapGpuAlias(
+ descriptor.Address,
+ source))
{
+ TraceTextureFallback(
+ descriptor,
+ $"guest-read-failed:{sourceByteCount}");
texture = CreateFallbackGuestDrawTexture(isStorage, descriptor.Format, descriptor.NumberType);
return true;
}
@@ -4486,6 +4503,7 @@ public static class AgcExports
$"size={descriptor.Width}x{descriptor.Height} pitch={descriptor.Pitch} " +
$"dst=0x{descriptor.DstSelect:X3} " +
$"bytes={source.Length} nonzero64={nonZero}");
+ DumpTextureSourceIfRequested(descriptor, sourceWidth, source);
var rgba = source;
texture = new VulkanGuestDrawTexture(
@@ -4506,6 +4524,158 @@ public static class AgcExports
return true;
}
+ private static int _textureFallbackTraceCount;
+
+ private static void TraceTextureFallback(
+ TextureDescriptor descriptor,
+ string reason)
+ {
+ var mode = Environment.GetEnvironmentVariable("SHARPEMU_TRACE_GUEST_IMAGES");
+ if ((!string.Equals(mode, "1", StringComparison.Ordinal) &&
+ !string.Equals(mode, "present", StringComparison.OrdinalIgnoreCase)) ||
+ Interlocked.Increment(ref _textureFallbackTraceCount) > 64)
+ {
+ return;
+ }
+
+ Console.Error.WriteLine(
+ $"[LOADER][TRACE] agc.texture_fallback reason={reason} " +
+ $"addr=0x{descriptor.Address:X16} type={descriptor.Type} " +
+ $"size={descriptor.Width}x{descriptor.Height} pitch={descriptor.Pitch} " +
+ $"fmt={descriptor.Format} num={descriptor.NumberType} " +
+ $"tile={descriptor.TileMode} mip={descriptor.MipLevels} " +
+ $"dst=0x{descriptor.DstSelect:X3}");
+ }
+
+
+
+ private static int _grassTraceCount;
+
+ private static void TraceGrassDrawVertices(
+ TranslatedGuestDraw draw,
+ IReadOnlyList textures,
+ IReadOnlyList vertexBuffers)
+ {
+ if (_grassTraceCount >= 6 ||
+ !textures.Any(texture => texture.Width == 288 && texture.Height == 160) ||
+ vertexBuffers.Count == 0 ||
+ Interlocked.Increment(ref _grassTraceCount) > 6)
+ {
+ return;
+ }
+
+ var text = new System.Text.StringBuilder();
+ text.Append($"agc.grassdraw prim=0x{draw.PrimitiveType:X} verts={draw.VertexCount} ");
+ text.Append($"indexed={draw.IndexBuffer is not null} buffers={vertexBuffers.Count}");
+ foreach (var buffer in vertexBuffers)
+ {
+ text.Append(
+ $"\n loc={buffer.Location} fmt={buffer.DataFormat}/{buffer.NumberFormat}x{buffer.ComponentCount} " +
+ $"stride={buffer.Stride} offset={buffer.OffsetBytes} bytes={buffer.Data.Length}");
+ var stride = Math.Max(buffer.Stride, 4u);
+ var maxVerts = Math.Min(6, (int)((buffer.Data.Length - buffer.OffsetBytes) / stride));
+ for (var vertex = 0; vertex < maxVerts; vertex++)
+ {
+ var baseOffset = (int)(buffer.OffsetBytes + vertex * stride);
+ var components = Math.Min(4, (int)((buffer.Data.Length - baseOffset) / 4));
+ text.Append($"\n v{vertex}:");
+ for (var c = 0; c < components; c++)
+ {
+ text.Append($" {BitConverter.ToSingle(buffer.Data, baseOffset + c * 4):0.#####}");
+ }
+ }
+ }
+
+ TraceAgcShader(text.ToString());
+ }
+
+ private static int _rectListTraceCount;
+
+ private static void TraceRectListVertices(
+ TranslatedGuestDraw draw,
+ IReadOnlyList vertexBuffers)
+ {
+ if (draw.PrimitiveType != 0x11 ||
+ draw.IndexBuffer is not null ||
+ vertexBuffers.Count == 0 ||
+ _rectListTraceCount >= 8 ||
+ Interlocked.Increment(ref _rectListTraceCount) > 8)
+ {
+ return;
+ }
+
+ var buffer = vertexBuffers[0];
+ var stride = Math.Max(buffer.Stride, 4u);
+ var text = new System.Text.StringBuilder();
+ for (var vertex = 0; vertex < 3; vertex++)
+ {
+ var baseOffset = (int)(buffer.OffsetBytes + vertex * stride);
+ if (baseOffset + 16 > buffer.Data.Length)
+ {
+ break;
+ }
+
+ var x = BitConverter.ToSingle(buffer.Data, baseOffset);
+ var y = BitConverter.ToSingle(buffer.Data, baseOffset + 4);
+ var z = BitConverter.ToSingle(buffer.Data, baseOffset + 8);
+ var w = BitConverter.ToSingle(buffer.Data, baseOffset + 12);
+ text.Append($" v{vertex}=({x:0.###},{y:0.###},{z:0.###},{w:0.###})");
+ }
+
+ TraceAgcShader(
+ $"agc.rectlist verts={draw.VertexCount} stride={buffer.Stride} " +
+ $"fmt={buffer.DataFormat}/{buffer.NumberFormat}x{buffer.ComponentCount}{text}");
+ }
+
+ private static int _textureDumpCount;
+ private static readonly ConcurrentDictionary _textureDumpKeys = new();
+
+ ///
+ /// Writes raw sampled-texture bytes (as read from guest memory) when
+ /// SHARPEMU_TEXTURE_DUMP_DIR is set, so upload-time content can be
+ /// inspected offline. File name records size and effective pitch.
+ ///
+ private static void DumpTextureSourceIfRequested(
+ in TextureDescriptor descriptor,
+ uint sourcePitch,
+ byte[] source)
+ {
+ var directory = Environment.GetEnvironmentVariable("SHARPEMU_TEXTURE_DUMP_DIR");
+ if (string.IsNullOrWhiteSpace(directory))
+ {
+ return;
+ }
+
+ var key = $"0x{descriptor.Address:X}-{descriptor.Width}x{descriptor.Height}";
+ var occurrence = _textureDumpKeys.AddOrUpdate(key, 1, static (_, count) => count + 1);
+ // First uses plus periodic later snapshots (the game reuses the same
+ // allocation for successive full-screen images).
+ if ((occurrence > 3 && occurrence % 500 >= 3) ||
+ Interlocked.Increment(ref _textureDumpCount) > 200)
+ {
+ return;
+ }
+
+ var index = _textureDumpCount;
+
+ try
+ {
+ Directory.CreateDirectory(directory);
+ var path = Path.Combine(
+ directory,
+ $"{index:D3}-0x{descriptor.Address:X}-{descriptor.Width}x{descriptor.Height}" +
+ $"-p{sourcePitch}-f{descriptor.Format}-t{descriptor.TileMode}.bin");
+ File.WriteAllBytes(path, source);
+ }
+ catch (Exception exception)
+ {
+ // A bad SHARPEMU_TEXTURE_DUMP_DIR (permissions, invalid path)
+ // must not take the emulator down; the dump is a debug aid.
+ Console.Error.WriteLine(
+ $"[LOADER][WARN] Texture dump failed: {exception.Message}");
+ }
+ }
+
private static VulkanGuestDrawTexture CreateFallbackGuestDrawTexture(
bool isStorage,
uint format,
diff --git a/src/SharpEmu.Libs/Kernel/KernelMemoryCompatExports.cs b/src/SharpEmu.Libs/Kernel/KernelMemoryCompatExports.cs
index da9065d..9d5c90b 100644
--- a/src/SharpEmu.Libs/Kernel/KernelMemoryCompatExports.cs
+++ b/src/SharpEmu.Libs/Kernel/KernelMemoryCompatExports.cs
@@ -125,6 +125,13 @@ public static class KernelMemoryCompatExports
private static ulong _nextPhysicalAddress;
private static ulong _nextVirtualAddress;
+ // First guest virtual address handed out for direct/flexible mappings
+ // when the game does not request one. 4GB is free on Windows, but on
+ // POSIX hosts it belongs to the host image / runtime (the Mach-O image
+ // base is 0x100000000 on macOS), so search from a guest-owned window
+ // well clear of host mappings instead.
+ private static readonly ulong DefaultMapSearchBase =
+ OperatingSystem.IsWindows() ? 0x1_0000_0000UL : 0x20_0000_0000UL;
private static ulong _mainDirectMemoryPoolBase = UnsetMainDirectMemoryPoolBase;
private static ulong _allocatedFlexibleBytes;
private static ulong _threadAtexitCountCallback;
@@ -241,7 +248,7 @@ public static class KernelMemoryCompatExports
lock (_memoryGate)
{
var desiredAddress = AlignUp(
- _nextVirtualAddress == 0 ? 0x1_0000_0000UL : _nextVirtualAddress,
+ _nextVirtualAddress == 0 ? DefaultMapSearchBase : _nextVirtualAddress,
effectiveAlignment);
if (!TryReserveGuestVirtualRange(ctx, desiredAddress, mappedLength, protection, effectiveAlignment, out address) ||
address == 0)
@@ -3185,7 +3192,7 @@ public static class KernelMemoryCompatExports
? requestedAddress
: directMemoryStart != 0
? AlignUp(directMemoryStart, effectiveAlignment)
- : AlignUp(_nextVirtualAddress == 0 ? 0x1_0000_0000UL : _nextVirtualAddress, effectiveAlignment);
+ : AlignUp(_nextVirtualAddress == 0 ? DefaultMapSearchBase : _nextVirtualAddress, effectiveAlignment);
var reserved = false;
if (fixedMapping && requestedAddress != 0)
@@ -3291,7 +3298,7 @@ public static class KernelMemoryCompatExports
var fixedMapping = (flags & 0x10UL) != 0;
var desiredAddress = requestedAddress != 0
? requestedAddress
- : AlignUp(_nextVirtualAddress == 0 ? 0x1_0000_0000UL : _nextVirtualAddress, 0x1000UL);
+ : AlignUp(_nextVirtualAddress == 0 ? DefaultMapSearchBase : _nextVirtualAddress, 0x1000UL);
if (fixedMapping && requestedAddress != 0)
{
@@ -6191,6 +6198,55 @@ public static class KernelMemoryCompatExports
return false;
}
+ internal static bool TryReadTrackedLibcHeapGpuAlias(
+ ulong packedAddress,
+ Span destination)
+ {
+ if (destination.IsEmpty)
+ {
+ return true;
+ }
+
+ // Gen5 texture descriptors retain 46 bits of the byte address. Host
+ // libc allocations can live at 0x7F... on Linux, so recover the full
+ // tracked allocation address when the descriptor contains its packed
+ // low-bit alias.
+ const ulong textureAddressMask = (1UL << 46) - 1;
+ var length = (ulong)destination.Length;
+ ulong resolvedAddress = 0;
+ lock (_libcAllocGate)
+ {
+ foreach (var (allocationAddress, allocation) in _libcAllocations)
+ {
+ var packedBase = allocationAddress & textureAddressMask;
+ if (packedAddress < packedBase)
+ {
+ continue;
+ }
+
+ var offset = packedAddress - packedBase;
+ var allocationSize = (ulong)allocation.Size;
+ if (offset > allocationSize || length > allocationSize - offset)
+ {
+ continue;
+ }
+
+ var candidate = allocationAddress + offset;
+ if (resolvedAddress != 0 && resolvedAddress != candidate)
+ {
+ // Do not guess if two live host allocations collide after
+ // descriptor address packing.
+ return false;
+ }
+
+ resolvedAddress = candidate;
+ }
+
+ return resolvedAddress != 0 &&
+ TryReadHostMemory(resolvedAddress, destination);
+ }
+ }
+
private static bool TryAllocateLibcHeap(ulong requestedSize, nuint alignment, bool zeroFill, out ulong address)
{
address = 0;
diff --git a/src/SharpEmu.Libs/Kernel/KernelVirtualRangeAllocator.cs b/src/SharpEmu.Libs/Kernel/KernelVirtualRangeAllocator.cs
index 8971dc8..50c8868 100644
--- a/src/SharpEmu.Libs/Kernel/KernelVirtualRangeAllocator.cs
+++ b/src/SharpEmu.Libs/Kernel/KernelVirtualRangeAllocator.cs
@@ -54,7 +54,10 @@ internal static class KernelVirtualRangeAllocator
}
catch
{
- Console.Error.WriteLine($"[LOADER][TRACE] {traceName}: AllocateAt invocation threw");
+ // Expected when a fixed-address request cannot be satisfied on
+ // this host; the caller falls back or reports the failure.
+ Console.Error.WriteLine(
+ $"[LOADER][TRACE] {traceName}: no host mapping at 0x{desiredAddress:X16} len=0x{length:X}");
return false;
}
}
diff --git a/src/SharpEmu.Libs/Pad/HostWindowInput.cs b/src/SharpEmu.Libs/Pad/HostWindowInput.cs
new file mode 100644
index 0000000..9a609b6
--- /dev/null
+++ b/src/SharpEmu.Libs/Pad/HostWindowInput.cs
@@ -0,0 +1,275 @@
+// Copyright (C) 2026 SharpEmu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+using SharpEmu.HLE.Host;
+using SharpEmu.HLE.Host.Posix;
+using Silk.NET.Input;
+
+namespace SharpEmu.Libs.Pad;
+
+///
+/// Keyboard and gamepad state sampled from the presenter's window, feeding
+/// the POSIX host input seam (macOS/Linux have no user32/XInput/raw-HID
+/// readers). The presenter attaches the window's input context once the
+/// window exists; input events arrive on the window thread and pad reads
+/// happen on guest threads, so all state is guarded.
+///
+public static class HostWindowInput
+{
+ private static readonly object Gate = new();
+ private static readonly HashSet Pressed = new();
+ private static volatile bool _connected;
+
+ // Latest window-gamepad snapshot in the host seam's conventions.
+ private static bool _gamepadConnected;
+ private static string? _gamepadName;
+ private static HostGamepadButtons _gamepadButtons;
+ private static byte _gamepadLeftX = 128;
+ private static byte _gamepadLeftY = 128;
+ private static byte _gamepadRightX = 128;
+ private static byte _gamepadRightY = 128;
+ private static byte _gamepadL2;
+ private static byte _gamepadR2;
+
+ /// True once a window keyboard is delivering events.
+ public static bool IsConnected => _connected;
+
+ public static void Attach(IInputContext input)
+ {
+ foreach (var keyboard in input.Keyboards)
+ {
+ keyboard.KeyDown += (_, key, _) =>
+ {
+ lock (Gate)
+ {
+ Pressed.Add(key);
+ }
+ };
+ keyboard.KeyUp += (_, key, _) =>
+ {
+ lock (Gate)
+ {
+ Pressed.Remove(key);
+ }
+ };
+ }
+
+ if (input.Keyboards.Count > 0)
+ {
+ _connected = true;
+ }
+
+ foreach (var gamepad in input.Gamepads)
+ {
+ AttachGamepad(gamepad);
+ }
+
+ input.ConnectionChanged += (device, connected) =>
+ {
+ if (device is not IGamepad gamepad)
+ {
+ return;
+ }
+
+ if (connected)
+ {
+ AttachGamepad(gamepad);
+ return;
+ }
+
+ lock (Gate)
+ {
+ _gamepadConnected = false;
+ _gamepadName = null;
+ _gamepadButtons = HostGamepadButtons.None;
+ _gamepadLeftX = 128;
+ _gamepadLeftY = 128;
+ _gamepadRightX = 128;
+ _gamepadRightY = 128;
+ _gamepadL2 = 0;
+ _gamepadR2 = 0;
+ }
+ };
+
+ PosixHostInput.SetSource(new WindowInputSource());
+ }
+
+ public static bool IsKeyDown(Key key)
+ {
+ lock (Gate)
+ {
+ return Pressed.Contains(key);
+ }
+ }
+
+ private sealed class WindowInputSource : IPosixWindowInputSource
+ {
+ public bool HasKeyboardFocus => _connected;
+
+ public bool IsKeyDown(int virtualKey)
+ {
+ return TryMapVirtualKey(virtualKey, out var key) && HostWindowInput.IsKeyDown(key);
+ }
+
+ public int GetGamepadStates(Span destination)
+ {
+ lock (Gate)
+ {
+ if (!_gamepadConnected || destination.Length == 0)
+ {
+ return 0;
+ }
+
+ destination[0] = new HostGamepadState(
+ Connected: true,
+ Buttons: _gamepadButtons,
+ LeftX: _gamepadLeftX,
+ LeftY: _gamepadLeftY,
+ RightX: _gamepadRightX,
+ RightY: _gamepadRightY,
+ LeftTrigger: _gamepadL2,
+ RightTrigger: _gamepadR2);
+ return 1;
+ }
+ }
+
+ public string? DescribeConnectedGamepad()
+ {
+ lock (Gate)
+ {
+ return _gamepadConnected ? _gamepadName ?? "GLFW gamepad" : null;
+ }
+ }
+ }
+
+ private static bool TryMapVirtualKey(int vk, out Key key)
+ {
+ key = vk switch
+ {
+ 0x08 => Key.Backspace,
+ 0x09 => Key.Tab,
+ 0x0D => Key.Enter,
+ 0x1B => Key.Escape,
+ 0x25 => Key.Left,
+ 0x26 => Key.Up,
+ 0x27 => Key.Right,
+ 0x28 => Key.Down,
+ >= 0x41 and <= 0x5A => Key.A + (vk - 0x41),
+ _ => Key.Unknown,
+ };
+ return key != Key.Unknown;
+ }
+
+ private static void AttachGamepad(IGamepad gamepad)
+ {
+ lock (Gate)
+ {
+ _gamepadConnected = true;
+ _gamepadName = gamepad.Name;
+ }
+
+ gamepad.ButtonDown += (_, button) =>
+ {
+ var bit = MapButton(button.Name);
+ if (bit == HostGamepadButtons.None)
+ {
+ return;
+ }
+
+ lock (Gate)
+ {
+ _gamepadButtons |= bit;
+ }
+ };
+ gamepad.ButtonUp += (_, button) =>
+ {
+ var bit = MapButton(button.Name);
+ if (bit == HostGamepadButtons.None)
+ {
+ return;
+ }
+
+ lock (Gate)
+ {
+ _gamepadButtons &= ~bit;
+ }
+ };
+ gamepad.ThumbstickMoved += (_, thumbstick) =>
+ {
+ // Silk's GLFW backend reports sticks -1..1 with +Y pointing down,
+ // matching the seam's 0..255 down-growing convention after biasing.
+ var x = ToStickByte(thumbstick.X);
+ var y = ToStickByte(thumbstick.Y);
+ lock (Gate)
+ {
+ if (thumbstick.Index == 0)
+ {
+ _gamepadLeftX = x;
+ _gamepadLeftY = y;
+ }
+ else
+ {
+ _gamepadRightX = x;
+ _gamepadRightY = y;
+ }
+ }
+ };
+ gamepad.TriggerMoved += (_, trigger) =>
+ {
+ // GLFW gamepad triggers rest at -1 and saturate at +1.
+ var value = (byte)Math.Clamp((int)((trigger.Position + 1.0f) * 0.5f * 255.0f), 0, 255);
+ lock (Gate)
+ {
+ if (trigger.Index == 0)
+ {
+ _gamepadL2 = value;
+ if (value > 64)
+ {
+ _gamepadButtons |= HostGamepadButtons.L2;
+ }
+ else
+ {
+ _gamepadButtons &= ~HostGamepadButtons.L2;
+ }
+ }
+ else
+ {
+ _gamepadR2 = value;
+ if (value > 64)
+ {
+ _gamepadButtons |= HostGamepadButtons.R2;
+ }
+ else
+ {
+ _gamepadButtons &= ~HostGamepadButtons.R2;
+ }
+ }
+ }
+ };
+ }
+
+ private static byte ToStickByte(float value)
+ {
+ return (byte)Math.Clamp((int)(128.0f + value * 127.0f), 0, 255);
+ }
+
+ private static HostGamepadButtons MapButton(ButtonName name) => name switch
+ {
+ // GLFW reports the Xbox layout: A=Cross, B=Circle, X=Square, Y=Triangle.
+ ButtonName.A => HostGamepadButtons.Cross,
+ ButtonName.B => HostGamepadButtons.Circle,
+ ButtonName.X => HostGamepadButtons.Square,
+ ButtonName.Y => HostGamepadButtons.Triangle,
+ ButtonName.LeftBumper => HostGamepadButtons.L1,
+ ButtonName.RightBumper => HostGamepadButtons.R1,
+ ButtonName.Back => HostGamepadButtons.TouchPad,
+ ButtonName.Start => HostGamepadButtons.Options,
+ ButtonName.LeftStick => HostGamepadButtons.L3,
+ ButtonName.RightStick => HostGamepadButtons.R3,
+ ButtonName.DPadUp => HostGamepadButtons.Up,
+ ButtonName.DPadRight => HostGamepadButtons.Right,
+ ButtonName.DPadDown => HostGamepadButtons.Down,
+ ButtonName.DPadLeft => HostGamepadButtons.Left,
+ _ => HostGamepadButtons.None,
+ };
+}
diff --git a/src/SharpEmu.Libs/Pad/PadExports.cs b/src/SharpEmu.Libs/Pad/PadExports.cs
index 72205c5..54a595f 100644
--- a/src/SharpEmu.Libs/Pad/PadExports.cs
+++ b/src/SharpEmu.Libs/Pad/PadExports.cs
@@ -442,6 +442,11 @@ public static class PadExports
r2 = Math.Max(r2, pad.RightTrigger);
}
+ if (IsAutoCrossActive())
+ {
+ buttons |= 0x4000;
+ }
+
_cachedInputState = new PadState(
Connected: true,
Buttons: buttons,
@@ -455,6 +460,51 @@ public static class PadExports
return _cachedInputState;
}
+ private static readonly long PadStartTimestamp = Stopwatch.GetTimestamp();
+ private static readonly double[] AutoCrossTimes = ParseAutoCrossTimes();
+
+ private static double[] ParseAutoCrossTimes()
+ {
+ // SHARPEMU_AUTO_CROSS="40,52,64": presses Cross for 0.4s at each
+ // second offset from process start. Debug aid for unattended runs.
+ var raw = Environment.GetEnvironmentVariable("SHARPEMU_AUTO_CROSS");
+ if (string.IsNullOrWhiteSpace(raw))
+ {
+ return [];
+ }
+
+ var values = new List();
+ foreach (var token in raw.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
+ {
+ if (double.TryParse(token, System.Globalization.CultureInfo.InvariantCulture, out var value))
+ {
+ values.Add(value);
+ }
+ }
+
+ return values.ToArray();
+ }
+
+ private static bool IsAutoCrossActive()
+ {
+ var times = AutoCrossTimes;
+ if (times.Length == 0)
+ {
+ return false;
+ }
+
+ var elapsed = (Stopwatch.GetTimestamp() - PadStartTimestamp) / (double)Stopwatch.Frequency;
+ foreach (var time in times)
+ {
+ if (elapsed >= time && elapsed < time + 0.4)
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
/// Maps the host seam's neutral button flags onto SCE_PAD_BUTTON bits.
private static uint ToOrbisButtons(HostGamepadButtons buttons)
{
diff --git a/src/SharpEmu.Libs/SharpEmu.Libs.csproj b/src/SharpEmu.Libs/SharpEmu.Libs.csproj
index 4d19ca1..70973f8 100644
--- a/src/SharpEmu.Libs/SharpEmu.Libs.csproj
+++ b/src/SharpEmu.Libs/SharpEmu.Libs.csproj
@@ -13,6 +13,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
+
diff --git a/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.cs b/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.cs
index ee47c7a..1283705 100644
--- a/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.cs
+++ b/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.cs
@@ -4,7 +4,9 @@
using Silk.NET.Core;
using Silk.NET.Core.Native;
using Silk.NET.Maths;
+using SharpEmu.HLE;
using SharpEmu.Libs.Agc;
+using Silk.NET.Input;
using Silk.NET.Vulkan;
using Silk.NET.Vulkan.Extensions.KHR;
using Silk.NET.Vulkan.Extensions.EXT;
@@ -164,7 +166,10 @@ internal static unsafe class VulkanVideoPresenter
private const uint DefaultWindowWidth = 1280;
private const uint DefaultWindowHeight = 720;
private const int MaxPendingGuestWork = 16;
- private const int MaxGuestWorkPerRender = 16;
+ // A single guest frame commonly contains 30-50 translated draws. Limiting
+ // this to 16 split one frame across several 60 Hz window callbacks and
+ // unnecessarily throttled the producer behind the bounded work queue.
+ private const int MaxGuestWorkPerRender = 128;
private const uint GuestPrimitiveRectList = 0x11;
private const uint GuestFormatR32Uint = 0x10004;
private const uint GuestFormatR32Sint = 0x20004;
@@ -189,8 +194,11 @@ internal static unsafe class VulkanVideoPresenter
private static uint _windowWidth;
private static uint _windowHeight;
private static bool _closed;
+ private static bool _presenterCloseRequested;
private const string DebugUtilsExtensionName = "VK_EXT_debug_utils";
private const uint NvidiaVendorId = 0x10DE;
+ private const string PortabilityEnumerationExtensionName = "VK_KHR_portability_enumeration";
+ private const string PortabilitySubsetExtensionName = "VK_KHR_portability_subset";
private static bool _splashHidden;
private static long _enqueuedGuestWorkSequence;
private static long _completedGuestWorkSequence;
@@ -202,6 +210,29 @@ internal static unsafe class VulkanVideoPresenter
string.Equals(mode, "present", StringComparison.OrdinalIgnoreCase);
}
+ private static bool ShouldTraceGuestImageSubmissionsForDiagnostics()
+ {
+ return string.Equals(
+ Environment.GetEnvironmentVariable("SHARPEMU_TRACE_GUEST_IMAGES"),
+ "1",
+ StringComparison.Ordinal);
+ }
+
+ private static bool ShouldSamplePresentedGuestImageForDiagnostics(long frame)
+ {
+ var mode = Environment.GetEnvironmentVariable("SHARPEMU_TRACE_GUEST_IMAGES");
+ if (string.Equals(mode, "present", StringComparison.OrdinalIgnoreCase))
+ {
+ // A 4K Vulkan readback is deliberately synchronous and can take
+ // several seconds on Linux. The lightweight "present" mode only
+ // needs one proof that the final image is non-black.
+ return frame == 1;
+ }
+
+ return string.Equals(mode, "1", StringComparison.Ordinal) &&
+ (frame is 1 or 30 or 120 || frame % 600 == 0);
+ }
+
public static void EnsureStarted(uint width, uint height)
{
if (width == 0 || height == 0)
@@ -259,12 +290,7 @@ internal static unsafe class VulkanVideoPresenter
TranslatedDraw: null,
RequiredGuestWorkSequence: _enqueuedGuestWorkSequence,
IsSplash: false);
- _thread = new Thread(Run)
- {
- IsBackground = true,
- Name = "SharpEmu Vulkan VideoOut",
- };
- _thread.Start();
+ StartPresenterLocked();
}
}
@@ -300,7 +326,7 @@ internal static unsafe class VulkanVideoPresenter
return;
}
- if (ShouldTracePresentedGuestImageContentsForDiagnostics())
+ if (ShouldTraceGuestImageSubmissionsForDiagnostics())
{
Console.Error.WriteLine($"[LOADER][TRACE] vk.submit_call kind=Submit {width}x{height}");
}
@@ -330,12 +356,7 @@ internal static unsafe class VulkanVideoPresenter
_windowWidth = width;
_windowHeight = height;
- _thread = new Thread(Run)
- {
- IsBackground = true,
- Name = "SharpEmu Vulkan VideoOut",
- };
- _thread.Start();
+ StartPresenterLocked();
}
}
@@ -346,7 +367,7 @@ internal static unsafe class VulkanVideoPresenter
return;
}
- if (ShouldTracePresentedGuestImageContentsForDiagnostics())
+ if (ShouldTraceGuestImageSubmissionsForDiagnostics())
{
Console.Error.WriteLine($"[LOADER][TRACE] vk.submit_call kind=SubmitGuestDraw({drawKind}) {width}x{height}");
}
@@ -380,12 +401,7 @@ internal static unsafe class VulkanVideoPresenter
_windowWidth = width;
_windowHeight = height;
- _thread = new Thread(Run)
- {
- IsBackground = true,
- Name = "SharpEmu Vulkan VideoOut",
- };
- _thread.Start();
+ StartPresenterLocked();
}
}
@@ -409,7 +425,7 @@ internal static unsafe class VulkanVideoPresenter
return;
}
- if (ShouldTracePresentedGuestImageContentsForDiagnostics())
+ if (ShouldTraceGuestImageSubmissionsForDiagnostics())
{
Console.Error.WriteLine(
$"[LOADER][TRACE] vk.submit_call kind=SubmitTranslatedDraw {width}x{height} textures={textures.Count}");
@@ -451,12 +467,7 @@ internal static unsafe class VulkanVideoPresenter
_windowWidth = width;
_windowHeight = height;
- _thread = new Thread(Run)
- {
- IsBackground = true,
- Name = "SharpEmu Vulkan VideoOut",
- };
- _thread.Start();
+ StartPresenterLocked();
}
}
@@ -522,7 +533,7 @@ internal static unsafe class VulkanVideoPresenter
return;
}
- if (ShouldTracePresentedGuestImageContentsForDiagnostics())
+ if (ShouldTraceGuestImageSubmissionsForDiagnostics())
{
Console.Error.WriteLine(
$"[LOADER][TRACE] vk.submit_call kind=SubmitOffscreenTranslatedDraw " +
@@ -671,7 +682,7 @@ internal static unsafe class VulkanVideoPresenter
{
// VideoOut registration does not imply a rendered Vulkan image.
var known = _gpuGuestImages.ContainsKey(address);
- if (ShouldTracePresentedGuestImageContentsForDiagnostics())
+ if (ShouldTraceGuestImageSubmissionsForDiagnostics())
{
Console.Error.WriteLine(
$"[LOADER][TRACE] vk.submit_call kind=TrySubmitGuestImage addr=0x{address:X16} " +
@@ -708,7 +719,9 @@ internal static unsafe class VulkanVideoPresenter
sequence,
GuestDrawKind.None,
TranslatedDraw: null,
- RequiredGuestWorkSequence: 0,
+ // A flip targets the image produced by all work already queued
+ // for this frame. Do not expose it until those draws finish.
+ RequiredGuestWorkSequence: _enqueuedGuestWorkSequence,
IsSplash: false,
GuestImageAddress: address);
System.Threading.Monitor.PulseAll(_gate);
@@ -964,6 +977,200 @@ internal static unsafe class VulkanVideoPresenter
return pixels;
}
+ private static void StartPresenterLocked()
+ {
+ if (HostMainThread.IsAvailable)
+ {
+ // GLFW windowing must run on the process main thread (AppKit on
+ // macOS, X11's single event queue on Linux), so hand the whole
+ // window loop to the main-thread pump the CLI parked for us.
+ // _thread only marks the presenter as running; Run() clears it on
+ // exit either way.
+ _thread = Thread.CurrentThread;
+ HostMainThread.SetShutdownRequestHandler(RequestClose);
+ HostMainThread.Post(Run);
+ return;
+ }
+
+ _thread = new Thread(Run)
+ {
+ IsBackground = true,
+ Name = "SharpEmu Vulkan VideoOut",
+ };
+ _thread.Start();
+ }
+
+ ///
+ /// Asks a running presenter to close its window; used at emulator
+ /// shutdown so a main-thread-hosted window loop returns to the pump.
+ ///
+ public static void RequestClose()
+ {
+ Volatile.Write(ref _presenterCloseRequested, true);
+ }
+
+ ///
+ /// GLFW resolves Vulkan with dlopen("libvulkan.1.dylib"), which cannot
+ /// find the app-local MoltenVK on macOS (Homebrew's Vulkan libraries are
+ /// arm64-only and this is an x86-64 process). GLFW 3.4 accepts the
+ /// loader entry point directly instead, so hand it MoltenVK's
+ /// vkGetInstanceProcAddr before any window exists.
+ ///
+ private static unsafe void InitializeMacVulkanLoader()
+ {
+ if (!OperatingSystem.IsMacOS())
+ {
+ return;
+ }
+
+ try
+ {
+ nint vulkan = 0;
+ foreach (var candidate in new[]
+ {
+ Path.Combine(AppContext.BaseDirectory, "libvulkan.1.dylib"),
+ Path.Combine(AppContext.BaseDirectory, "libMoltenVK.dylib"),
+ "libvulkan.1.dylib",
+ "libMoltenVK.dylib",
+ })
+ {
+ if (System.Runtime.InteropServices.NativeLibrary.TryLoad(candidate, out vulkan))
+ {
+ break;
+ }
+ }
+
+ if (vulkan == 0 ||
+ !System.Runtime.InteropServices.NativeLibrary.TryGetExport(
+ vulkan, "vkGetInstanceProcAddr", out var procAddr))
+ {
+ Console.Error.WriteLine(
+ "[LOADER][WARN] No Vulkan loader for GLFW; place a universal libMoltenVK.dylib " +
+ "next to SharpEmu as libvulkan.1.dylib.");
+ return;
+ }
+
+ var glfw = System.Runtime.InteropServices.NativeLibrary.Load(
+ Path.Combine(AppContext.BaseDirectory, "libglfw.3.dylib"));
+ var initVulkanLoader = (delegate* unmanaged)
+ System.Runtime.InteropServices.NativeLibrary.GetExport(glfw, "glfwInitVulkanLoader");
+ initVulkanLoader(procAddr);
+ Console.Error.WriteLine("[LOADER][INFO] GLFW Vulkan loader wired to MoltenVK.");
+ }
+ catch (Exception exception)
+ {
+ Console.Error.WriteLine($"[LOADER][WARN] GLFW Vulkan loader setup failed: {exception.Message}");
+ }
+ }
+
+ // GLFW platform enum (GLFW 3.4): glfwInitHint(GLFW_PLATFORM, ...) selects a
+ // backend, glfwGetPlatform() reports the one in use.
+ private const int GlfwPlatformHint = 0x00050003;
+ private const int GlfwPlatformWin32 = 0x00060001;
+ private const int GlfwPlatformCocoa = 0x00060002;
+ private const int GlfwPlatformWayland = 0x00060003;
+ private const int GlfwPlatformX11 = 0x00060004;
+ private const int GlfwPlatformNull = 0x00060005;
+
+ ///
+ /// GLFW's native Wayland backend does not reliably map the Vulkan window
+ /// with some drivers (notably NVIDIA): the surface presents frames but the
+ /// window never becomes visible, so the game runs with no picture while
+ /// audio works. XWayland is dependable, so on a Wayland session that also
+ /// exposes an X server (DISPLAY set) we force GLFW's X11 backend through
+ /// its GLFW_PLATFORM init hint before GLFW initializes — the supported way
+ /// to pick a backend, applied by calling into the same libglfw GLFW loads.
+ /// Opt back into native Wayland with SHARPEMU_ENABLE_WAYLAND=1.
+ ///
+ private static unsafe void PreferX11OnLinuxWayland()
+ {
+ if (!OperatingSystem.IsLinux() ||
+ string.Equals(
+ Environment.GetEnvironmentVariable("SHARPEMU_ENABLE_WAYLAND"),
+ "1",
+ StringComparison.Ordinal))
+ {
+ return;
+ }
+
+ // Only steer on a Wayland session (WAYLAND_DISPLAY set). Forcing X11
+ // needs an X server to fall back to (XWayland, DISPLAY set); without
+ // one, forcing it would make glfwInit fail outright, so leave GLFW
+ // alone and say why the window may not appear.
+ if (string.IsNullOrEmpty(Environment.GetEnvironmentVariable("WAYLAND_DISPLAY")))
+ {
+ return;
+ }
+
+ if (string.IsNullOrEmpty(Environment.GetEnvironmentVariable("DISPLAY")))
+ {
+ Console.Error.WriteLine(
+ "[LOADER][WARN] Wayland session without an X server (DISPLAY unset); " +
+ "cannot steer GLFW to XWayland. If the window does not appear, install " +
+ "XWayland, or run natively with SHARPEMU_ENABLE_WAYLAND=1.");
+ return;
+ }
+
+ if (!TryLoadGlfw(out var glfw))
+ {
+ return;
+ }
+
+ try
+ {
+ var initHint = (delegate* unmanaged)
+ System.Runtime.InteropServices.NativeLibrary.GetExport(glfw, "glfwInitHint");
+ initHint(GlfwPlatformHint, GlfwPlatformX11);
+ Console.Error.WriteLine(
+ "[LOADER][INFO] Wayland session detected; requested GLFW X11/XWayland " +
+ "backend (set SHARPEMU_ENABLE_WAYLAND=1 to force native Wayland).");
+ }
+ catch (Exception exception)
+ {
+ Console.Error.WriteLine(
+ $"[LOADER][WARN] Could not set GLFW X11 platform hint: {exception.Message}");
+ }
+ }
+
+ /// Logs the backend GLFW actually selected, so a "no window"
+ /// report shows Wayland vs X11 at a glance.
+ private static unsafe void LogGlfwPlatformInUse()
+ {
+ if (OperatingSystem.IsWindows() || !TryLoadGlfw(out var glfw))
+ {
+ return;
+ }
+
+ try
+ {
+ var getPlatform = (delegate* unmanaged)
+ System.Runtime.InteropServices.NativeLibrary.GetExport(glfw, "glfwGetPlatform");
+ var platform = getPlatform();
+ var label = platform switch
+ {
+ GlfwPlatformWin32 => "Win32",
+ GlfwPlatformCocoa => "Cocoa",
+ GlfwPlatformWayland => "Wayland",
+ GlfwPlatformX11 => "X11",
+ GlfwPlatformNull => "Null",
+ _ => $"0x{platform:X}",
+ };
+ Console.Error.WriteLine($"[LOADER][INFO] GLFW windowing platform in use: {label}");
+ }
+ catch (Exception exception)
+ {
+ Console.Error.WriteLine($"[LOADER][WARN] Could not query GLFW platform: {exception.Message}");
+ }
+ }
+
+ private static bool TryLoadGlfw(out nint handle)
+ {
+ var name = OperatingSystem.IsMacOS() ? "libglfw.3.dylib" : "libglfw.so.3";
+ return System.Runtime.InteropServices.NativeLibrary.TryLoad(
+ Path.Combine(AppContext.BaseDirectory, name), out handle) ||
+ System.Runtime.InteropServices.NativeLibrary.TryLoad(name, out handle);
+ }
+
private static void Run()
{
uint width;
@@ -974,6 +1181,9 @@ internal static unsafe class VulkanVideoPresenter
height = _windowHeight == 0 ? _latestPresentation?.Height ?? 720 : _windowHeight;
}
+ InitializeMacVulkanLoader();
+ PreferX11OnLinuxWayland();
+
try
{
using var presenter = new Presenter(width, height);
@@ -1133,6 +1343,8 @@ internal static unsafe class VulkanVideoPresenter
private bool _swapchainRecreateDeferred;
private bool _tracedPresentedSwapchain;
private bool _swapchainReadbackPending;
+ private static int _guestImageDumpSequence;
+ private readonly System.Collections.Concurrent.ConcurrentQueue _pendingAliasImageDumps = new();
private bool _deviceLost;
private bool _deviceLostLogged;
private int _directPresentationCount;
@@ -1330,6 +1542,20 @@ internal static unsafe class VulkanVideoPresenter
_window.SetWindowIcon(ref icon);
}
+ LogGlfwPlatformInUse();
+ if (!OperatingSystem.IsWindows())
+ {
+ try
+ {
+ Pad.HostWindowInput.Attach(_window.CreateInput());
+ Console.Error.WriteLine("[LOADER][INFO] Window keyboard input attached for pad emulation.");
+ }
+ catch (Exception exception)
+ {
+ Console.Error.WriteLine($"[LOADER][WARN] Window keyboard input unavailable: {exception.Message}");
+ }
+ }
+
WaitForRenderDocAttachIfRequested();
_vk = Vk.GetApi();
CreateInstance();
@@ -1542,8 +1768,10 @@ internal static unsafe class VulkanVideoPresenter
var extensions = _window.VkSurface!.GetRequiredExtensions(out var extensionCount);
byte* debugUtilsExtension = null;
+ byte* portabilityExtension = null;
+ var instanceCreateFlags = InstanceCreateFlags.None;
var enabledExtensionCount = (int)extensionCount;
- var enabledExtensions = stackalloc byte*[(int)extensionCount + 1];
+ var enabledExtensions = stackalloc byte*[(int)extensionCount + 2];
for (var index = 0; index < (int)extensionCount; index++)
{
enabledExtensions[index] = extensions[index];
@@ -1555,6 +1783,15 @@ internal static unsafe class VulkanVideoPresenter
enabledExtensions[enabledExtensionCount++] = debugUtilsExtension;
}
+ if (IsInstanceExtensionAvailable(PortabilityEnumerationExtensionName))
+ {
+ // MoltenVK is a portability (non-conformant) implementation;
+ // without this flag + extension the loader hides it.
+ portabilityExtension = (byte*)SilkMarshal.StringToPtr(PortabilityEnumerationExtensionName);
+ enabledExtensions[enabledExtensionCount++] = portabilityExtension;
+ instanceCreateFlags |= InstanceCreateFlags.EnumeratePortabilityBitKhr;
+ }
+
if (enableValidation && IsInstanceLayerAvailable("VK_LAYER_KHRONOS_validation"))
{
validationLayerName = (byte*)SilkMarshal.StringToPtr("VK_LAYER_KHRONOS_validation");
@@ -1573,6 +1810,7 @@ internal static unsafe class VulkanVideoPresenter
var createInfo = new InstanceCreateInfo
{
SType = StructureType.InstanceCreateInfo,
+ Flags = instanceCreateFlags,
PApplicationInfo = &applicationInfo,
EnabledExtensionCount = (uint)enabledExtensionCount,
PpEnabledExtensionNames = enabledExtensions,
@@ -1601,6 +1839,10 @@ internal static unsafe class VulkanVideoPresenter
{
SilkMarshal.Free((nint)debugUtilsExtension);
}
+ if (portabilityExtension is not null)
+ {
+ SilkMarshal.Free((nint)portabilityExtension);
+ }
}
}
finally
@@ -1613,6 +1855,40 @@ internal static unsafe class VulkanVideoPresenter
}
}
+ private bool IsDeviceExtensionAvailable(string extensionName)
+ {
+ uint extensionCount = 0;
+ if (_vk.EnumerateDeviceExtensionProperties(_physicalDevice, (byte*)null, &extensionCount, null) != Result.Success ||
+ extensionCount == 0)
+ {
+ return false;
+ }
+
+ var properties = new ExtensionProperties[extensionCount];
+ fixed (ExtensionProperties* propertyPointer = properties)
+ {
+ if (_vk.EnumerateDeviceExtensionProperties(
+ _physicalDevice,
+ (byte*)null,
+ &extensionCount,
+ propertyPointer) != Result.Success)
+ {
+ return false;
+ }
+
+ var expected = Encoding.UTF8.GetBytes(extensionName);
+ for (var index = 0; index < extensionCount; index++)
+ {
+ if (Utf8NullTerminatedEquals(propertyPointer[index].ExtensionName, expected))
+ {
+ return true;
+ }
+ }
+ }
+
+ return false;
+ }
+
private bool IsInstanceLayerAvailable(string layerName)
{
uint layerCount = 0;
@@ -1748,8 +2024,12 @@ internal static unsafe class VulkanVideoPresenter
_vk.GetPhysicalDeviceProperties(_physicalDevice, out var selected);
_maxColorAttachments = selected.Limits.MaxColorAttachments;
var selectedName = SilkMarshal.PtrToString((nint)selected.DeviceName) ?? "unknown";
+ var apiMajor = (selected.ApiVersion >> 22) & 0x7F;
+ var apiMinor = (selected.ApiVersion >> 12) & 0x3FF;
+ var apiPatch = selected.ApiVersion & 0xFFF;
Console.Error.WriteLine(
- $"[LOADER][INFO] Vulkan device: {selectedName} ({selected.DeviceType})");
+ $"[LOADER][INFO] Vulkan device: {selectedName} " +
+ $"(type={selected.DeviceType}, api={apiMajor}.{apiMinor}.{apiPatch})");
VideoOutExports.SetSelectedGpuName(selectedName);
_window.Title = VideoOutExports.GetWindowTitle();
}
@@ -1859,6 +2139,7 @@ internal static unsafe class VulkanVideoPresenter
};
_vk.GetPhysicalDeviceFeatures2(_physicalDevice, &featuresQuery);
var supportsMaintenance8 = maintenance8Features.Maintenance8;
+ var supportsRobustBufferAccess2 = robustness2Features.RobustBufferAccess2;
var supportsRobustImageAccess2 = robustness2Features.RobustImageAccess2;
var supportsNullDescriptor = robustness2Features.NullDescriptor;
var supportsRobustness2 = supportsRobustImageAccess2 || supportsNullDescriptor;
@@ -1879,9 +2160,10 @@ internal static unsafe class VulkanVideoPresenter
var swapchainExtension = (byte*)SilkMarshal.StringToPtr("VK_KHR_swapchain");
var maintenance8Extension = (byte*)SilkMarshal.StringToPtr("VK_KHR_maintenance8");
var robustness2Extension = (byte*)SilkMarshal.StringToPtr("VK_EXT_robustness2");
+ var portabilitySubsetExtension = (byte*)SilkMarshal.StringToPtr(PortabilitySubsetExtensionName);
try
{
- var extensions = stackalloc byte*[3];
+ var extensions = stackalloc byte*[4];
var extensionCount = 0u;
extensions[extensionCount++] = swapchainExtension;
if (supportsMaintenance8)
@@ -1894,10 +2176,17 @@ internal static unsafe class VulkanVideoPresenter
extensions[extensionCount++] = robustness2Extension;
}
+ if (IsDeviceExtensionAvailable(PortabilitySubsetExtensionName))
+ {
+ // The spec requires enabling this when the (MoltenVK)
+ // device advertises it.
+ extensions[extensionCount++] = portabilitySubsetExtension;
+ }
+
maintenance8Features.Maintenance8 = supportsMaintenance8;
maintenance8Features.PNext = null;
robustness2Features.RobustBufferAccess2 =
- supportsRobustImageAccess2 && supportedFeatures.RobustBufferAccess;
+ supportsRobustBufferAccess2 && supportedFeatures.RobustBufferAccess;
robustness2Features.RobustImageAccess2 = supportsRobustImageAccess2;
robustness2Features.NullDescriptor = supportsNullDescriptor;
robustness2Features.PNext = supportsMaintenance8 ? &maintenance8Features : null;
@@ -1926,6 +2215,7 @@ internal static unsafe class VulkanVideoPresenter
SilkMarshal.Free((nint)swapchainExtension);
SilkMarshal.Free((nint)maintenance8Extension);
SilkMarshal.Free((nint)robustness2Extension);
+ SilkMarshal.Free((nint)portabilitySubsetExtension);
}
_vk.GetDeviceQueue(_device, _queueFamilyIndex, 0, out _queue);
@@ -3371,6 +3661,17 @@ internal static unsafe class VulkanVideoPresenter
$"tile={texture.TileMode} format={vkFormat}");
}
+ if (string.Equals(
+ Environment.GetEnvironmentVariable("SHARPEMU_TRACE_GUEST_IMAGES"),
+ "alias",
+ StringComparison.OrdinalIgnoreCase) &&
+ _tracedGuestImageContents.Add(guestImage.Address))
+ {
+ // Deferred: reading back here would clobber the command
+ // buffer mid-recording; drained after the next present.
+ _pendingAliasImageDumps.Enqueue(guestImage);
+ }
+
if (TryCreateCpuTextureRefreshResource(texture, guestImage, view, out var refresh))
{
return refresh;
@@ -4464,6 +4765,14 @@ internal static unsafe class VulkanVideoPresenter
checked((uint)(bottom - top)));
}
+ private static readonly float ViewportDebugEpsilon = float.TryParse(
+ Environment.GetEnvironmentVariable("SHARPEMU_VIEWPORT_EPSILON"),
+ System.Globalization.NumberStyles.Float,
+ System.Globalization.CultureInfo.InvariantCulture,
+ out var viewportEpsilon)
+ ? viewportEpsilon
+ : 0f;
+
private static Viewport ClampViewport(VulkanGuestViewport? viewport, Extent2D extent)
{
if (viewport is not { } rect)
@@ -4471,21 +4780,26 @@ internal static unsafe class VulkanVideoPresenter
return new Viewport(0, 0, extent.Width, extent.Height, 0, 1);
}
- var maxX = (float)extent.Width;
- var maxY = (float)extent.Height;
- var left = Math.Clamp(rect.X, 0f, maxX);
- var right = Math.Clamp(rect.X + rect.Width, left, maxX);
- var yOrigin = Math.Clamp(rect.Y, 0f, maxY);
- var yEnd = Math.Clamp(rect.Y + rect.Height, 0f, maxY);
+ // Do NOT trim the rectangle to the render target: Vulkan allows
+ // viewports that extend beyond the framebuffer (rendering is
+ // confined by the scissor), and trimming changes the guest's
+ // scale and offset. That skews texel addressing on 1:1 draws -
+ // source rows get skipped or duplicated - which shredded the
+ // game's pre-composed tile surfaces. Only guard what the spec
+ // requires: a positive width and hardware viewport bounds.
+ const float bound = 32767f;
+ var x = Math.Clamp(rect.X, -bound, bound);
+ var y = Math.Clamp(rect.Y, -bound, bound);
+ var width = Math.Clamp(rect.Width, 1e-3f, bound);
+ var height = Math.Clamp(rect.Height, -bound, bound);
+ if (height == 0f)
+ {
+ height = extent.Height;
+ }
+
var minDepth = Math.Clamp(rect.MinDepth, 0f, 1f);
var maxDepth = Math.Clamp(rect.MaxDepth, minDepth, 1f);
- return new Viewport(
- left,
- yOrigin,
- right - left,
- yEnd - yOrigin,
- minDepth,
- maxDepth);
+ return new Viewport(x, y, width, height, minDepth, maxDepth);
}
private static byte[] CreateFallbackTexturePixels(uint format, uint width, uint height, ulong expectedSize)
@@ -5075,10 +5389,12 @@ internal static unsafe class VulkanVideoPresenter
}
}
}
-
foreach (var target in targets)
{
- if (ShouldTraceGuestImageWriteForDiagnostics(target.Address))
+ var traceSmallWrites =
+ Environment.GetEnvironmentVariable("SHARPEMU_TRACE_GUEST_WRITES") == "small" &&
+ target.Width <= 512 && target.Height <= 256;
+ if (ShouldTraceGuestImageWriteForDiagnostics(target.Address) || traceSmallWrites)
{
var writeCount = _tracedGuestWriteCounts.TryGetValue(
target.Address,
@@ -5086,7 +5402,7 @@ internal static unsafe class VulkanVideoPresenter
? previousCount + 1
: 1;
_tracedGuestWriteCounts[target.Address] = writeCount;
- if (writeCount <= 3)
+ if (writeCount <= (traceSmallWrites ? 48 : 3))
{
_commandBuffer = _presentationCommandBuffer;
Check(
@@ -5599,7 +5915,7 @@ internal static unsafe class VulkanVideoPresenter
private void UpdatePerformanceHud()
{
- if (!_performanceHudEnabled || !OperatingSystem.IsWindows())
+ if (!_performanceHudEnabled)
{
return;
}
@@ -5621,28 +5937,34 @@ internal static unsafe class VulkanVideoPresenter
var hottestThreadId = 0;
var hottestThreadCpuSeconds = 0.0;
- foreach (ProcessThread thread in process.Threads)
+ // Per-thread CPU times and thread names come from Windows-only
+ // APIs; on POSIX the HUD reports process totals with an "idle"
+ // hottest-thread slot.
+ if (OperatingSystem.IsWindows())
{
- using (thread)
+ foreach (ProcessThread thread in process.Threads)
{
- try
+ using (thread)
{
- var threadId = thread.Id;
- var cpu = thread.TotalProcessorTime;
- currentThreadIds.Add(threadId);
- currentThreadCpu[threadId] = cpu;
- if (_performanceHudThreadCpu.TryGetValue(threadId, out var previousCpu))
+ try
{
- var deltaSeconds = Math.Max(0.0, (cpu - previousCpu).TotalSeconds);
- if (deltaSeconds > hottestThreadCpuSeconds)
+ var threadId = thread.Id;
+ var cpu = thread.TotalProcessorTime;
+ currentThreadIds.Add(threadId);
+ currentThreadCpu[threadId] = cpu;
+ if (_performanceHudThreadCpu.TryGetValue(threadId, out var previousCpu))
{
- hottestThreadCpuSeconds = deltaSeconds;
- hottestThreadId = threadId;
+ var deltaSeconds = Math.Max(0.0, (cpu - previousCpu).TotalSeconds);
+ if (deltaSeconds > hottestThreadCpuSeconds)
+ {
+ hottestThreadCpuSeconds = deltaSeconds;
+ hottestThreadId = threadId;
+ }
}
}
- }
- catch (InvalidOperationException)
- {
+ catch (InvalidOperationException)
+ {
+ }
}
}
}
@@ -5812,6 +6134,12 @@ internal static unsafe class VulkanVideoPresenter
private void Render(double _)
{
+ if (Volatile.Read(ref _presenterCloseRequested))
+ {
+ _window.Close();
+ return;
+ }
+
if (!_vulkanReady)
{
return;
@@ -5884,6 +6212,7 @@ internal static unsafe class VulkanVideoPresenter
TranslatedDrawResources? translatedResources = null;
GuestImageResource? presentedGuestImage = null;
+ var tracePresentedGuestImage = false;
if (presentation.GuestImageAddress != 0 &&
(!_guestImages.TryGetValue(
presentation.GuestImageAddress,
@@ -5895,13 +6224,13 @@ internal static unsafe class VulkanVideoPresenter
if (presentedGuestImage is not null)
{
_directPresentationCount++;
- if (ShouldTracePresentedGuestImageContentsForDiagnostics() &&
- _directPresentationCount is 1 or 30 or 120)
+ if (ShouldSamplePresentedGuestImageForDiagnostics(
+ _directPresentationCount))
{
+ tracePresentedGuestImage = true;
Console.Error.WriteLine(
$"[LOADER][TRACE] vk.present_sample frame={_directPresentationCount} " +
$"addr=0x{presentedGuestImage.Address:X16}");
- TraceGuestImageContents(presentedGuestImage);
}
}
@@ -6089,6 +6418,19 @@ internal static unsafe class VulkanVideoPresenter
CompletePendingPresentation(wait: true);
TraceSwapchainReadback();
}
+ // Report the actual presented pixels before starting the larger
+ // source-image readback. If a guest draw wedges the GPU, the
+ // source probe can block in vkQueueWaitIdle; doing it first used
+ // to hide whether the swapchain itself was black and made the
+ // diagnostic run stop immediately after vk.present_sample.
+ if (tracePresentedGuestImage && presentedGuestImage is not null)
+ {
+ TraceGuestImageContents(presentedGuestImage);
+ }
+ while (_pendingAliasImageDumps.TryDequeue(out var aliasImage))
+ {
+ TraceGuestImageContents(aliasImage);
+ }
CollectCompletedGuestSubmissions(waitForOldest: false);
_imageInitialized[imageIndex] = true;
@@ -6132,7 +6474,8 @@ internal static unsafe class VulkanVideoPresenter
var bytesPerPixel = GetReadbackBytesPerPixel(image.Format);
if (bytesPerPixel == 0)
{
- TraceVulkanShader(
+ Console.Error.WriteLine(
+ "[LOADER][TRACE] " +
$"vk.guest_image addr=0x{image.Address:X16} " +
$"format={image.Format} readback=unsupported");
return;
@@ -6267,7 +6610,8 @@ internal static unsafe class VulkanVideoPresenter
(int)bytesPerPixel);
var center = Convert.ToHexString(
bytes.Slice(centerOffset, (int)bytesPerPixel));
- TraceVulkanShader(
+ Console.Error.WriteLine(
+ "[LOADER][TRACE] " +
$"vk.guest_image addr=0x{image.Address:X16} " +
$"size={image.Width}x{image.Height} format={image.Format} " +
$"nonzero_bytes={nonzeroBytes}/{byteCount} " +
@@ -6299,9 +6643,10 @@ internal static unsafe class VulkanVideoPresenter
}
Directory.CreateDirectory(directory);
+ var sequence = Interlocked.Increment(ref _guestImageDumpSequence);
var path = Path.Combine(
directory,
- $"0x{image.Address:X16}-{image.Width}x{image.Height}-{image.Format}.rgba");
+ $"{sequence:D4}-0x{image.Address:X16}-{image.Width}x{image.Height}-{image.Format}.rgba");
File.WriteAllBytes(path, bytes.ToArray());
}
@@ -6387,8 +6732,14 @@ internal static unsafe class VulkanVideoPresenter
continue;
}
- var hasPriorContents = texture.GuestImage is { } guestImage &&
- (guestImage.Initialized || guestImage.InitialUploadPending);
+ // InitialUploadPending means this upload still has to perform
+ // the image's first layout transition. Treating it as prior
+ // contents records ShaderReadOnlyOptimal as oldLayout even
+ // though a freshly created image is still Undefined. Linux
+ // validation reports VUID-vkCmdDraw-None-09600 and NVIDIA
+ // samples the uninitialized (black) image in that case.
+ var hasPriorContents =
+ texture.GuestImage is { Initialized: true };
var toTransfer = new ImageMemoryBarrier
{
SType = StructureType.ImageMemoryBarrier,
@@ -6754,6 +7105,11 @@ internal static unsafe class VulkanVideoPresenter
}
var drawViewport = ClampViewport(resources.Viewport, extent);
+ if (ViewportDebugEpsilon != 0f)
+ {
+ drawViewport.X += ViewportDebugEpsilon;
+ drawViewport.Y += ViewportDebugEpsilon;
+ }
_vk.CmdSetViewport(_commandBuffer, 0, 1, &drawViewport);
if (resources.VertexBuffers.Length != 0)
{
@@ -6989,7 +7345,15 @@ internal static unsafe class VulkanVideoPresenter
var sourceToTransfer = new ImageMemoryBarrier
{
SType = StructureType.ImageMemoryBarrier,
- SrcAccessMask = AccessFlags.ShaderReadBit,
+ // An offscreen target is last written as a color attachment,
+ // then put in ShaderReadOnlyOptimal for later sampling. A
+ // layout-only handoff to ShaderRead does not make that write
+ // visible to this transfer when no shader sample occurs in
+ // between. NVIDIA's Linux driver exposed the resulting stale
+ // (usually black) image while Windows drivers happened to
+ // tolerate it. Include all preceding writes before blitting
+ // the image into the swapchain.
+ SrcAccessMask = AccessFlags.MemoryWriteBit | AccessFlags.ShaderReadBit,
DstAccessMask = AccessFlags.TransferReadBit,
OldLayout = ImageLayout.ShaderReadOnlyOptimal,
NewLayout = ImageLayout.TransferSrcOptimal,
@@ -7060,6 +7424,14 @@ internal static unsafe class VulkanVideoPresenter
1),
DstOffsets = destinationOffsets,
};
+ // Nearest keeps integer upscales pixel-crisp, but any fractional
+ // scale (e.g. a 3840x2160 guest frame into a 2560x1440 swapchain)
+ // must blend neighbours or it silently drops every Nth source
+ // row/column, which shreds 1-2px features in the guest frame.
+ var isIntegerUpscale =
+ source.Width != 0 && source.Height != 0 &&
+ _extent.Width >= source.Width && _extent.Height >= source.Height &&
+ _extent.Width % source.Width == 0 && _extent.Height % source.Height == 0;
_vk.CmdBlitImage(
_commandBuffer,
source.Image,
@@ -7068,7 +7440,7 @@ internal static unsafe class VulkanVideoPresenter
ImageLayout.TransferDstOptimal,
1,
®ion,
- Filter.Nearest);
+ isIntegerUpscale ? Filter.Nearest : Filter.Linear);
if (traceDestination)
{
diff --git a/src/SharpEmu.Libs/packages.lock.json b/src/SharpEmu.Libs/packages.lock.json
index 4a4f120..efa61e0 100644
--- a/src/SharpEmu.Libs/packages.lock.json
+++ b/src/SharpEmu.Libs/packages.lock.json
@@ -2,6 +2,16 @@
"version": 2,
"dependencies": {
"net10.0": {
+ "Silk.NET.Input": {
+ "type": "Direct",
+ "requested": "[2.23.0, )",
+ "resolved": "2.23.0",
+ "contentHash": "Xzl+tVwAp2eEd8blmGQjmJrsZPnp3PWG0KJjiAQHaY2Zr/ELVWeAROKXmZdCAvexzmte2JVGEy/dxnMycbxlpg==",
+ "dependencies": {
+ "Silk.NET.Input.Common": "2.23.0",
+ "Silk.NET.Input.Glfw": "2.23.0"
+ }
+ },
"Silk.NET.Vulkan": {
"type": "Direct",
"requested": "[2.23.0, )",
@@ -69,6 +79,23 @@
"Ultz.Native.GLFW": "3.4.0"
}
},
+ "Silk.NET.Input.Common": {
+ "type": "Transitive",
+ "resolved": "2.23.0",
+ "contentHash": "QbJVV7kFBHEByayXCYdJtXXI9Sp4a+QAf0IdGV6uCWkFYcEmqBYW3aaNGvFOdSwTBDbHL5T/OtOCrGh4qYhk7A==",
+ "dependencies": {
+ "Silk.NET.Windowing.Common": "2.23.0"
+ }
+ },
+ "Silk.NET.Input.Glfw": {
+ "type": "Transitive",
+ "resolved": "2.23.0",
+ "contentHash": "KGHYqsv/IQRJtD6dloYh2tN4CkaM40vxM2kj0cGKBoCQiBDYHHhJiyDTyMPx0W7Fz5IgnhnG42ELmIAa0DH69A==",
+ "dependencies": {
+ "Silk.NET.Input.Common": "2.23.0",
+ "Silk.NET.Windowing.Glfw": "2.23.0"
+ }
+ },
"Silk.NET.Maths": {
"type": "Transitive",
"resolved": "2.23.0",
diff --git a/tests/SharpEmu.Libs.Tests/packages.lock.json b/tests/SharpEmu.Libs.Tests/packages.lock.json
index 6114506..82aca41 100644
--- a/tests/SharpEmu.Libs.Tests/packages.lock.json
+++ b/tests/SharpEmu.Libs.Tests/packages.lock.json
@@ -81,6 +81,23 @@
"Ultz.Native.GLFW": "3.4.0"
}
},
+ "Silk.NET.Input.Common": {
+ "type": "Transitive",
+ "resolved": "2.23.0",
+ "contentHash": "QbJVV7kFBHEByayXCYdJtXXI9Sp4a+QAf0IdGV6uCWkFYcEmqBYW3aaNGvFOdSwTBDbHL5T/OtOCrGh4qYhk7A==",
+ "dependencies": {
+ "Silk.NET.Windowing.Common": "2.23.0"
+ }
+ },
+ "Silk.NET.Input.Glfw": {
+ "type": "Transitive",
+ "resolved": "2.23.0",
+ "contentHash": "KGHYqsv/IQRJtD6dloYh2tN4CkaM40vxM2kj0cGKBoCQiBDYHHhJiyDTyMPx0W7Fz5IgnhnG42ELmIAa0DH69A==",
+ "dependencies": {
+ "Silk.NET.Input.Common": "2.23.0",
+ "Silk.NET.Windowing.Glfw": "2.23.0"
+ }
+ },
"Silk.NET.Maths": {
"type": "Transitive",
"resolved": "2.23.0",
@@ -168,6 +185,7 @@
"type": "Project",
"dependencies": {
"SharpEmu.HLE": "[1.0.0, )",
+ "Silk.NET.Input": "[2.23.0, )",
"Silk.NET.Vulkan": "[2.23.0, )",
"Silk.NET.Vulkan.Extensions.EXT": "[2.23.0, )",
"Silk.NET.Vulkan.Extensions.KHR": "[2.23.0, )",
@@ -183,6 +201,16 @@
"resolved": "1.21.0",
"contentHash": "dv5+81Q1TBQvVMSOOOmRcjJmvWcX3BZPZsIq31+RLc5cNft0IHAyNlkdb7ZarOWG913PyBoFDsDXoCIlKmLclg=="
},
+ "Silk.NET.Input": {
+ "type": "CentralTransitive",
+ "requested": "[2.23.0, )",
+ "resolved": "2.23.0",
+ "contentHash": "Xzl+tVwAp2eEd8blmGQjmJrsZPnp3PWG0KJjiAQHaY2Zr/ELVWeAROKXmZdCAvexzmte2JVGEy/dxnMycbxlpg==",
+ "dependencies": {
+ "Silk.NET.Input.Common": "2.23.0",
+ "Silk.NET.Input.Glfw": "2.23.0"
+ }
+ },
"Silk.NET.Vulkan": {
"type": "CentralTransitive",
"requested": "[2.23.0, )",