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, )",